From 97bae01249c47f660d2aae27024ed564eb3b55bf Mon Sep 17 00:00:00 2001 From: Matt Harvey Date: Sun, 19 Jan 2025 17:54:25 -0800 Subject: [PATCH 1/6] Fix amy.py translation of send global volume to wire format Lowercase v is velocity. Capital V is volume. --- amy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amy.py b/amy.py index b1b20fe2..68947a80 100644 --- a/amy.py +++ b/amy.py @@ -162,7 +162,7 @@ def message(**kwargs): # Each keyword maps to two chars, first is the wire protocol prefix, second is an arg type code # I=int, F=float, S=str, L=list, C=ctrl_coefs kw_map = {'osc': 'vI', 'wave': 'wI', 'note': 'nF', 'vel': 'lF', 'amp': 'aC', 'freq': 'fC', 'duty': 'dC', 'feedback': 'bF', 'time': 'tI', - 'reset': 'SI', 'phase': 'PF', 'pan': 'QC', 'client': 'gI', 'volume': 'vF', 'pitch_bend': 'sF', 'filter_freq': 'FC', 'resonance': 'RF', + 'reset': 'SI', 'phase': 'PF', 'pan': 'QC', 'client': 'gI', 'volume': 'VF', 'pitch_bend': 'sF', 'filter_freq': 'FC', 'resonance': 'RF', 'bp0': 'AL', 'bp1': 'BL', 'eg0_type': 'TI', 'eg1_type': 'XI', 'debug': 'DI', 'chained_osc': 'cI', 'mod_source': 'LI', 'eq': 'xL', 'filter_type': 'GI', 'algorithm': 'oI', 'ratio': 'IF', 'latency_ms': 'NI', 'algo_source': 'OL', 'load_sample': 'zL', 'chorus': 'kL', 'reverb': 'hL', 'echo': 'ML', 'load_patch': 'KI', 'store_patch': 'uS', 'voices': 'rL', From 049c08b27e970de8ee4b54161c759b6d5713e4e5 Mon Sep 17 00:00:00 2001 From: Matt Harvey Date: Sun, 19 Jan 2025 17:55:48 -0800 Subject: [PATCH 2/6] experiments/piano_recital: piano voice test case and demo This resulted from a piano player "tracking" to MIDI files and then trying to get the best sound from AMY seen as a batch-renderer. The Chopin ones double as a stress test for voice stealing under heavy sustain and variable velocity. --- .../000-IMSLP172781-WIMA.cb18-wtc01.mid | Bin 0 -> 8386 bytes experiments/piano_recital/001-chopin_op66.mid | Bin 0 -> 16753 bytes .../piano_recital/002-chopin_op_25_no_12.mid | Bin 0 -> 62040 bytes .../piano_recital/003-schumann_op_15_no_1.mid | Bin 0 -> 1499 bytes experiments/piano_recital/midi.py | 209 ++++++++++++++++++ experiments/piano_recital/program.txt | 35 +++ experiments/piano_recital/runner.py | 120 ++++++++++ 7 files changed, 364 insertions(+) create mode 100644 experiments/piano_recital/000-IMSLP172781-WIMA.cb18-wtc01.mid create mode 100644 experiments/piano_recital/001-chopin_op66.mid create mode 100644 experiments/piano_recital/002-chopin_op_25_no_12.mid create mode 100644 experiments/piano_recital/003-schumann_op_15_no_1.mid create mode 100644 experiments/piano_recital/midi.py create mode 100644 experiments/piano_recital/program.txt create mode 100644 experiments/piano_recital/runner.py diff --git a/experiments/piano_recital/000-IMSLP172781-WIMA.cb18-wtc01.mid b/experiments/piano_recital/000-IMSLP172781-WIMA.cb18-wtc01.mid new file mode 100644 index 0000000000000000000000000000000000000000..2e3a7a219aaea59e4737b85bbc2cf3d661496b7f GIT binary patch literal 8386 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 YZd0(0kExDNTP7Dd<~ThUvd~5Q-<-Ya(f|Me literal 0 HcmV?d00001 diff --git a/experiments/piano_recital/001-chopin_op66.mid b/experiments/piano_recital/001-chopin_op66.mid new file mode 100644 index 0000000000000000000000000000000000000000..1328ad04eca61be7ceb70fb6fe5014751a4c6889 GIT binary patch literal 16753 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 B|D6B; literal 0 HcmV?d00001 diff --git a/experiments/piano_recital/003-schumann_op_15_no_1.mid b/experiments/piano_recital/003-schumann_op_15_no_1.mid new file mode 100644 index 0000000000000000000000000000000000000000..83da221b1e2ca9ba036e1930644273e6d788dad3 GIT binary patch literal 1499 zcmXApU31e$6oy|}b}R?y!;nuWPGS?s3BtCtakP=NY!q!2xyINjFf?>%h8dt^pbSF` z%+M6lb=!;HwLhUhroW4x&GR?Mtr+`WwIY%2xu)33_c1QWs*VM?hhnld z-GhECu^L`4@NzDJkvjS(+0yk8S&r`&>k~*ls8rmJF_fmll!X2exq8#ZgCUAaOcBUl z1jg2;{HihNm%e~7{muvCy?KVS0p|NHH^#2FU6NSub@E~@M<6-L^do_a2O|lN2xLcM zE^L?6z~Ut%fqp4FcQ}j?+EKEd;*-QtyO!nl!cZ+ynpZNk+R#%&jFdc+c+OGfdCO^D z?FUB`ccYqqp>8fks5{v1qt$cs99;#`P0Y?#riW#|sNag_cv|gMI&e5j0_R=u#GPP^ z8E!*i46!}VtKAZjB$yMJV$iDRn`|RX1M-^VoN)C;a?=u6VlX`DXFX&SWYf-~UCFU+ z{d36lO@;*<4^>s)BmFcCCl7GDjaG9n=Yf+R zLc~o>D9^ORd1;R`yrGpsKdfuV##bD(&gw!4BdXO&boVGa5-qChA;+nM2XSaR<^ro^ z3E-6<=r=JeDe^uJ94yd`X4^XzB`vn))8Ct%>wEGRCvui3tGV_Cgqmz$&!i%sTu!yc zu|+o9^ZnoMOYcRB-rZGY_T601CU7`dacQx zJ0oHAF)KX%QLgG&0#_4O>tU6|H4N>X1k$}i0*kMXCnP`R;33Xb*d>rd@_N9su9F$6 zBIWrQ?dy{v?UY-li=zM+v^4x%nL*cN&KAW-($SA%UZDaR1_N0A`bmr(ud&$M*^tPI z@Y1W(a$kQ;c1@37K)>&ykcL+1KvD~;nTkVf44rj@gKOeASVC- literal 0 HcmV?d00001 diff --git a/experiments/piano_recital/midi.py b/experiments/piano_recital/midi.py new file mode 100644 index 00000000..a3923307 --- /dev/null +++ b/experiments/piano_recital/midi.py @@ -0,0 +1,209 @@ +"""Pared-down fork of Tulip midi.py. + +piano_recital.py in the AMY repository is the dependant motivating the fork. + +Forking avoids a dependency cycle and eliminates some opportunities for +confusion about the necessity of or assumptions about globals like midi config +and event handler. + +`Queue` was removed in favor of the standard `collections.deque`, reflecting +the assumption that piano_recital.py will usually be run under CPython. +""" +import collections +import time + +import amy + + +class VoiceObject: + """Object to wrap an amy voice.""" + + def __init__(self, amy_voice): + self.amy_voice = amy_voice + + def note_on(self, note, vel, time=None, sequence=None): + amy.send(time=time, + voices=self.amy_voice, + note=note, + vel=vel, + sequence=sequence) + + def note_off(self, time=None, sequence=None): + amy.send(time=time, voices=self.amy_voice, vel=0, sequence=sequence) + + +class Synth: + """Manage a polyphonic synthesizer by rotating among a fixed pool of voices. + + Provides methods: + synth.note_on(midi_note, velocity, time=None, sequence=None) + synth.note_off(midi_note, time=None, sequence=None) + synth.all_notes_off() + synth.program_change(patch_num) changes preset for all voices. + synth.control_change(control, value) modifies a parameter for all voices. + Provides read-back attributes (for voices.py UI): + synth.amy_voices + synth.patch_number + synth.patch_state - patch-specific data only used by clients e.g. UI state + + Note: The synth internally refers to its voices by indices in + range(0, num_voices). These numbers are not related to the actual amy + voices rendering the note; the amy voice number is internal to the + VoiceObjects and is opaque to the Synth object. + """ + # Class-wide record of which voice to allocate next. + allocated_amy_voices = set() + next_amy_patch_number = 1024 + + @classmethod + def reset(cls): + """Resets AMY and Synth's tracking of its state.""" + cls.allocated_amy_voices = set() + cls.next_amy_patch_number = 1024 + amy.reset() + + def __init__(self, num_voices=6, patch_number=None, patch_string=None): + self.voice_objs = self._get_new_voices(num_voices) + self.released_voices = collections.deque(range(num_voices)) + self.active_voices = collections.deque(tuple(), num_voices) + # Dict to look up active voice from note number, for note-off. + self.voice_of_note = {} + self.note_of_voice = [None] * num_voices + self.sustaining = False + self.sustained_notes = set() + # Fields used by UI + self.patch_number = None + self.patch_state = None + if patch_number is not None and patch_string is not None: + raise ValueError( + 'You cannot specify both patch_number and patch_string.') + if patch_string is not None: + patch_number = Synth.next_amy_patch_number + Synth.next_amy_patch_number = patch_number + 1 + amy.send(store_patch='%d,%s' % (patch_number, patch_string)) + self.program_change(patch_number) + + def _get_new_voices(self, num_voices): + new_voices = [] + next_amy_voice = 0 + while len(new_voices) < num_voices: + while next_amy_voice in Synth.allocated_amy_voices: + next_amy_voice += 1 + new_voices.append(next_amy_voice) + next_amy_voice += 1 + self.amy_voice_nums = new_voices + Synth.allocated_amy_voices.update(new_voices) + voice_objects = [] + for amy_voice_num in self.amy_voice_nums: + voice_objects.append(VoiceObject(amy_voice_num)) + return voice_objects + + @property + def amy_voices(self): + return [o.amy_voice for o in self.voice_objs] + + @property + def num_voices(self): + return len(self.voice_objs) + + # send an AMY message to the voices in this synth + def amy_send(self, **kwargs): + vstr = ",".join([str(a) for a in self.amy_voice_nums]) + amy.send(voices=vstr, **kwargs) + + def _get_next_voice(self): + """Return the next voice to use.""" + # First try free/released_voices in order, then steal from active_voices. + if self.released_voices: + return self.released_voices.popleft() + # We have to steal an active voice. + stolen_voice = self.active_voices.popleft() + #print('Stealing voice for', self.note_of_voice[stolen_voice]) + self._voice_off(stolen_voice) + return stolen_voice + + def _voice_off(self, voice, time=None, sequence=None): + """Terminate voice, update note_of_voice, but don't alter the queues.""" + self.voice_objs[voice].note_off(time=time, sequence=sequence) + # We no longer have a voice playing this note. + del self.voice_of_note[self.note_of_voice[voice]] + self.note_of_voice[voice] = None + + def note_off(self, note, time=None, sequence=None): + if self.sustaining: + self.sustained_notes.add(note) + return + if note not in self.voice_of_note: + return + old_voice = self.voice_of_note[note] + self._voice_off(old_voice, time=time, sequence=sequence) + # Return to released. + self.active_voices.remove(old_voice) + self.released_voices.append(old_voice) + + def all_notes_off(self): + self.sustain(False) + while self.active_voices: + voice = self.active_voices.popleft() + self._voice_off(voice) + self.released_voices.append(voice) + + def note_on(self, note, velocity=1, time=None, sequence=None): + if not self.amy_voice_nums: + # Note on after synth.release()? + raise ValueError( + 'Synth note on with no voices - synth has been released?') + if velocity == 0: + self.note_off(note, time=time, sequence=sequence) + else: + # Velocity > 0, note on. + if note in self.voice_of_note: + # Send another note-on to the voice already playing this note. + new_voice = self.voice_of_note[note] + else: + new_voice = self._get_next_voice() + self.active_voices.append(new_voice) + self.voice_of_note[note] = new_voice + self.note_of_voice[new_voice] = note + self.voice_objs[new_voice].note_on(note, + velocity, + time=time, + sequence=sequence) + + def sustain(self, state): + """Turn sustain on/off.""" + if state: + self.sustaining = True + else: + self.sustaining = False + for midinote in self.sustained_notes: + self.note_off(midinote) + self.sustained_notes = set() + + def get_patch_state(self): + return self.patch_state + + def set_patch_state(self, state): + self.patch_state = state + + def program_change(self, patch_number): + if patch_number != self.patch_number: + self.patch_number = patch_number + # Reset any modified state due to previous patch modifications. + self.patch_state = None + time.sleep(0.1) # "AMY queue will fill if not slept." + self.amy_send(load_patch=patch_number) + + def control_change(self, control, value): + if control == 64: + self.sustain(value > 64) + + def release(self): + """Called to terminate this synth and release its amy_voice resources.""" + # Turn off any active notes + self.all_notes_off() + # Return all the amy_voices + for amy_voice in self.amy_voice_nums: + Synth.allocated_amy_voices.remove(amy_voice) + self.amy_voice_nums = [] + del self.voice_objs[:] diff --git a/experiments/piano_recital/program.txt b/experiments/piano_recital/program.txt new file mode 100644 index 00000000..250d7d9d --- /dev/null +++ b/experiments/piano_recital/program.txt @@ -0,0 +1,35 @@ +000-IMSLP172781-WIMA.cb18-wtc01.mid +Prelude and Fugue in C from the Well-tempered Clavier +J. S. Bach +BWV 846 +format: unknown +https://imslp.org/wiki/Special:ImagefromIndex/172781/hfpn +Accessed on January 18, 2025 +James L. Bailey +Creative Commons Attribution Non-commercial Share Alike 3.0 + +001-chopin_op66.mid +Fantasie Impromptu +Frédéric Chopin +Op. 66 +with artificial quantization of note event time. *too* perfect. +format: export from Ableton Live MIDI clip editor +Matt Harvey +Creative Commons CC0 (public domain) + +002-chopin_op_25_no_12.mid +Ocean Etude +Frédéric Chopin +Op. 25 No. 12 +with terrible mistakes +format: arecordmidi from Kawai CA-67 digital piano +Matt Harvey +Creative Commons CC0 (public domain) + +003-schumann_op_15_no_1.mid +Of Foreign Lands and Peoples +Robert Schumann +Op. 15 No. 1 +format: arecordmidi from Kawai CA-67 digital piano +Matt Harvey +Creative Commons CC0 (public domain) diff --git a/experiments/piano_recital/runner.py b/experiments/piano_recital/runner.py new file mode 100644 index 00000000..cc7217bb --- /dev/null +++ b/experiments/piano_recital/runner.py @@ -0,0 +1,120 @@ +"""Stress test for AMY piano voice, curated by an amateur pianist. + +``` +python3 -m experiments.piano_recital.runner +``` + +Will play all the .mid files in experiments/piano_recital, sorted by filename. + +Assumptions: + * `audio_playback_device=0`. If not, edit this file. + * You execute the command from top-level amy/. + * The `libamy` and `mido` packages are installed. + * python3 is CPython, probably on a laptop. + * You edited amy/src/amy_config.h to have `#define AMY_OSCS 1024` before + installing `amy`. + +Findings: + * Voice stealing can be noticed, even at 32 voices, especially in the Ocean + Etude op_25_no_12. It would be nice not only to make voice stealing aware + of velocities but also to support turning MAX_VOICES up to more than 88. + (As of now, this seems to trigger memory addressing bugs.) + * Output can crackle on Linux due to buffer underrun. See + https://github.com/mackron/miniaudio/issues/427 + Editing amy/src/libminiaudio-audio.c to add + `deviceConfig.periodSizeInFrames = AMY_BLOCK_SIZE * 8;` reduces the + severity while preserving the use of PulseAudio. Switching to JACK using + both `#define MA_NO_PULSEAUDIO` and `#define MA_NO_ALSA` achieves 0 + dropped samples. +""" + +import os +import time +from typing import Iterable + +import amy +import mido +from experiments.piano_recital import midi + + +def _velocity_is_binarized(midi_file: mido.MidiFile) -> bool: + """Returns whether all note ons have a constant positive velocity.""" + velocities = [ + m.velocity for m in midi_file if m.type == 'note_on' and m.velocity > 0 + ] + return velocities and min(velocities) == max(velocities) + + +def play_piece(synth: midi.Synth, + filename: str, + default_velocity: int = 64) -> None: + """Plays a piano MIDI file to a Synth. + + This strongly assumes we are playing a *piano* in that it ignores channels + as well all messages other than note on, note off, and the control changes + for the three pedals. + + Args: + synth: The synth that will play the notes. Patch and effects must have + already been set. + filename: Path to a MIDI file to play. + default_velocity: Some files, like the Bach above, binarize velocity to + {0, N}, either because they were programmed rather than played. This + is neither pianistic nor realistic, and if N is large, it causes + distortion. For such files, the constant nonzero velocity will be + replaced by this value. + """ + try: + midi_file = mido.MidiFile(filename) + use_default_velocity = _velocity_is_binarized(midi_file) + for m in midi_file.play(): + if m.type == 'note_on': + velocity = m.velocity + if velocity > 0 and use_default_velocity: + velocity = default_velocity + synth.note_on(m.note, velocity=velocity / 127) + elif m.type == 'note_off': + synth.note_off(m.note) + elif m.is_cc() and m.control in (64, 66, 67): + synth.control_change(m.control, m.value) + finally: + synth.all_notes_off() + + +def give_recital(filenames: Iterable[str]) -> None: + """Plays each MIDI file on the AMY piano.""" + amy.live(audio_playback_device=0) + try: + # The volume that ff 4-finger chords from a Kawai CA-67 peak just below + # the soft-clipping threshold is 0.75. + amy.send(volume=0.75) + amy.reverb(0.1) + amy.chorus(0) + amy.echo(0) + synth = midi.Synth(num_voices=32, patch_number=256) + try: + iter_filenames = iter(filenames) + filename = next(iter_filenames) + while True: + try: + play_piece(synth, filename) + filename = next(iter_filenames) + time.sleep(1.0) + except (StopIteration, KeyboardInterrupt): + break + finally: + synth.release() + finally: + amy.pause() + + +def set_list() -> Iterable[str]: + """Yields the sorted MIDI filenames in this directory.""" + directory = 'experiments/piano_recital' + for filename in sorted(os.listdir(directory)): + if filename.lower().endswith('.mid'): + yield os.path.join(directory, filename) + + +if __name__ == '__main__': + give_recital(set_list()) From f5221e50a93c0ff34831d105590fa4192108fd1c Mon Sep 17 00:00:00 2001 From: Matt Harvey Date: Sat, 25 Jan 2025 18:17:21 -0800 Subject: [PATCH 3/6] Refactor piano_recital to mido_piano and add jam binary --- .../000-IMSLP172781-WIMA.cb18-wtc01.mid | Bin .../001-chopin_op66.mid | Bin .../002-chopin_op_25_no_12.mid | Bin .../003-schumann_op_15_no_1.mid | Bin experiments/mido_piano/instrument.py | 91 +++++++++++++ experiments/mido_piano/jam.py | 14 ++ .../{piano_recital => mido_piano}/midi.py | 7 +- .../{piano_recital => mido_piano}/program.txt | 0 experiments/mido_piano/recital.py | 47 +++++++ experiments/piano_recital/runner.py | 120 ------------------ 10 files changed, 158 insertions(+), 121 deletions(-) rename experiments/{piano_recital => mido_piano}/000-IMSLP172781-WIMA.cb18-wtc01.mid (100%) rename experiments/{piano_recital => mido_piano}/001-chopin_op66.mid (100%) rename experiments/{piano_recital => mido_piano}/002-chopin_op_25_no_12.mid (100%) rename experiments/{piano_recital => mido_piano}/003-schumann_op_15_no_1.mid (100%) create mode 100644 experiments/mido_piano/instrument.py create mode 100644 experiments/mido_piano/jam.py rename experiments/{piano_recital => mido_piano}/midi.py (96%) rename experiments/{piano_recital => mido_piano}/program.txt (100%) create mode 100644 experiments/mido_piano/recital.py delete mode 100644 experiments/piano_recital/runner.py diff --git a/experiments/piano_recital/000-IMSLP172781-WIMA.cb18-wtc01.mid b/experiments/mido_piano/000-IMSLP172781-WIMA.cb18-wtc01.mid similarity index 100% rename from experiments/piano_recital/000-IMSLP172781-WIMA.cb18-wtc01.mid rename to experiments/mido_piano/000-IMSLP172781-WIMA.cb18-wtc01.mid diff --git a/experiments/piano_recital/001-chopin_op66.mid b/experiments/mido_piano/001-chopin_op66.mid similarity index 100% rename from experiments/piano_recital/001-chopin_op66.mid rename to experiments/mido_piano/001-chopin_op66.mid diff --git a/experiments/piano_recital/002-chopin_op_25_no_12.mid b/experiments/mido_piano/002-chopin_op_25_no_12.mid similarity index 100% rename from experiments/piano_recital/002-chopin_op_25_no_12.mid rename to experiments/mido_piano/002-chopin_op_25_no_12.mid diff --git a/experiments/piano_recital/003-schumann_op_15_no_1.mid b/experiments/mido_piano/003-schumann_op_15_no_1.mid similarity index 100% rename from experiments/piano_recital/003-schumann_op_15_no_1.mid rename to experiments/mido_piano/003-schumann_op_15_no_1.mid diff --git a/experiments/mido_piano/instrument.py b/experiments/mido_piano/instrument.py new file mode 100644 index 00000000..abed7d37 --- /dev/null +++ b/experiments/mido_piano/instrument.py @@ -0,0 +1,91 @@ +"""Piano MIDI sound module based on AMY and `mido`. + +Example usage: + +``` +piano = instrument.Piano() +piano.play_file('test.mid') +``` + +Assumptions: + * `amy.live()` and other setup are done external to this module. + * Patch 256 is piano, and we will use 32 voices. + * The `libamy` and `mido` packages are installed and supported by your + platform. `python-rtmidi` is also useful for `play_input`. + * You edited amy/src/amy_config.h to have `#define AMY_OSCS 1024` before + installing `libamy`. +""" + +import amy +import mido +from experiments.mido_piano import midi + + +class MidoSynth(midi.Synth): + """Bridge from `mido` to a Tulip `midi.Synth`.""" + + def __del__(self): + super().release() + + def play_message(self, message: mido.Message) -> None: + """Plays a single MIDI message. + + All input values ranges are assumed to be as in standard MIDI format, + which is in some cases different from what `midi.Synth` assumes. + + Args: + message: The message to play. + """ + if message.type == 'note_on': + self.note_on(message.note, velocity=message.velocity / 127) + elif message.type == 'note_off': + self.note_off(message.note) + elif message.is_cc(): + self.control_change(message.control, message.value) + + def play_file(self, filename: str, default_velocity: int = 64) -> None: + """Plays a MIDI file. + + Args: + filename: Path to a MIDI file to play. + default_velocity: If this is set, and if all positive velocities of + note on events have the same value, then this value will replace the + constant velocity from the file. Without this, files with constant + velocity 127 sound bad. + """ + midi_file = mido.MidiFile(filename) + velocities = [ + m.velocity for m in midi_file + if m.type == 'note_on' and m.velocity > 0 + ] + if (not velocities) or min(velocities) != max(velocities): + default_velocity = None + for m in midi_file.play(): + if m.type == 'note_on' and m.velocity > 0 and default_velocity: + m = m.copy(velocity=default_velocity) + self.play_message(m) + + def play_input(self, name: str | None = None) -> None: + """Plays MIDI messages from a `mido` input. + + This is useful if you are connected to a USB instrument and have also + installed the `python-rtmidi` package. + + name: `mido` input name from which to consume messages. + `mido.get_input_names()` shows your options. USB replugging can cause + the name to change. If none, this attempts to find the first input + with "usb" in its lowercased name. + """ + if not name: + for candidate in mido.get_input_names(): + if 'usb' in candidate.lower(): + name = candidate + break + for message in mido.open_input(name): + self.play_message(message) + + +class Piano(MidoSynth): + + def __init__(self): + super().__init__(num_voices=32, patch_number=256) diff --git a/experiments/mido_piano/jam.py b/experiments/mido_piano/jam.py new file mode 100644 index 00000000..66fa334c --- /dev/null +++ b/experiments/mido_piano/jam.py @@ -0,0 +1,14 @@ +"""Plays your USB MIDI input in the piano voice.""" +import amy +from experiments.mido_piano import instrument + + +def run() -> None: + amy.send(volume=2.0) + amy.live() + piano = instrument.Piano() + piano.play_input() + + +if __name__ == '__main__': + run() diff --git a/experiments/piano_recital/midi.py b/experiments/mido_piano/midi.py similarity index 96% rename from experiments/piano_recital/midi.py rename to experiments/mido_piano/midi.py index a3923307..bf19786b 100644 --- a/experiments/piano_recital/midi.py +++ b/experiments/mido_piano/midi.py @@ -157,6 +157,8 @@ def note_on(self, note, velocity=1, time=None, sequence=None): self.note_off(note, time=time, sequence=sequence) else: # Velocity > 0, note on. + if note in self.sustained_notes: + self.sustained_notes.remove(note) if note in self.voice_of_note: # Send another note-on to the voice already playing this note. new_voice = self.voice_of_note[note] @@ -196,7 +198,10 @@ def program_change(self, patch_number): def control_change(self, control, value): if control == 64: - self.sustain(value > 64) + if value > 100 and not self.sustaining: + self.sustain(True) + if value < 60 and self.sustaining: + self.sustain(False) def release(self): """Called to terminate this synth and release its amy_voice resources.""" diff --git a/experiments/piano_recital/program.txt b/experiments/mido_piano/program.txt similarity index 100% rename from experiments/piano_recital/program.txt rename to experiments/mido_piano/program.txt diff --git a/experiments/mido_piano/recital.py b/experiments/mido_piano/recital.py new file mode 100644 index 00000000..e0c99c75 --- /dev/null +++ b/experiments/mido_piano/recital.py @@ -0,0 +1,47 @@ +"""Plays a collection of MIDI files using the AMY piano voice.""" + +import os +import time +from typing import Iterable + +import amy +from experiments.mido_piano import instrument + + +def init_amy() -> None: + amy.live(audio_playback_device=0) + # Volume value was determined by making 8-finger ff chords on a Kawai CA-67 + # come just short of the soft-clipping threshold. + amy.send(volume=0.75) + amy.reverb(0.1) + amy.chorus(0) + amy.echo(0) + + +def set_list() -> Iterable[str]: + """Yields the sorted MIDI filenames in this directory.""" + directory = 'experiments/mido_piano' + for filename in sorted(os.listdir(directory)): + if filename.lower().endswith('.mid'): + yield os.path.join(directory, filename) + + +def run() -> None: + init_amy() + try: + piano = instrument.Piano() + iter_filenames = iter(set_list()) + filename = next(iter_filenames) + while True: + try: + piano.play_file(filename) + filename = next(iter_filenames) + time.sleep(2.0) + except (StopIteration, KeyboardInterrupt): + break + finally: + amy.pause() + + +if __name__ == '__main__': + run() diff --git a/experiments/piano_recital/runner.py b/experiments/piano_recital/runner.py deleted file mode 100644 index cc7217bb..00000000 --- a/experiments/piano_recital/runner.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Stress test for AMY piano voice, curated by an amateur pianist. - -``` -python3 -m experiments.piano_recital.runner -``` - -Will play all the .mid files in experiments/piano_recital, sorted by filename. - -Assumptions: - * `audio_playback_device=0`. If not, edit this file. - * You execute the command from top-level amy/. - * The `libamy` and `mido` packages are installed. - * python3 is CPython, probably on a laptop. - * You edited amy/src/amy_config.h to have `#define AMY_OSCS 1024` before - installing `amy`. - -Findings: - * Voice stealing can be noticed, even at 32 voices, especially in the Ocean - Etude op_25_no_12. It would be nice not only to make voice stealing aware - of velocities but also to support turning MAX_VOICES up to more than 88. - (As of now, this seems to trigger memory addressing bugs.) - * Output can crackle on Linux due to buffer underrun. See - https://github.com/mackron/miniaudio/issues/427 - Editing amy/src/libminiaudio-audio.c to add - `deviceConfig.periodSizeInFrames = AMY_BLOCK_SIZE * 8;` reduces the - severity while preserving the use of PulseAudio. Switching to JACK using - both `#define MA_NO_PULSEAUDIO` and `#define MA_NO_ALSA` achieves 0 - dropped samples. -""" - -import os -import time -from typing import Iterable - -import amy -import mido -from experiments.piano_recital import midi - - -def _velocity_is_binarized(midi_file: mido.MidiFile) -> bool: - """Returns whether all note ons have a constant positive velocity.""" - velocities = [ - m.velocity for m in midi_file if m.type == 'note_on' and m.velocity > 0 - ] - return velocities and min(velocities) == max(velocities) - - -def play_piece(synth: midi.Synth, - filename: str, - default_velocity: int = 64) -> None: - """Plays a piano MIDI file to a Synth. - - This strongly assumes we are playing a *piano* in that it ignores channels - as well all messages other than note on, note off, and the control changes - for the three pedals. - - Args: - synth: The synth that will play the notes. Patch and effects must have - already been set. - filename: Path to a MIDI file to play. - default_velocity: Some files, like the Bach above, binarize velocity to - {0, N}, either because they were programmed rather than played. This - is neither pianistic nor realistic, and if N is large, it causes - distortion. For such files, the constant nonzero velocity will be - replaced by this value. - """ - try: - midi_file = mido.MidiFile(filename) - use_default_velocity = _velocity_is_binarized(midi_file) - for m in midi_file.play(): - if m.type == 'note_on': - velocity = m.velocity - if velocity > 0 and use_default_velocity: - velocity = default_velocity - synth.note_on(m.note, velocity=velocity / 127) - elif m.type == 'note_off': - synth.note_off(m.note) - elif m.is_cc() and m.control in (64, 66, 67): - synth.control_change(m.control, m.value) - finally: - synth.all_notes_off() - - -def give_recital(filenames: Iterable[str]) -> None: - """Plays each MIDI file on the AMY piano.""" - amy.live(audio_playback_device=0) - try: - # The volume that ff 4-finger chords from a Kawai CA-67 peak just below - # the soft-clipping threshold is 0.75. - amy.send(volume=0.75) - amy.reverb(0.1) - amy.chorus(0) - amy.echo(0) - synth = midi.Synth(num_voices=32, patch_number=256) - try: - iter_filenames = iter(filenames) - filename = next(iter_filenames) - while True: - try: - play_piece(synth, filename) - filename = next(iter_filenames) - time.sleep(1.0) - except (StopIteration, KeyboardInterrupt): - break - finally: - synth.release() - finally: - amy.pause() - - -def set_list() -> Iterable[str]: - """Yields the sorted MIDI filenames in this directory.""" - directory = 'experiments/piano_recital' - for filename in sorted(os.listdir(directory)): - if filename.lower().endswith('.mid'): - yield os.path.join(directory, filename) - - -if __name__ == '__main__': - give_recital(set_list()) From 5da9e0bd855391d6647df1397f7e9906e9eccdf9 Mon Sep 17 00:00:00 2001 From: Matt Harvey Date: Sun, 9 Feb 2025 16:03:53 -0800 Subject: [PATCH 4/6] mido_piano time-exact rendering --- experiments/mido_piano/instrument.py | 57 ++++++++++++++++++++++------ experiments/mido_piano/midi.py | 10 ++--- experiments/mido_piano/render.py | 32 ++++++++++++++++ 3 files changed, 83 insertions(+), 16 deletions(-) create mode 100644 experiments/mido_piano/render.py diff --git a/experiments/mido_piano/instrument.py b/experiments/mido_piano/instrument.py index abed7d37..6bb50f7d 100644 --- a/experiments/mido_piano/instrument.py +++ b/experiments/mido_piano/instrument.py @@ -12,12 +12,16 @@ * Patch 256 is piano, and we will use 32 voices. * The `libamy` and `mido` packages are installed and supported by your platform. `python-rtmidi` is also useful for `play_input`. - * You edited amy/src/amy_config.h to have `#define AMY_OSCS 1024` before - installing `libamy`. + * You edited `#define AMY_OSCS` in amy/src/amy_config.h to have enough. + (Enough is (32 * 21 == 672), which is the number of voices in `Piano` + below times the number of oscs for the dpwe piano patch, except that + there are chorus oscs and 999 and stuff, so why not go big?) """ -import amy import mido +import numpy as np + +import amy from experiments.mido_piano import midi @@ -27,7 +31,9 @@ class MidoSynth(midi.Synth): def __del__(self): super().release() - def play_message(self, message: mido.Message) -> None: + def play_message(self, + message: mido.Message, + time: float | None = None) -> None: """Plays a single MIDI message. All input values ranges are assumed to be as in standard MIDI format, @@ -35,15 +41,21 @@ def play_message(self, message: mido.Message) -> None: Args: message: The message to play. + time: Optional time to forward to amy_send. """ if message.type == 'note_on': - self.note_on(message.note, velocity=message.velocity / 127) + self.note_on(message.note, + velocity=message.velocity / 127, + time=time) elif message.type == 'note_off': - self.note_off(message.note) + self.note_off(message.note, time=time) elif message.is_cc(): - self.control_change(message.control, message.value) + self.control_change(message.control, message.value, time=time) - def play_file(self, filename: str, default_velocity: int = 64) -> None: + def play_file(self, + filename: str, + default_velocity: int = 64, + blocking: bool = True) -> float: """Plays a MIDI file. Args: @@ -52,18 +64,41 @@ def play_file(self, filename: str, default_velocity: int = 64) -> None: note on events have the same value, then this value will replace the constant velocity from the file. Without this, files with constant velocity 127 sound bad. + blocking: Whether to use `mido.MidiFile.play`. The canonical use case + for setting this to `False` is `amy.render`. + + Returns: + The duration of the MIDI file, in seconds. """ midi_file = mido.MidiFile(filename) + duration = sum((m.time for m in midi_file)) velocities = [ m.velocity for m in midi_file if m.type == 'note_on' and m.velocity > 0 ] if (not velocities) or min(velocities) != max(velocities): default_velocity = None - for m in midi_file.play(): + + def filter_fn(m: mido.Message) -> mido.Message: if m.type == 'note_on' and m.velocity > 0 and default_velocity: - m = m.copy(velocity=default_velocity) - self.play_message(m) + return m.copy(velocity=default_velocity) + return m + + if blocking: + for m in midi_file.play(): + self.play_message(filter_fn(m)) + else: + millis = 0.0 + for m in midi_file: + m = filter_fn(m) + millis += m.time * 1000.0 + self.play_message(m, time=millis) + return duration + + def render(self, filename: str, volume_db: float = 0.0) -> tuple[np.ndarray, float]: + amy.send(volume=np.pow(10.0, volume_db / 20.0)) + samples = amy.render(self.play_file(filename, blocking=False)) + return samples, amy.AMY_SAMPLE_RATE def play_input(self, name: str | None = None) -> None: """Plays MIDI messages from a `mido` input. diff --git a/experiments/mido_piano/midi.py b/experiments/mido_piano/midi.py index bf19786b..e52ec86a 100644 --- a/experiments/mido_piano/midi.py +++ b/experiments/mido_piano/midi.py @@ -172,14 +172,14 @@ def note_on(self, note, velocity=1, time=None, sequence=None): time=time, sequence=sequence) - def sustain(self, state): + def sustain(self, state, time=None): """Turn sustain on/off.""" if state: self.sustaining = True else: self.sustaining = False for midinote in self.sustained_notes: - self.note_off(midinote) + self.note_off(midinote, time=time) self.sustained_notes = set() def get_patch_state(self): @@ -196,12 +196,12 @@ def program_change(self, patch_number): time.sleep(0.1) # "AMY queue will fill if not slept." self.amy_send(load_patch=patch_number) - def control_change(self, control, value): + def control_change(self, control, value, time=None): if control == 64: if value > 100 and not self.sustaining: - self.sustain(True) + self.sustain(True, time=time) if value < 60 and self.sustaining: - self.sustain(False) + self.sustain(False, time=time) def release(self): """Called to terminate this synth and release its amy_voice resources.""" diff --git a/experiments/mido_piano/render.py b/experiments/mido_piano/render.py new file mode 100644 index 00000000..1e37d9e7 --- /dev/null +++ b/experiments/mido_piano/render.py @@ -0,0 +1,32 @@ +"""Renders a MIDI file using AMY piano and soundfile. + +Usage: + +``` +python3 -m experiments.mido_piano.render input.mid output.wav 10.0 +``` +""" + +import os +import sys + +import soundfile + +import amy +from experiments.mido_piano import instrument + + +def run(input_filename: str, + output_filename: str, + volume_db: float = 0.0) -> None: + try: + piano = instrument.Piano() + samples, sample_rate = piano.render(input_filename, + volume_db=volume_db) + soundfile.write(output_filename, samples, int(round(sample_rate))) + finally: + piano.release() + + +if __name__ == '__main__': + run(sys.argv[1], sys.argv[2], float(sys.argv[3])) From 77a978f40f7c9fae3253af0c5472b90b030cb613 Mon Sep 17 00:00:00 2001 From: Matt Harvey Date: Sun, 9 Feb 2025 17:52:14 -0800 Subject: [PATCH 5/6] More time specifications --- experiments/mido_piano/instrument.py | 20 +++++++++++------ experiments/mido_piano/midi.py | 32 ++++++++++++++++------------ experiments/mido_piano/render.py | 6 ++++-- 3 files changed, 36 insertions(+), 22 deletions(-) diff --git a/experiments/mido_piano/instrument.py b/experiments/mido_piano/instrument.py index 6bb50f7d..4dc390fd 100644 --- a/experiments/mido_piano/instrument.py +++ b/experiments/mido_piano/instrument.py @@ -55,7 +55,8 @@ def play_message(self, def play_file(self, filename: str, default_velocity: int = 64, - blocking: bool = True) -> float: + blocking: bool = True, + start_millis: float = 0.0) -> float: """Plays a MIDI file. Args: @@ -66,6 +67,8 @@ def play_file(self, velocity 127 sound bad. blocking: Whether to use `mido.MidiFile.play`. The canonical use case for setting this to `False` is `amy.render`. + start_millis: AMY time for the start of the file. Only matters when + `blocking == False`. Returns: The duration of the MIDI file, in seconds. @@ -88,15 +91,18 @@ def filter_fn(m: mido.Message) -> mido.Message: for m in midi_file.play(): self.play_message(filter_fn(m)) else: - millis = 0.0 + millis = start_millis for m in midi_file: m = filter_fn(m) millis += m.time * 1000.0 self.play_message(m, time=millis) return duration - def render(self, filename: str, volume_db: float = 0.0) -> tuple[np.ndarray, float]: - amy.send(volume=np.pow(10.0, volume_db / 20.0)) + def render(self, + filename: str, + volume_db: float = 0.0) -> tuple[np.ndarray, float]: + start_millis = 0.0 + amy.send(volume=np.pow(10.0, volume_db / 20.0), time=start_millis) samples = amy.render(self.play_file(filename, blocking=False)) return samples, amy.AMY_SAMPLE_RATE @@ -122,5 +128,7 @@ def play_input(self, name: str | None = None) -> None: class Piano(MidoSynth): - def __init__(self): - super().__init__(num_voices=32, patch_number=256) + def __init__(self, patch_time: float | None = None): + super().__init__(num_voices=16, + patch_number=256, + patch_time=patch_time) diff --git a/experiments/mido_piano/midi.py b/experiments/mido_piano/midi.py index e52ec86a..c8ccbab8 100644 --- a/experiments/mido_piano/midi.py +++ b/experiments/mido_piano/midi.py @@ -10,7 +10,7 @@ the assumption that piano_recital.py will usually be run under CPython. """ import collections -import time +import time as time_lib import amy @@ -62,7 +62,11 @@ def reset(cls): cls.next_amy_patch_number = 1024 amy.reset() - def __init__(self, num_voices=6, patch_number=None, patch_string=None): + def __init__(self, + num_voices=6, + patch_number=None, + patch_string=None, + patch_time=None): self.voice_objs = self._get_new_voices(num_voices) self.released_voices = collections.deque(range(num_voices)) self.active_voices = collections.deque(tuple(), num_voices) @@ -81,7 +85,7 @@ def __init__(self, num_voices=6, patch_number=None, patch_string=None): patch_number = Synth.next_amy_patch_number Synth.next_amy_patch_number = patch_number + 1 amy.send(store_patch='%d,%s' % (patch_number, patch_string)) - self.program_change(patch_number) + self.program_change(patch_number, time=patch_time) def _get_new_voices(self, num_voices): new_voices = [] @@ -111,7 +115,7 @@ def amy_send(self, **kwargs): vstr = ",".join([str(a) for a in self.amy_voice_nums]) amy.send(voices=vstr, **kwargs) - def _get_next_voice(self): + def _get_next_voice(self, time): """Return the next voice to use.""" # First try free/released_voices in order, then steal from active_voices. if self.released_voices: @@ -119,7 +123,7 @@ def _get_next_voice(self): # We have to steal an active voice. stolen_voice = self.active_voices.popleft() #print('Stealing voice for', self.note_of_voice[stolen_voice]) - self._voice_off(stolen_voice) + self._voice_off(stolen_voice, time=time) return stolen_voice def _voice_off(self, voice, time=None, sequence=None): @@ -141,11 +145,11 @@ def note_off(self, note, time=None, sequence=None): self.active_voices.remove(old_voice) self.released_voices.append(old_voice) - def all_notes_off(self): - self.sustain(False) + def all_notes_off(self, time=None): + self.sustain(False, time=time) while self.active_voices: voice = self.active_voices.popleft() - self._voice_off(voice) + self._voice_off(voice, time=time) self.released_voices.append(voice) def note_on(self, note, velocity=1, time=None, sequence=None): @@ -163,7 +167,7 @@ def note_on(self, note, velocity=1, time=None, sequence=None): # Send another note-on to the voice already playing this note. new_voice = self.voice_of_note[note] else: - new_voice = self._get_next_voice() + new_voice = self._get_next_voice(time=time) self.active_voices.append(new_voice) self.voice_of_note[note] = new_voice self.note_of_voice[new_voice] = note @@ -188,13 +192,13 @@ def get_patch_state(self): def set_patch_state(self, state): self.patch_state = state - def program_change(self, patch_number): + def program_change(self, patch_number, time=None): if patch_number != self.patch_number: self.patch_number = patch_number # Reset any modified state due to previous patch modifications. self.patch_state = None - time.sleep(0.1) # "AMY queue will fill if not slept." - self.amy_send(load_patch=patch_number) + time_lib.sleep(0.1) # "AMY queue will fill if not slept." + self.amy_send(load_patch=patch_number, time=time) def control_change(self, control, value, time=None): if control == 64: @@ -203,10 +207,10 @@ def control_change(self, control, value, time=None): if value < 60 and self.sustaining: self.sustain(False, time=time) - def release(self): + def release(self, time=None): """Called to terminate this synth and release its amy_voice resources.""" # Turn off any active notes - self.all_notes_off() + self.all_notes_off(time=time) # Return all the amy_voices for amy_voice in self.amy_voice_nums: Synth.allocated_amy_voices.remove(amy_voice) diff --git a/experiments/mido_piano/render.py b/experiments/mido_piano/render.py index 1e37d9e7..090e667d 100644 --- a/experiments/mido_piano/render.py +++ b/experiments/mido_piano/render.py @@ -19,13 +19,15 @@ def run(input_filename: str, output_filename: str, volume_db: float = 0.0) -> None: + duration = None try: - piano = instrument.Piano() + piano = instrument.Piano(patch_time=0.0) samples, sample_rate = piano.render(input_filename, volume_db=volume_db) + duration = samples.shape[0] / sample_rate soundfile.write(output_filename, samples, int(round(sample_rate))) finally: - piano.release() + piano.release(time=(duration * 1000.0)) if __name__ == '__main__': From cb64db9c621e36e98847ff58ce06de3b3d3b7901 Mon Sep 17 00:00:00 2001 From: Matt Harvey Date: Sun, 9 Feb 2025 22:06:18 -0800 Subject: [PATCH 6/6] Docstring for render --- experiments/mido_piano/instrument.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/experiments/mido_piano/instrument.py b/experiments/mido_piano/instrument.py index 4dc390fd..0240e5ee 100644 --- a/experiments/mido_piano/instrument.py +++ b/experiments/mido_piano/instrument.py @@ -100,10 +100,26 @@ def filter_fn(m: mido.Message) -> mido.Message: def render(self, filename: str, - volume_db: float = 0.0) -> tuple[np.ndarray, float]: - start_millis = 0.0 + volume_db: float = 0.0, + start_millis: float = 0.0) -> tuple[np.ndarray, float]: + """Renders a MIDI file to an array of samples. + + This can be useful for deubgging, for more accurate timing than + `mido.play`, and for faster than real-time rendering. + + Unlike other methods in this class, this one assumes that we are not + `amy.live`, since it calls `amy.render` to generate the samples. + + Args: + filename: Path to a MIDI file to play. + volume_db: Output volume in dB rel AMY volume=1.0. + start_millis: AMY time for the start of the file. Only matters when + `blocking == False`. + """ amy.send(volume=np.pow(10.0, volume_db / 20.0), time=start_millis) - samples = amy.render(self.play_file(filename, blocking=False)) + samples = amy.render( + self.play_file(filename, blocking=False, + start_millis=start_millis)) return samples, amy.AMY_SAMPLE_RATE def play_input(self, name: str | None = None) -> None: