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:
A6 E3 A4
A6 | E3 | A4 |
< A6 E3 A4 >
[ A6 E3 A4 ]
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:
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:
Default Settings
default_duration
: Default duration for playing notes (0.5 seconds).
default_wait_time
: Default wait time between notes (0.1 seconds).
Note and Frequency Mapping
note_to_key
: Dictionary mapping musical notes to corresponding keyboard keys.
frequencies
: Dictionary mapping musical notes to their frequencies.
Key to Note Mapping
key_to_note
: Dictionary mapping keyboard keys back to musical notes.
play_note
Function
Plays a single 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, holds the specified parent note.
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).
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.
Help Information
If the user selects mode H (Help), displays information about special characters used in the input sequence.
Exiting the Program
If the user selects mode 7 (Exit), prints a goodbye message and exits the program.
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.