Adding "Hold Parent Note" functionality/ "Extending the duration of a note being played"

178 views Asked by At

Overall, this script provides a simple way to input musical notes and have them played as sound through the computer's speakers. The notation allows for various musical constructs such as pauses, note duration modifiers, and holding parent notes.

Below is the relevant part of the entire code.

Examples:

  1. A6 E3 A4
  2. A6 | E3 | A4 |
  3. < A6 E3 A4 >
  4. [ A6 E3 A4 ]
  5. This does not work: A6 + ( E3 A4 )

(Briefly, | stands for a pause, <...> indicates ritardando, [...] is accelerando.)

Explaination for 5.: What I want and can't achieve: Playing a parent-note while child/children-notes are playing sequentially. Here, E3 is the Parent-Note, A4, E3, A6 are Child-Notes.

[    A6    ]
------------
[ A4 E3 A6 ]

This shows that A6 is held till A4 and other keys are done playing. This is represented by A6 + ( A4 E3 A6 ). A6-Parent +Hold what notes (The notes in brackets A4 E3 A6-Child Notes )

Mixed example: A6 E3 < A4 > | [ A6 ] E3 + ( A4 E3 A6 )

What I tried: Threading (I tried, but to no avail) and another function to play the parent note for the period till the child notes are playing; but I'm apparently still too new at this.

import sounddevice as sd
import numpy as np
import time
import re

# Default duration for playing notes
default_duration = 0.5
# Default wait time between notes
default_wait_time = 0.1
# Default pause time for '|'
default_pause_time = 0.5

# Variable to store the last output
last_output = ""

# Flag to track if it's the first input
first_input = True

# Dictionary mapping musical notes to corresponding keyboard keys
note_to_key = {'A6': 'b', 'E3': '0', 'A4': 'p', '|' : '|', " ":" "}
# Dictionary mapping musical notes to their frequencies
frequencies = { 'A6': 1760.00, 'E3': 164.81, 'A4': 440.00}

# Dictionary mapping keyboard keys back to musical notes
key_to_note = {v: k for k, v in note_to_key.items()}
# Function to play a single note
def play_note(note, duration=None, wait_time=None, reverse_dict=False, hold_parent=None):
    """
    Play a musical note or a sequence of notes.

    Parameters:
    - note: The note or sequence of notes to be played.
    - duration: The duration of each note.
    - wait_time: The time to wait between notes.
    - reverse_dict: If True, reverse the note_to_key dictionary.
    - hold_parent: If provided, hold the specified parent note.

    Note: The function uses sounddevice to play audio and numpy for waveform generation.
    """
    # Handling default values for duration and wait_time
    if duration is None:
        duration = default_duration
    if wait_time is None:
        wait_time = default_wait_time
    # Check for special characters in the note
    if '|' in note:
        time.sleep(duration)
    # Pause for the specified duration
    elif '+' in note:
    # Handle holding a parent note and playing child notes
        parent_key, child_notes = note.split('+')
        play_note(child_notes[:-1], duration=duration, hold_parent=parent_key)
    # Handle hastening or slowing down notes within brackets
    elif '[' in note or '<' in note:
        if note[0] == '[':
            duration_factor = 0.5
        elif note[0] == '<':
            duration_factor = 2.0
        else:
            duration_factor = 1.0
    # Extract notes inside brackets
        notes_inside = re.findall(r'\S+', note[1:-1])
        for note_inside in notes_inside:
            play_note(note_inside, duration=duration_factor * duration)
    else:
    # Play a single note
        if reverse_dict:
        # Reverse lookup in the key_to_note dictionary
            try:
                if note in key_to_note:
                    note = key_to_note[note]
                else:
                    print(f"Note not found in reversed dictionary: {note}")
                    return
            except KeyError:
                print(f"Wrong Note/Key: {note}")
                return
        # Get the frequency of the note
        frequency = frequencies[note]
        fs = 44100
        t = np.linspace(0, duration, int(fs * duration), False)
        waveform = 0.5 * np.sin(2 * np.pi * frequency * t)
        # Play the waveform using sounddevice
        sd.play(waveform, fs)
        sd.wait()
        time.sleep(wait_time)

