Fix MIDI running status + sequencer freeze under external MIDI clock (F8)#748
Conversation
System Real-Time messages (0xF8-0xFF: clock, start, stop, active sensing) may be interleaved anywhere in the stream -- even between the data bytes of another message -- and must not disturb running status. convert_midi_bytes_to_messages overwrote current_midi_message[0] for every status byte, so a clock byte (streamed at 24 ppqn while a sequencer plays) clobbered the stored note-on status. The next running-status note's data bytes then resolved to status 0xF0, matched no case, and were silently dropped. Route real-time bytes through a scratch buffer so they never touch current_midi_message[] or midi_message_slot. Also reset midi_message_slot on a genuine new status byte so a truncated message self-heals instead of desyncing the data-byte parity of the next one. Fixes #747 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🎛️ AMYboard HW CIDispatched this PR's |
…nc switch Two follow-on fixes to the F8/MIDI-clock handling, for users who enable external sync (tulip.external_midi_sync(True)): 1. sequencer_external_clock was a one-way latch: sequencer_midi_clock_tick() set it true on the first F8 and nothing ever cleared it, so once an external clock stopped, sequencer_check_and_fill() early-returned forever and the internal sequencer froze until reboot -- turning sync back off did not recover it. Add sequencer_external_clock_disable() (clears the latch, resumes internal clocking, re-anchors the tick timer) and call it from amy_external_midi_sync(0), making "sync off" a real recovery path. 2. MIDI Start (FA) / Stop (FC) drove the sequencer unconditionally, so a connected DAW's transport could halt the AMYboard's own internal sequence even with sync off. Gate both behind external_midi_sync_enabled. The internal-until-first-external-tick handover is preserved: the latch still switches to external clock on the first F8 while sync is enabled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bumps the amy submodule from 694cb22 to 612e3cf so this preview also includes the second commit on shorepine/amy#748: the external-clock sequencer-freeze fix and the FA/FC transport gating. Lets the preview exercise the full F8 behavior, not just running status.
…bytes The byte-stream parser (convert_midi_bytes_to_messages) had no test coverage: inject_midi hands a pre-formed 3-byte message straight to amy_event_midi_message_received, bypassing the parser entirely -- which is why the running-status bug went unnoticed. - pyamy: add inject_midi_bytes(data, usb=0) binding that feeds a raw byte stream through convert_midi_bytes_to_messages, plus the amy.inject_midi_bytes Python wrapper. - test: TestMidiRunningStatusClock sends three note-ons with the 0x90 status byte given once, interleaving a MIDI clock (0xF8) before note 2 and an active-sensing (0xFE) before note 3. All three notes (60, 62, 64) must sound. Verified the test catches the regression: against the pre-fix parser it fails at err=-30.1 dB (notes 62 and 64 dropped); with the fix it is bit-exact (err=-100.0 dB). Full suite: 95 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebases the preview onto current tulipcc main and re-pins the amy submodule to the latest shorepine/amy#748 head (d4b740b: running-status + sequencer-freeze fixes + the regression test). Matches the amy-PR HWCI build that already passed on the bench; regenerates the firmware after a transient corrupt-flash boot loop on the prior preview build.
🎛️ HW CI (physical bench)Built from this AMY PR, pinned into the tulipcc AMYboard (USB-MIDI + AMY Tulip (TULIP4_R11; serial-REPL audio + WiFi screenshot): ✅ PASS — flashed this PR’s firmware; all checks matched the references. ⬇️ Artifacts: recordings · screenshot · serial logs · run logs Self-hosted bench. Audio spectral-compared to |
The AMYboard bench occasionally fails with "board MIDI never appeared": the board panic-loops in the ESP-IDF 2nd-stage bootloader (Guru Meditation, StoreProhibited) before USB-MIDI enumerates. This is NOT a firmware bug -- the crashing image's bootloader is byte-identical to builds that boot fine (diffing the two full images over the bootloader region turns up only the embedded __TIME__ build string + the derived checksum/SHA), so it's a transient flash-read/power/reset glitch after the ~61 s USB flash. Investigated via shorepine/amy#748. Wrap flash+boot in a retry loop (--boot-attempts, default 3). On "MIDI never appeared", stop the attempt's boot logger (freeing the dongle so esptool can drive reset), re-flash -- which forces ROM download mode, a clean known-good reset -- and wait again. A genuinely broken build still fails every attempt, so real regressions aren't masked; each failed attempt's crash log is kept in the combined serial log. wait_for_board() now returns None on timeout instead of sys.exit. Verified the loop with mocked flash/wait: all-fail does N re-flashes then exits; a boot on attempt 2 flashes twice then proceeds.
python.md covered sending MIDI notes (midi_out + amy.AMY_MIDI) but not clock sync. Add a "Syncing to MIDI clock" subsection: - Following: tulip.external_midi_sync(True/False) to slave AMYboard to an external F8 clock (off by default); F8 sets tempo, FA/FC start/stop while enabled. - Sending: emit FA/F8/FC with tulip.midi_out(), and a tempo-locked clock generator via tulip.seq_add_callback() (48 PPQ internal vs 24 PPQ MIDI -> period=2). The FA/FC-gating and "sync off restores internal clock" semantics described here land with shorepine/amy#748.
Updates the amy submodule ff59eb27 -> 055b70f (current amy main), pulling in the merged shorepine/amy#748: - Fix MIDI running status broken by interleaved real-time bytes (F8/FE): a clock byte no longer clobbers the stored status, so running-status notes after an F8 aren't dropped. - Fix sequencer freeze under external MIDI clock; gate MIDI transport (FA/FC) behind external_midi_sync. - Regression test for the parser. Also includes amy#749 (docs: sending MIDI out via wave=AMY_MIDI) and the AMYboard hardware-CI trigger plumbing. Validated on the physical AMYboard bench via the amy#748 amypr HWCI run (PASS).
Fixes #747. Addresses real-world reports of an AMYboard "crashing" / no longer receiving MIDI when an external device streams MIDI clock (F8).
Three related F8 / MIDI-clock bugs, in two configs:
1. Running status broken by interleaved real-time bytes (default config)
System Real-Time messages (
0xF8–0xFF: clock, start, stop, active sensing) may be interleaved anywhere in the stream — even between the data bytes of another message — and must not disturb running status.convert_midi_bytes_to_messagesoverwrotecurrent_midi_message[0]for every status byte, so a clock byte (24 ppqn while a sequencer plays) clobbered the stored note-on status. The next running-status note's data bytes then resolved to status0xF0, matched no case, and were silently dropped:This is the "stops receiving MIDI" symptom in default config (sync off, F8 is otherwise a no-op). Fix: real-time bytes (
>= 0xF8) dispatch via a scratch buffer and never touchcurrent_midi_message[]/midi_message_slot; a genuine new status byte also resetsmidi_message_slotso a truncated message self-heals.2. Sequencer freeze under external clock (sync enabled)
sequencer_external_clockwas a one-way latch:sequencer_midi_clock_tick()set it true on the first F8 and nothing ever cleared it, so once the external clock stopped,sequencer_check_and_fill()early-returned forever and the internal sequencer froze until reboot — turning sync back off did not recover it. Fix:sequencer_external_clock_disable()(clears the latch, resumes internal clocking, re-anchors the tick timer), called fromamy_external_midi_sync(0), making "sync off" a real recovery path. The internal-until-first-external-tick handover is preserved.3. Transport (FA/FC) ungated
MIDI Start/Stop drove the sequencer unconditionally, so a connected DAW's transport could halt the AMYboard's own internal sequence even with sync off. Both are now gated behind
external_midi_sync_enabled.Testing
make amy-moduleclean (-Wall -Wextra, no warnings); full suite 95 tests pass.TestMidiRunningStatusClockthat feeds a raw byte stream through the parser — three running-status note-ons with an interleaved0xF8clock and0xFEactive-sensing — and asserts all three notes sound. Verified non-vacuous: it fails against the pre-fix parser (err=-30.1 dB, two notes dropped) and is bit-exact with the fix (err=-100.0 dB).inject_midibypasses it). Newamy.inject_midi_bytes()binding routes bytes throughconvert_midi_bytes_to_messages.🤖 Generated with Claude Code