From e1b52a39af93c4b0bfc0a9bbfffd0a3cacb0f25e Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:27:15 -0500 Subject: [PATCH] synthio: Fix note dropped when re-pressed after decay A note released and then re-pressed after its envelope has run down to 0 is silently dropped. synth.pressed reports it as pressed, and then the next render loses it. synthio_span_change_note()'s fast path for a note still on its channel re-enters ATTACK without touching level. At level 0 the render loop's "note is truly finished" check reaps the channel before the envelope is stepped, so the re-press never sounds. Route that case through the same envelope init a fresh press uses. --- shared-module/synthio/__init__.c | 11 +++++++-- .../synthio_note_repress_after_decay.py | 24 +++++++++++++++++++ .../synthio_note_repress_after_decay.py.exp | 6 +++++ 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 tests/circuitpython/synthio_note_repress_after_decay.py create mode 100644 tests/circuitpython/synthio_note_repress_after_decay.py.exp diff --git a/shared-module/synthio/__init__.c b/shared-module/synthio/__init__.c index cb582b1c4bc..368bae24140 100644 --- a/shared-module/synthio/__init__.c +++ b/shared-module/synthio/__init__.c @@ -497,8 +497,15 @@ static int find_channel_with_note(synthio_synth_t *synth, mp_obj_t note) { bool synthio_span_change_note(synthio_synth_t *synth, mp_obj_t old_note, mp_obj_t new_note) { int channel; if (new_note != SYNTHIO_SILENCE && (channel = find_channel_with_note(synth, new_note)) != -1) { - // note already playing, re-enter attack phase - synth->envelope_state[channel].state = SYNTHIO_ENVELOPE_STATE_ATTACK; + if (synth->envelope_state[channel].level == 0) { + // released and already decayed to silence, but not yet reaped: + // treat this like a fresh press, not a swell from 0 + synthio_envelope_state_init(&synth->envelope_state[channel], synthio_synth_get_note_envelope(synth, new_note)); + synth->accum[channel] = 0; + } else { + // note already playing, re-enter attack phase + synth->envelope_state[channel].state = SYNTHIO_ENVELOPE_STATE_ATTACK; + } return true; } channel = find_channel_with_note(synth, old_note); diff --git a/tests/circuitpython/synthio_note_repress_after_decay.py b/tests/circuitpython/synthio_note_repress_after_decay.py new file mode 100644 index 00000000000..9fda75b1503 --- /dev/null +++ b/tests/circuitpython/synthio_note_repress_after_decay.py @@ -0,0 +1,24 @@ +# A note whose envelope has decayed to 0 but whose slot hasn't been reaped +# yet should re-press like a fresh note, not silently vanish. +from synthio import Synthesizer, Note, Envelope +from audiocore import get_buffer + +# release_time=0 collapses the envelope to level 0 in a single step, while +# the note object still holds its channel. +quick = Envelope(release_time=0) +synth = Synthesizer() +note = Note(440, envelope=quick) + +synth.press(note) +get_buffer(synth) +synth.release(note) +get_buffer(synth) +print("pressed after decay:", len(synth.pressed)) + +synth.press(note) +print("pressed right after re-press:", len(synth.pressed)) +get_buffer(synth) +print("pressed after one more render:", len(synth.pressed)) +for _ in range(3): + print("{} {:.2f}".format(*synth.note_info(note))) + get_buffer(synth) diff --git a/tests/circuitpython/synthio_note_repress_after_decay.py.exp b/tests/circuitpython/synthio_note_repress_after_decay.py.exp new file mode 100644 index 00000000000..3b79fe6f3ab --- /dev/null +++ b/tests/circuitpython/synthio_note_repress_after_decay.py.exp @@ -0,0 +1,6 @@ +pressed after decay: 0 +pressed right after re-press: 1 +pressed after one more render: 1 +synthio.EnvelopeState.ATTACK 0.46 +synthio.EnvelopeState.ATTACK 0.70 +synthio.EnvelopeState.ATTACK 0.93