# Main loop for user interaction
while True:
    prompt = ">" if not first_input else "Select Mode \n3. Play Notes\n7. Exit\nH: Help\n> "
    mode = input(prompt)
    first_input = False
    # User wants to play notes
    if mode == '3':
        input_sequence = input("Enter Notes: ")
        items = re.findall(r'\[.*?\]|\{.*?\}|<.*?>|\S+', input_sequence)
        # Check if all items are valid notes or keys
        if all(item in frequencies or item in note_to_key.values() or re.match(r'[\[<{].*?[\]>}]', item) for item in items):
            # Play each item in the sequence
            for item in items:
                play_note(item)
        else:
            print("Invalid input. Please enter either piano notes or keyboard keys.")
    # Display help information
    elif mode.lower() == 'h':
        print("'|' \tPauses Briefly \n<>' \tSlows Notes Within Them \n'[]' \tHastens Notes Within Them \n'+()' \tHolds Parent Within Them ")
    
    elif mode == '7':
        # User wants to exit the program
        print("Exiting the program. Goodbye!")
        break
        
    else:
        # Invalid mode selected
        print("Invalid mode. Please enter 3, 7 or H.")

Problematic Code-Part (According to me):

def play_note(note, duration=None, wait_time=None, reverse_dict=False, hold_parent=None):
....
    elif '+' in note:
        parent_key, child_notes = note.split('+')
        play_note(child_notes[:-1], duration=duration, hold_parent=parent_key)

End notes:

  1. Mixing the brackets and pause don't work for unknown reasons.
  2. The title might be irrelevant to what I am asking, but, I'll change if better suggestions are given.
  3. There might be some irrelevant code-pieces left, if so, I'll edit and remove them.
  4. QWERTY Inputs are not accepted, rather, refer to the notes/frequencies dictionaries.

5. I'm not a music/python major, just enthusiastic about both. I don't know the terms, so, anyone is free to correct the wrongs.

Excess information for clarity/ Breakdown of the script:

  1. Default Settings

    default_duration: Default duration for playing notes (0.5 seconds).

    default_wait_time: Default wait time between notes (0.1 seconds).

  2. Note and Frequency Mapping

    note_to_key: Dictionary mapping musical notes to corresponding keyboard keys.

    frequencies: Dictionary mapping musical notes to their frequencies.

  3. Key to Note Mapping

    key_to_note: Dictionary mapping keyboard keys back to musical notes.

  4. play_note Function

    Plays a single note or a sequence of notes.

  5. Parameters

    note: The note or sequence of notes to be played.

    duration: The duration of each note.

    wait_time: The time to wait between notes.

    reverse_dict: If True, reverse the note_to_key dictionary.

    hold_parent: If provided, holds the specified parent note.

  6. Main Loop

    Enters a continuous loop for user interaction.

    Asks the user to select a mode: play notes (3), display help (H), or exit (7).

  7. User Interaction

    If the user selects mode 3 (Play Notes): Prompts the user to enter a sequence of notes. Parses the input using regular expressions. Calls the play_note function for each item in the sequence.

  8. Help Information

    If the user selects mode H (Help), displays information about special characters used in the input sequence.

  9. Exiting the Program

    If the user selects mode 7 (Exit), prints a goodbye message and exits the program.

1

There are 1 answers

1
Reinderien On BEST ANSWER

I went a little overboard here, because this covers a lot more than just the narrow problem of "parent notes".

first_input needs to go away.

note_to_key is useless, and you only need a key_to_note.

It's not useful to have a frequencies table - it's more code than necessary, and less accurate than just calculating the exact frequency on the fly.

play_note is troubled because it takes on more responsibilities than it should - in this case, parsing and playing.

Not a good idea to time.sleep, and also not a good idea to individually sd.play(). You've probably noticed that even without a wait_time, there are gaps between the notes. This can be avoided with the use of a proper continuous buffer.

Your use of t = np.linspace introduces error because it includes the endpoint when it shouldn't.

Rather than sd.wait(), you can just enable blocking behaviour when you play().

