Skip to content

Fix: Octatrack base, USB MIDI clock gives way to DIN clock - #1131

Merged
repeat98 merged 1 commit into
mainfrom
claude/usb-clock-yields-to-din
Nov 1, 2026
Merged

repeat98 merged 1 commit into
mainfrom
claude/usb-clock-yields-to-din

Conversation

@repeat98

@repeat98 repeat98 commented Nov 1, 2026

Copy link
Copy Markdown
Owner
  • What was wrong or hard: with MIDI clock coming in on DIN and over USB at the same time, the Octatrack counted both. The UART0 ISR (0x400106ec) stamps each DIN 0xF8 into the clock timestamp (0x46c8345a, delta 0x46c83466). The base's shared USB MIDI receiver (usbmidi_rx.s, usbmidi_rx_decode, lines 308-323) stamps each USB 0xF8 into the same word and enqueues it into the same FIFO, framer and clock handler (0x40005a48), with no check for DIN. Both clocks together are one stream of twice the ticks: the handler reads about double the tempo and the project saves it. This is inferred from the code and an upstream review of the same receiver. It is not measured on our base. Every USB layout of the base (USB MIDI alone and all six USB Audio layouts) runs this one receiver: build_core.py writes it for each one, and the profile dispatch jumps to usbmidi_rx_isr_shim.
  • What this changes:
    • Nothing in the stock OS marks "DIN clock running". The only sign is the UART's stamp. So the receiver now keeps its own last stamp (mw_usb_clk_stamp). If the timestamp word differs from it, DIN wrote it. DIN owns the clock while that tick is younger than the clock handler's own resync gap: 1,881,600 DTCN0 counts, 167 ms (cmpil at 0x40005a96). A longer gap makes the handler restart its interval ring.
    • While DIN owns the clock, a USB clock, START, CONTINUE, STOP or song position (F8 FA FB FC F2) is dropped and counted in mw_usb_clock_dropped. Notes, CCs, active sensing and everything else still pass.
    • DIN stops: about 167 ms after its last tick, USB takes over. The first USB tick carries a delta past the gap, so the handler starts a fresh tempo on USB rather than mixing intervals. A DIN stamp found stale is taken as the receiver's own, so a DTCN0 wrap (about 380 s) cannot make an old DIN tick look recent.
    • Cost, as intended: a DIN device that sends clock all the time keeps USB clock out.
    • usbmidi_rx.s stays byte-exact to its MIT import record. The change is a guarded source transform, receiver_source in base_usb_functions.py (GPL, our own code): one store after the receiver's stamp, and one call before an event is enqueued. A changed seam refuses to build. The bles/bgts short branches of the decoder still fit (124 of 127 bytes). An out-of-range one fails the assembly.
    • The beta base is regenerated (npm run octatrack:base -- --beta --write, new identity 9680e9ac). The base-bound packages are relinked (npm run octatrack:module-packages -- --base-only: only requiredBase moved). The USB group stays inside its reserve, so arena, pool and symbols outside it don't move.
    • The changelog entry is changelog/2026-11-01-usb-clock-din.md. A short note is in sdk/machines/octatrack/elekloader/README.md.
  • How I checked it:
    • npm run pr:ready says Ready. sdk:check passed, including octatrack-module-packages.py --check. The base build is stock-free and flash-safe, and the browser .bin is byte-identical.
    • New test test_receiver_clock_guard_is_two_insertions_and_refuses_drift, which runs everywhere: the transform makes exactly its two insertions and refuses a changed seam or a second application.
    • The existing link test now links the shipped (transformed) receiver at two origins with full relocation. It runs where ColdFire binutils exist; it is skipped in CI.
    • New test test_usb_clock_and_transport_give_way_while_din_clocks runs the transformed usbmidi_rx_decode in Unicorn's ColdFire (CFV4E). DIN's UART stamp is modelled as two stores. Scenarios:
      • USB alone passes everything and stamps.
      • DIN clocking drops USB F8 FA FB FC F2, passes note, FE and F9, and leaves DIN's stamp and delta alone.
      • At the gap minus one count USB is dropped; at the gap it is taken, with a delta equal to the gap.
      • A DIN tick just before a DTCN0 wrap still owns the clock after it.
      • A stale stamp is adopted, so a full wrap later USB still passes.
      • SR comes back to the entry level.
    • Unicorn isn't in the repository's toolchain, so this test is skipped unless it is installed (pip install unicorn, as check_stub.py already needs). It passed here with Unicorn 2.1.4. Run against the unchanged receiver, the same DIN scenario lets USB F8 FA through with a half-tick delta.
    • No host-test harness runs the base's ColdFire assembly in CI: the C host tests cover C units, and the receiver is assembly. That is why the behaviour test needs Unicorn.
    • Not run on a unit or in the emulator. It needs a unit run before merge (below).
  • Unit run before merge (owner's MKII):
    • Setup: boot this branch's base with modules as usual. PROJECT > MIDI > SYNC: CLOCK RECEIVE and TRANSPORT RECEIVE on. A DIN clock source on MIDI IN at, for example, 100 BPM. A DAW on USB sending clock and transport at a different tempo, for example 140 BPM.
    1. Both: start DIN, then the DAW. The tempo reads 100 and steady, not about 240 or wandering, and the pattern plays at DIN's speed. A DAW STOP or START does nothing; DIN's STOP stops it. Save the project, reload it, and check the saved tempo is 100. Optional: mem:<mw_usb_clock_dropped>:4 rises while both run (the address is in the --out build's symbols.json), and mem:0x80001818:4 reads about 2400 (tempo x 24).
    2. USB alone (DIN cable out): it follows 140, and the DAW's START, STOP and CONTINUE work.
    3. DIN alone: it follows 100, unchanged from today.
    4. DIN stops mid-play with the DAW still running: within about a fifth of a second the Octatrack follows the DAW's clock and settles on 140 without a tempo spike. Starting DIN again takes the clock back.
  • Licences: usbmidi_rx.s and its MIT licence are unchanged. The upstream fix to the same receiver (MIT) supplied only the idea and the facts it relies on. The facts were checked against the disassembly of the user's own OS (the UART ISR's stamp, the handler's 167 ms resync gap). The code is written fresh in base_usb_functions.py (GPL-3.0-or-later) and copies none of it. MIT and GPL combine in the base as they already do.
  • Base PR queue: open base PRs merge one at a time, and each one regenerates the base: Fix: keep native FLEX readers alive for opted-in source machines #1108 → Mute Modes 0.2.2-experimental: SHADOW out of the stock timestretch task's SRAM (owner review) #1007 → Octatrack base: the key bus to Y $7E00, Sidechain's MON words to Y $FF0 (#1064) #1098 → Octatrack base: PERSONALIZE rows as a base table (ot_personalize_rows), so table owners no longer pin package hashes #1023 → ABI 8 builder fix → machine-rows → Improvement: rename the hook files (ships with the next base) #985 → Fix: Octatrack card builds keep the 16 bytes the stock DELAY reads past its table #1045. This one joins the queue. Whichever merges later regenerates again (npm run octatrack:base -- --stock OS.bin --beta --write, then npm run octatrack:module-packages -- --base-only).
  • Not covered: the legacy card module usb-audio-out-tracks-main-cue carries its own older copy of the receiver. It is another author's module and keeps the double-clock path until its own versioned update. The base fix does not change that card download.
  • What the guides lacked or got wrong, and where I fixed it: the base README said only where the shared receiver lives. It now also says that the receiver stays as imported and names the one guarded change the base makes to it (sdk/machines/octatrack/elekloader/README.md). The npm ci prepare step's ignored files (module-changelogs.json) were missing in a fresh worktree whose node_modules was symlinked. octatrack:base then fails until node scripts/machine-modules.mjs && node scripts/module-changelogs.mjs --write has run. That is already in DEVELOPER_WORKFLOW ("Archived source snapshots"), so nothing new.

🤖 Generated with Claude Code

The UART0 ISR stamps each DIN 0xF8 into the clock handler's timestamp,
and the shared USB MIDI receiver stamps each USB one into the same word;
both reach one FIFO, framer and clock handler. With a clock on each the
handler read double the tempo and the project saved it.

The receiver now drops a USB clock, START, CONTINUE, STOP or song
position while DIN is clocking: the last stamp is not the receiver's own
and is younger than the clock handler's resync gap (167 ms). The first
USB tick after DIN stops carries a delta past that gap, so the handler
starts a fresh tempo on USB. Every USB layout of the base shares this
receiver.

The vendored usbmidi_rx.s stays byte-exact to its import record; the
change is a guarded source transform in base_usb_functions.py. The beta
base is regenerated and the base-bound packages relinked (--base-only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added the bug Something isn't working label Nov 1, 2026
@repeat98
repeat98 marked this pull request as ready for review November 1, 2026 15:17
@repeat98
repeat98 merged commit 09c4c7a into main Nov 1, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant