From 3ec61d38a5d62af5e028ae05b3910a732378ac8e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:45:11 -0400 Subject: [PATCH] note_output: don't send notes from a patch being stored patches_store_patch parses a patch string into the patch's own delta list, and a segment such as "i1n60l1" names a synth and a note, so it went down patches_event_has_voices and note_output sent a real note-on out of the MIDI port (or raised a CV gate) at the moment the patch was saved. A stored "i1l0" likewise ran ALL NOTES OFF, dropping a gate that a played note was holding up. note_output_handle_event now takes `live` (the event is headed for amy_global.delta_queue). A stored note is still claimed, so a voiceless note-output synth skips the voice path as before, but nothing is sent and the held-note stack is untouched. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PykXkeeWw2aRpC6PLfpqTQ --- src/amy.h | 4 +++- src/note_output.c | 13 +++++++++++-- src/patches.c | 2 +- tests/test_note_output.c | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/amy.h b/src/amy.h index 40706400..a1dc6249 100644 --- a/src/amy.h +++ b/src/amy.h @@ -1362,7 +1362,9 @@ extern void cv_trigger_clear_mappings(int gate_cv); // note_output.c -- cv_trigger's mirror: a synth's note events out to // CV/gate or MIDI instead of to its oscillators. extern uint8_t note_output_mode_for(uint8_t synth); -extern bool note_output_handle_event(amy_event *e); +// live: the event is being played (headed for amy_global.delta_queue), not +// stored into a patch. Only a live event sends anything. +extern bool note_output_handle_event(amy_event *e, bool live); extern void note_output_config(uint8_t synth, int mode, float *args, int num_args); extern void note_output_all_off(uint8_t synth); extern void note_output_all_gates_off(void); diff --git a/src/note_output.c b/src/note_output.c index 33ccf2d9..fb357e3a 100644 --- a/src/note_output.c +++ b/src/note_output.c @@ -183,7 +183,15 @@ void note_output_all_off(uint8_t synth) { // Called from patches_event_has_voices. Returns true if this event was a // note for a note-output synth and has been dealt with. -bool note_output_handle_event(amy_event *e) { +// +// `live` is false when the event is being STORED -- parsed into a patch's +// delta list by patches_store_patch -- rather than played. A note in a +// patch string is not a note being played, and before this a stored +// "i1n60l1" sent a real note-on out of the port (or raised a gate) at the +// moment the patch was saved. Such a note is still claimed, so a voiceless +// note-output synth skips the voice path exactly as when it plays, but +// nothing is sent and no held-note state changes. +bool note_output_handle_event(amy_event *e, bool live) { if (AMY_IS_UNSET(e->synth)) return false; note_output_t *n = note_output_find(e->synth); if (n == NULL || n->mode == NOTE_OUTPUT_OFF) return false; @@ -197,7 +205,7 @@ bool note_output_handle_event(amy_event *e) { // the API could clear. Panic has to reach here or it is not panic. if (AMY_IS_UNSET(e->midi_note) && AMY_IS_SET(e->velocity) && e->velocity == 0) { - note_output_all_off(e->synth); + if (live) note_output_all_off(e->synth); return false; // ...and the synth's own voices still get it } // ONLY NOTE EVENTS ARE CLAIMED. Anything else addressed to this synth @@ -205,6 +213,7 @@ bool note_output_handle_event(amy_event *e) { // carries on down the normal path, because swallowing it here would // make a note-output synth a synth you cannot change. if (AMY_IS_UNSET(e->midi_note)) return false; + if (!live) return true; // A note that arrived over MIDI is not sent back out over MIDI by // default: without this a thru-patched port is a feedback loop. The // wave-type implementation guarded on the same thing. A CV/gate diff --git a/src/patches.c b/src/patches.c index 9b3ebd2a..585d7f35 100644 --- a/src/patches.c +++ b/src/patches.c @@ -1268,7 +1268,7 @@ void patches_event_has_voices(amy_event *e, struct delta **queue) { // here -- and why the existence check passes a NULL tag, since // "synth N not defined" three times per note would be noise about a // deliberate arrangement. - if (note_output_handle_event(e) && !instrument_number_exists(e->synth, NULL)) + if (note_output_handle_event(e, queue == &amy_global.delta_queue) && !instrument_number_exists(e->synth, NULL)) return; uint8_t synth = e->synth; diff --git a/tests/test_note_output.c b/tests/test_note_output.c index d4d0578b..86f0e6ba 100644 --- a/tests/test_note_output.c +++ b/tests/test_note_output.c @@ -301,6 +301,40 @@ static void test_repeated_note_on_retriggers(void) { CHECK(fabsf(last_on(1)) < 1e-4, "one note-off is enough to drop the gate"); } +// Storing a patch parses its string into the patch's delta list, and a +// segment like "i1n60l1" names a synth and a note. That is a note being +// SAVED, not played: it used to go out of the port (or raise the gate) the +// moment the patch was stored. Pinned for both modes, and for a stored +// panic, which must not drop a gate that a real note is holding up. +static void test_storing_a_patch_sends_nothing(void) { + printf("storing a patch that contains a note sends nothing\n"); + restart(); + wire("i1iG2,1"); + clear_log(); + wire("K1024ui1n60l1"); + CHECK(midi_writes == 0, "MIDI: nothing sent while storing (%d)", midi_writes); + wire("i1n62l1"); + CHECK(midi_writes == 1 && midi_log[0][0] == 0x90 && midi_log[0][1] == 62, + "MIDI: a played note still goes out (%d sent)", midi_writes); + + restart(); + wire("i1iG1,0,1"); + clear_log(); + wire("K1025ui1n60l1"); + CHECK(cv_writes == 0, "CV: no voltage written while storing (%d writes)", cv_writes); + // Had the stored note been pushed onto the held stack, this note-on + // would be legato over it and raise no gate edge at all. + wire("i1n48l1"); + CHECK(fabsf(last_on(1) - 5.0f) < 1e-4 && fabsf(last_on(0) - 2.0f) < 1e-4, + "CV: the next played note raises the gate at its own pitch (gate %.2f, pitch %.3f)", + last_on(1), last_on(0)); + clear_log(); + wire("K1026ui1l0"); // a stored ALL NOTES OFF + CHECK(writes_on(1) == 0, "CV: a stored panic leaves the held gate alone (%d gate writes)", writes_on(1)); + wire("i1l0"); // the real one + CHECK(fabsf(last_on(1)) < 1e-4, "CV: a played panic still drops it (%.2f)", last_on(1)); +} + int main(void) { test_cv_gate_voltages(); test_loopback_identity(); @@ -313,6 +347,7 @@ int main(void) { test_echo_not_diversion(); test_lost_note_off_is_recoverable(); test_repeated_note_on_retriggers(); + test_storing_a_patch_sends_nothing(); printf("%s: %d failure%s\n", failures ? "FAILED" : "PASSED", failures, failures == 1 ? "" : "s"); return failures ? 1 : 0;