I think it's a little obtrusive to ask for a numeric mode on every loop iteration. Exit is just via ctrl+C; help can be printed at the beginning, and in all cases you should just be in the equivalent of "play notes" mode.

The idea of a specialized "parent" note is not particularly useful. Instead, set up a tree where you have a generalised "sustain" binary operator that sets the left and right operands to play simultaneously.

The following does successfully fix the original "parent" problem, and departs a fair distance from the design of the original program. For instance, it can accommodate expressions like

<<6 + * + 0>> + (a4 c#5 d5 e5)

which parses to

Tokens: SlowOpen SlowOpen A2 Sustain C♯3 Sustain E3 Close Close Sustain GroupOpen A4 C#5 D5 E5 Close
Tree: (((A2+C♯3+E3))+(A4, C#5, D5, E5))

or even

a4b4c#5a4 a4b4c#5a4 c#5d5<e5> c#5d5<e5> [e5f#5e5d5]c#5a4 [e5f#5e5d5]c#5a4 a4e4<a4> a4e4<a4>

or even

<<f#4 + c#5>>+([|||[c#6 b5]] c#6 f#5) <<b3 + f#4>>+([|||[d6 c#6 d6| c#6|]] b5)
from typing import Callable, ClassVar, Iterable, Iterator, Optional, Union, Sequence

import sounddevice
import numpy as np
import re


F_SAMPLE = 44_100


class Token:
    def __init__(self) -> None:
        self.duration: float | None = None

    def set_duration(self, parent: 'TokenGroup') -> None:
        pass

    def render(self) -> Iterable[np.ndarray]:
        return ()


class Note(Token):
    # D#3 cannot be a parenthesis; that's reserved for groups. It's been changed to '-'
    KEY_TO_NOTE: ClassVar[dict[str, str]] = {
        '1': 'C2', '2': 'D2', '3': 'E2', '4': 'F2', '5': 'G2', '6': 'A2', '7': 'B2',
        '8': 'C3', '9': 'D3', '0': 'E3', 'q': 'F3', 'w': 'G3', 'e': 'A3', 'r': 'B3',
        't': 'C4', 'y': 'D4', 'u': 'E4', 'i': 'F4', 'o': 'G4', 'p': 'A4', 'a': 'B4',
        's': 'C5', 'd': 'D5', 'f': 'E5', 'g': 'F5', 'h': 'G5', 'j': 'A5', 'k': 'B5',
        'l': 'C6', 'z': 'D6', 'x': 'E6', 'c': 'F6', 'v': 'G6', 'b': 'A6', 'n': 'B6',
        'm': 'C7',
            '!': 'C♯2', '@': 'D♯2',          '$': 'F♯2', '%': 'G♯2', '^': 'A♯2',
            '*': 'C♯3', '-': 'D♯3',          'Q': 'F♯3', 'W': 'G♯3', 'E': 'A♯3',
            'T': 'C♯4', 'Y': 'D♯4',          'I': 'F♯4', 'O': 'G♯4', 'P': 'A♯4',
            'S': 'C♯5', 'D': 'D♯5',          'G': 'F♯5', 'H': 'G♯5', 'J': 'A♯5',
            'L': 'C♯6', 'Z': 'D♯6',          'C': 'F♯6', 'V': 'G♯6', 'B': 'A♯6',
    }

    KEY_PAT: ClassVar[re.Pattern] = re.compile(
        r'''(?x)  # verbose, case-sensitive
            \s*   # ignore whitespace
            (?P<key>  # capture
                ['''  # character class
                + re.escape(''.join(KEY_TO_NOTE.keys()))
                + r''']
            )
        '''
    )

    ACCIDENTALS: ClassVar[dict[str, int]] = {
        '♭': -1, 'b': -1,  # Canonical: U+266D
        '♮': 0,            # Canonical: U+266E
        '♯': 1, '#': 1,    # Canonical: U+266F
    }

    NOTE_PAT: ClassVar[re.Pattern] = re.compile(
        r'''(?xi)  # verbose, case-insensitive
            \s*               # ignore whitespace
            (?P<note>         # note within octave
                [a-g]
            )
            (?P<accidental>   # flat or sharp, optional
                ['''
                + re.escape(''.join(ACCIDENTALS.keys()))
                + r''']
            )?
            (?P<octave>
                \d+           # one or more octave digits
            )
        '''
    )

    SEMITONES: ClassVar[dict[str, int]] = {
        'c': -9, 'd': -7, 'e': -5, 'f': -4, 'g': -2, 'a': 0, 'b': 2,
    }
    A0: ClassVar[float] = 27.5

    def __init__(self, name: str, freq: float) -> None:
        super().__init__()
        self.name = name
        self.freq = freq

    @classmethod
    def map_key(cls, match: re.Match) -> 'Note':
        return cls.from_name(name=cls.KEY_TO_NOTE[match['key']])

    @classmethod
    def map_name(cls, match: re.Match) -> 'Note':
        semitone = cls.SEMITONES[match['note'].lower()]
        accidental = cls.ACCIDENTALS[match['accidental'] or '♮']
        octave = int(match['octave'])
        note = octave*12 + semitone + accidental
        freq = cls.A0 * 2**(note/12)
        name = match[0].strip().upper()  # Not canonicalised

        return cls(name=name, freq=freq)

    @classmethod
    def from_name(cls, name: str) -> 'Note':
        return cls.map_name(cls.NOTE_PAT.match(name))

    def set_duration(self, parent: 'TokenGroup') -> None:
        self.duration = parent.duration

    @property
    def omega(self) -> float:
        return 2*np.pi*self.freq

    def render(self) -> tuple[np.ndarray]:
        t = np.arange(0, self.duration, 1/F_SAMPLE)
        y = 0.1*np.sin(self.omega*t)
        return y,

    def __str__(self) -> str:
        return self.name


class SimpleToken(Token):
    def __init__(self, match: re.Match) -> None:
        super().__init__()

    def __str__(self) -> str:
        return type(self).__name__


class Rest(SimpleToken):
    PAT = re.compile(r'\s*\|')

    def set_duration(self, parent: 'TokenGroup') -> None:
        self.duration = parent.wait_time

    def render(self) -> tuple[np.ndarray]:
        return np.zeros(round(self.duration * F_SAMPLE)),


class Sustain(SimpleToken):
    PAT = re.compile(r'\s*\+')


class GroupOpen(SimpleToken):
    PAT = re.compile(r'\s*\(')
    speed_factor = 1


class SlowOpen(GroupOpen):
    PAT = re.compile(r'\s*<')
    speed_factor = 2


class FastOpen(GroupOpen):
    PAT = re.compile(r'\s*\[')
    speed_factor = 0.5


class Close(SimpleToken):
    PAT = re.compile(r'\s*[)>\]]')


class TokenError(Exception, SimpleToken):
    pass


class Tree:
    def __init__(self) -> None:
        self.token_queue: list[Token] = []

    def consume(self, content: str) -> None:
        self.token_queue.extend(tokenize(content))

    def describe_queue(self) -> Iterator[str]:
        for token in self.token_queue:
            yield str(token)

    def build(self) -> 'TokenGroup':
        root = TokenGroup()
        root.build(tokens=group_recurse(queue=iter(self.token_queue), parent=root))
        self.token_queue.clear()
        root.build_sustain()
        return root


class TokenGroup:
    def __init__(
        self,
        parent: Optional['TokenGroup'] = None,
        open_token: Optional[GroupOpen] = None,
    ) -> None:
        if parent is None:
            duration = 0.5
            wait_time = 0.5
        else:
            duration = parent.duration
            wait_time = parent.wait_time
        if open_token is None:
            speed_factor = 1
        else:
            speed_factor = open_token.speed_factor
        self.duration = speed_factor * duration
        self.wait_time = speed_factor * wait_time
        self.tokens: list[Token | TokenGroup] = []

    def build(self, tokens: Iterable[Union[Token, 'TokenGroup']]) -> None:
        self.tokens.extend(tokens)

    def build_sustain(self) -> None:
        self.tokens = sustain_recurse(self.tokens)

    def render(self) -> Iterator[np.ndarray]:
        for token in self.tokens:
            yield from token.render()

    def __str__(self) -> str:
        return '(' + ', '.join(str(t) for t in self.tokens) + ')'


class SustainGroup(TokenGroup):
    def __init__(self, left: Token, right: Token) -> None:
        self.tokens = (left, right)

    def __str__(self) -> str:
        return f'{self.tokens[0]}+{self.tokens[1]}'

    def render(self) -> tuple[np.ndarray]:
        segments = [
            np.concatenate(tuple(t.render()))
            for t in self.tokens
        ]
        size = max(s.size for s in segments)
        total = np.zeros(shape=size, dtype=segments[0].dtype)
        for segment in segments:
            total[:segment.size] += segment
        return total,


TOKENS: tuple[
    tuple[
        re.Pattern,
        Callable[[re.Match], Token],
    ], ...
] = (  # In decreasing order of priority
    (Note.NOTE_PAT, Note.map_name),
    (Rest.PAT, Rest),
    (Sustain.PAT, Sustain),
    (GroupOpen.PAT, GroupOpen),
    (SlowOpen.PAT, SlowOpen),
    (FastOpen.PAT, FastOpen),
    (Close.PAT, Close),
    (Note.KEY_PAT, Note.map_key),
)


def tokenize(content: str) -> Iterator[Token]:
    pos = 0
    while pos < len(content):
        for pat, map_content in TOKENS:
            match = pat.match(string=content, pos=pos)
            if match is not None:
                try:
                    yield map_content(match)
                except KeyError as e:
                    yield TokenError(str(e))
                pos = match.end()
                break
        else:
            yield TokenError(f'Error: "{content[pos:]}"')
            return


def group_recurse(
    queue: Iterator[Token],
    parent: TokenGroup | None = None,
) -> Iterator[Token | TokenGroup]:
    for token in queue:
        if isinstance(token, Close):
            break
        if isinstance(token, GroupOpen):
            group = TokenGroup(parent=parent, open_token=token)
            group.build(group_recurse(queue=queue, parent=group))
            yield group
        else:
            token.set_duration(parent)
            yield token


def sustain_recurse(
    tokens: Sequence[Token | TokenGroup]
):
    while True:
        for i, token in enumerate(tokens):
            if isinstance(token, TokenGroup):
                token.build_sustain()
            elif isinstance(token, Sustain) and i > 0:
                tokens = (
                    *tokens[:i-1],
                    SustainGroup(left=tokens[i-1], right=tokens[i+1]),
                    *tokens[i+2:],
                )
                break
        else:
            return tokens


def show_help() -> None:
    print("|   Pauses Briefly")
    print("+   Holds Note to Next")
    print("()  Note Group")
    print("<>  Slows Notes Within Them")
    print("[]  Hastens Notes Within Them")
    print("^C  Exit")
    print()
    print('Note shortcuts:')
    width = 7
    offset = -1
    for i, (key, note) in enumerate(Note.KEY_TO_NOTE.items()):
        if key == '!':
            print()
            width = 5
            offset = -5
        print(f'{key} {note:3}  ', end='')
        if i % width == width + offset:
            print()
    print()


def play(root: TokenGroup) -> None:
    segments = tuple(root.render())
    if len(segments) > 0:
        buffer = np.concatenate(segments)
        sounddevice.play(data=buffer, samplerate=F_SAMPLE, blocking=True)


def demo() -> None:
    print('The terminal sequence:')
    synth = Tree()
    synth.consume(
        '<<F♯4 + C♯5>>+(|[|[C♯6 B5]] C♯6 F♯5) '
        '<<F♯4 + B3>>+(|[|[D6 C♯6 D6| C♯6|]] B5)'
    )
    play(synth.build())


def main() -> None:
    show_help()

    synth = Tree()
    while True:
        synth.consume(input())
        print('Tokens:', ' '.join(synth.describe_queue()))
        root = synth.build()
        print('Tree:', root)
        play(root)


if __name__ == '__main__':
    try:
        # demo()
        main()
    except KeyboardInterrupt:
        pass

The tree-traversal code is not wonderful, but at these scales that doesn't make a performance difference.