pythonregexnumpypython-sounddevice

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


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.


Solution

  • 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.