From e4f176eec51d27f23f9f069288f044eeea83e1c6 Mon Sep 17 00:00:00 2001 From: Deva Date: Thu, 3 Sep 2026 19:47:46 +0530 Subject: [PATCH 01/12] =?UTF-8?q?Answer=20the=20rulings=20on=20=C2=A77.6?= =?UTF-8?q?=20and=20=C2=A711=20before=20the=20code=20leans=20on=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connectivity work needs four choices the PRD did not make, so they go in the log before the commits that depend on them. MIDI notes leave v1 (D-110): the word appeared once in the port table with no channel, note map, note-off rule or settings row behind it, and every guess inside such a map becomes a compatibility promise on the first unit shipped. §7.6 now says the MIDI jack carries clock. MIDI becomes two jacks and a jam link two cables (D-111): one TRS type A cable is a single current loop in one direction, so with one jack the roles would be fixed by which end was plugged in before anyone pressed play, and §11's two gestures could only work one way. The documents already disagreed (§7.6 one port, D-055 "jacks", D-065 four, the BOM three); this settles them at five ports, and D-065 carries the revisit. Two linked devices lock cycles, not only beats (D-112): the follower counts from the leader's Start modulo 96 and its first cycle begins on the leader's, so T-19 stands as written. The wait — up to one cycle — is shown as a count-in on play's backlight, because beat lock has no repair gesture. A port being followed is never driven (D-113): so a follower still clocks everything downstream of it, §11's "always on while playing" stays true, the settings rows keep meaning what they say, and the feedback ring two cables would otherwise form cannot form. Co-Authored-By: Claude Opus 4.8 --- DECISIONS.md | 6 +++++- PRD.md | 8 +++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 055591c..543105f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -68,7 +68,7 @@ What was decided, why, and when to look again. One row per decision. IDs are seq | D-062 | The production codec is the NXP SGTL5000XNLA3 (20-QFN), bought in run quantity up front; the 32-pin XNBA3 is the package alternative and the TI TLV320AIC3206 the fallback only if §7.4's 30 mW into 32 Ω is relaxed to 22.5 mW. | 2026-09-02. Of six candidates only the SGTL5000 and the AIC3206 have Teensy Audio drivers, and only the SGTL5000 meets §7.4 (30 mW into 32 Ω at 100 dB SNR with a separate 100 dB line out); the 1 mm-body XNAA3 on most reference designs is end-of-life, the XNLA3 has 16,177 in stock with a 39-week lead, and the XNBA3 has none until October 2026. There is no second-source die, which is why the run quantity is bought early. Rejected: WM8960 (end-of-life since January 2024); TLV320AIC3104 (15 mW into 32 Ω, no driver); NAU88C22 and ES8388 (no driver, no 32 Ω figure). | If NXP puts the XNLA3 or XNBA3 on the XNAA3's discontinuance path, or if the AIC3206 fallback is taken (then relax §7.4). | | D-063 | The production speaker amp is the Diodes PAM8302AASCR on a footprint that carries the thermal pad, so the TI TPA2005D1DGNR is a drop-in second source; it runs from the SYS rail with a fixed input divider, and whether to add a 5 V boost for §7.4's 1–2 W is decided at DVT after the sealed-pocket listening test. | 2026-09-02. The same IC as the EVT module, cheapest and deepest-stocked of the candidates with a shutdown pin, and pin-compatible with a TI part that has 17 dB more SNR if the noise floor proves audible. From SYS (3.0–4.5 V) it delivers about 0.65–1 W into 8 Ω, which is under §7.4; a TPS61023-class boost would restore 1.3 W at 1 % THD for about INR 50 and some efficiency, and only listening in the pocket can say whether the loudness is needed. Rejected: MAX98357A (I2S input, four times the price, datasheet unreachable); TPA2012D2 (stereo, half unused); PAM8301 (not recommended for new designs); TFA9882 (obsolete). | At the DVT loudness test (§14 phase 4). | | D-064 | The production speaker stays the Same Sky CLS0361-L152 that the EVT unit is voiced on; the CMS-3648-18SP is the recorded cheaper alternative and the DVT pocket test decides whether its 50 Hz higher resonance and 3 dB lower sensitivity are audible. | 2026-09-02. The player hears this part, so the lowest resonance and highest sensitivity among 36 mm speakers under 7 mm deep win over a USD 2.30 saving; keeping one speaker from EVT to DVT means the pocket, the high-pass and the master chain are tuned once. Its 100 and 500-piece prices were not read (UNVERIFIED; INR 721 at one, INR 348 at a thousand). Rejected: PUI AS03604MR-N50-R (Fo 200 Hz, the only real bass, 17.5 mm deep); Visaton K 36 WP (same resonance as the CMS-3648, three times its price). | After the DVT pocket measurement with both Same Sky parts fitted. | -| D-065 | All four 3.5 mm ports use one Same Sky SMT footprint (SJ-3524-SMT-TR with tip switch for headphones, SJ-3523-SMT-TR for line, sync and MIDI); the headphone detect is read on an ADC pin or through a level-shifted pull-up, never as a plain digital input, and the headphone sleeve is wired to the codec's HP_VGND and kept isolated from the other three sleeves and any metal panel. | 2026-09-02. One reel and one land pattern for four ports, 95k to 98k in stock, at a third of the Switchcraft price. Every switched jack found (Same Sky, Switchcraft, Kycon) rests its switch contact on the tip conductor, and the SGTL5000 drives headphones against a virtual ground that must not meet system ground, so the tip idles at mid-rail when unplugged and a 3.3 V logic pin would read it between its thresholds. Rejected: SJ-3525-SMT-TR (not a current part); Kycon STX-3100-5N (bushing and nut, but no stock until January 2027); Switchcraft (different footprint, no published insertion life). | If the enclosure needs a threaded bushing (then the Kycon STX-3100 "C" variants, whose drawings could not be fetched). | +| D-065 | All five 3.5 mm ports (four until D-111 split the MIDI jack in two) use one Same Sky SMT footprint (SJ-3524-SMT-TR with tip switch for headphones, SJ-3523-SMT-TR for line, sync and MIDI); the headphone detect is read on an ADC pin or through a level-shifted pull-up, never as a plain digital input, and the headphone sleeve is wired to the codec's HP_VGND and kept isolated from the other three sleeves and any metal panel. | 2026-09-02. One reel and one land pattern for four ports, 95k to 98k in stock, at a third of the Switchcraft price. Every switched jack found (Same Sky, Switchcraft, Kycon) rests its switch contact on the tip conductor, and the SGTL5000 drives headphones against a virtual ground that must not meet system ground, so the tip idles at mid-rail when unplugged and a 3.3 V logic pin would read it between its thresholds. Rejected: SJ-3525-SMT-TR (not a current part); Kycon STX-3100-5N (bushing and nut, but no stock until January 2027); Switchcraft (different footprint, no published insertion life).. **Revisited 2026-09-03:** D-111 made MIDI two jacks, so the count is five and the BOM's unswitched-jack line goes from three to four; the footprint argument is unchanged and is the reason adding one costs a line on an existing reel and no new land pattern. | If the enclosure needs a threaded bushing (then the Kycon STX-3100 "C" variants, whose drawings could not be fetched). | | D-066 | The production display is an ILI9341-driven 2.8" IPS glass with 4-wire SPI, so the EVT driver (ILI9341_T4) and HAL carry to DVT: Displaytech DT028DTFT-IPS first, EastRising ER-TFT028A2-4 second; the ring animation is a partial-window redraw, never a 60 fps full frame. Revisited 2026-09-02: a second source means a drop-in, same footprint and connector with no PCB change, and none exists for this display (the ER-TFT028A2-4's 50-pin FPC needs its own layout), so the display is single-sourced for EVT; the EastRising panel stays as an alternative that requires a layout change. | 2026-09-02. Both ILI9341 (100 ns write cycle) and ST7789V (66 ns) fall far short of 60 full frames per second at datasheet clocks (about 8 and 12 fps), so the only path to §7.3's 60 fps ring is differential redraw of the ring region, which ILI9341_T4 already does and no ST7789 Teensy driver does. Newhaven's 1000 cd/m² ST7789 panel is the brightest but draws 160 mA of backlight and would change the driver; the Crystalfontz ST7789V panel has the only verified volume price (USD 18.27 at 500) and stands as the cost ceiling and the fallback if the Displaytech price cannot be obtained. Rejected: the Displaytech SHB variant for production (120 mA at 6 V backlight); Waveshare modules (two driver ICs shipped under one SKU). | At DVT sample review with the three panels side by side, and if Displaytech's volume price exceeds the Crystalfontz ceiling. | | D-067 | Production pads and buttons are one compression-moulded translucent silicone sheet (about 50 Shore A) with carbon pills over gold-over-nickel interdigitated contacts, designed at 100–150 g actuation, 0.8–1.0 mm stroke and 40–50 % snap ratio, with a top-mounted 2020 RGB LED beside each pill; a second keypad house tools from the same drawing. | 2026-09-02. §7.2 asks for "tactile but quiet": silicone membranes are the only construction a fetched source calls silent, metal domes are audible by their maker's own guide, and SMD tactiles have 0.2 mm of travel; every pad product examined (SparkFun, Adafruit, NeoTrellis, Novation Circuit) uses this construction, and the keypad design guides converge on the same force and snap numbers. Tooling is USD 800–4,000 per compression tool with a prototype tool first; MOQ was not published by any source (UNVERIFIED). Rejected: silicone over domes (audible); capacitive pads (no tactile feedback); Circuit-style resistive rings (velocity is not required). | After the §16 blind feel test and the first prototype tool. | | D-068 | Pad and button lighting is one chain of sixteen WS2812B-2020-V6 LEDs on a DMA-driven pin (OctoWS2811), with a level shifter from 3.3 V; the button backlights use the same chain so armed states can show the Appendix D accent. | 2026-09-02. One data line and no CPU time in the audio path, no matrix driver, no scan noise, the NeoTrellis precedent, and USD 0.057 per LED at 500; the eight button positions cost USD 0.34 more than white 0603 LEDs and buy the accent colour the design language requires. Rejected: Lumissil IS31FL3733 plus discrete RGB (no 5 V rail needed but a QFN-48 and I2C traffic every frame); XINGLIGHT 1010 (5 mA per channel, about 60 % less light); SK6812MINI-E reverse mount (needs a PCB cutout whose size is unverified). | If the 5 V level shift or the chain's reset gap collides with a 60 fps refresh in the HAL. | @@ -113,3 +113,7 @@ What was decided, why, and when to look again. One row per decision. IDs are seq | D-107 | A song slot whose file will not parse (§9.6, T-97): a tap refuses it and says `hold to replace song 2`; a hold on that pad copies the song on screen over it and says `song 2 replaced`. The hold is live only on a slot the device already knows it cannot read, so no hold can destroy a song the player could still open, and a hold anywhere else in the song view still does nothing. | 2026-09-03. D-104 made a file that will not parse a slot rather than a gap, which stopped a pick from silently copying over somebody's song but left the slot unreachable: the only thing that replaced it was the player already being on it, so recovery meant a computer or a factory reset — a dead pad on an instrument that ships with no manual. Press-then-hold is already this device's idiom for a destructive thing, in `hold dice to clear` and `hold play to reset`, and §9.6 said the pads' hold gestures were inactive in the song view, so the gesture was free and consistent rather than new; PRD §9.6 gains the exception. Rejected: a second tap inside the arming timeout (a double tap is easy by accident, and every other confirmation here is a hold); quarantining the file at boot (needs a HAL rename or delete, and silently moves a player's file aside); a settings row to clear a slot (a sub-menu for a rare failure, against §9.6's one screen). | If usability round 1 shows nobody finds the hold, or if a fourth tile state would say it better than the status line. | | D-108 | A kit's samples come off the card, not the firmware image: `io::load_samples` reads `kits//` into the PSRAM `hal::sample_memory()` hands it — 1.5 MB, the eight pads of two seconds D-081 allows each — and fills a `sound::SampleBank` with what it found. Each WAV is read straight into the room left in that memory and parsed where it lands, then moved down over its own header, so nothing is ever staged twice. A file that is missing or is not 16-bit 48 kHz mono PCM costs its own pad its sound and no more. The simulator does the same thing with the same code: `host/CMakeLists.txt` seeds `out/sdcard/kits/` from `spec/kits/` at configure time. | 2026-09-03. §7.5 puts the samples in PSRAM and the kits on the card, and until now the device passed `app::init` an empty bank, so the drum pads were silent on hardware and the simulator used the render tool's host-only loader — two paths, one of them not the product's. Reading in place rather than through a staging buffer because a sample is up to 192 KB and the device has 63 KB of ordinary RAM free; there is nowhere to put a copy. `hal::sample_memory()` answers with nothing when no PSRAM is fitted, which is every board until bring-up, because writing to a section that is not backed would fault rather than fail. Rejected: samples in the firmware image (a kit could never be changed without a rebuild, which is what §12 rule 6 is against); streaming from the card at play time (file I/O in the audio path, forbidden by §12 rule 4). | At bring-up, when a real PSRAM chip says whether reads from it keep up with sixteen voices; or if a kit needs more than eight samples of two seconds. | | D-109 | A kit is readable text on the card: `tools/kit_builder.py` writes `kits//kit.txt` beside the header it already generates, both from the one `kit.json`, and `io::load_kit` reads it. One line a field, the five progressions in mode order, the eight pads in the order share-format §2 fixes with each pad's tap templates under it, and a step spelled exactly as a share code spells one — `engine::read_step` is now the only implementation of that table. Fractions are whole hundredths, so the device needs no float parser. `engine::Kit` holds its strings in fixed arrays rather than pointers, so a card kit and a compiled kit are one type, and `settings.txt`'s `kit=` line says which folder to play, falling back to the built-in kit when the card has no such kit. | 2026-09-03. §12 rule 6 asks for an open format so the community can make kits, and a kit that only exists as a C++ header can only be made by rebuilding the firmware. Text because §7.6 shows the card over USB and because the songs and settings are already text (D-104): one habit, not three. Rejected: parsing `kit.json` on the device (a JSON parser is a few hundred lines of firmware at a boundary that reads whatever a card holds, against the rule about reaching for the standard library first) and a packed binary (smaller and faster, but then only the tool can make a kit, which is the opposite of open). An unknown line fails the whole file rather than being skipped as an unknown settings row is: a setting the device ignores costs one row, a kit field it ignores would be an instrument quietly playing something other than what the kit says, and a field left out entirely fails for the same reason — the zero it would leave behind is not this kit — as does a single-valued field said twice, since which of the two the kit meant is not a question this firmware gets to answer. Every card string that reaches a path is checked against the grammar rather than trusted: a kit id is share-format §2's `[a-z0-9]{1,12}`, a pad's source is a plain file name, and a kit whose id is not the folder it was found in is refused, because its samples are looked for by its own id and would be hunted for somewhere else. The test that the card kit equals the compiled kit is what holds the builder's two outputs together, and CI diffs both. | If a kit needs a field that is not a number, a name or a share code; or when kits can be chosen from the settings view, which needs the samples reloaded rather than just the file re-read. | +| D-110 | MIDI notes are not in v1. §7.6's port table said the MIDI jack carried "clock and notes" and is amended to say clock; nothing sends a note on, a note off or a controller message, there is no note map in the kit file and no `midi notes out` settings row. | 2026-09-03, user ruling. "Notes" appeared once in the whole PRD with nothing behind it: no channel, no note numbers, no note-off rule for events that carry no duration (§6.2 events have a time and no length), no rule for the two upper tones of a chord, and no row in §9.4. §5 names the text view, export and MIDI clock for the live-coder user, not notes, and §3's video has none. Every choice inside such a map becomes a compatibility promise on the first shipped unit. At T-12's density notes are about 576 bytes a second at 180 bpm against a wire that carries 3125, which would force a priority policy — clock, then jam, then notes, dropped rather than queued — that exists only to serve a feature nobody asked for. Rejected: General MIDI drums on channel 10 with the melodic pads on 1–3 (a guess dressed as a standard); the same plus a fourteenth settings row (costs D-096, `ui::kSettingsRowCount` and the row list pinned in `ui_test.cpp` as well). | If a tester or a stockist asks to drive an external synth or record MIDI into a DAW; then notes get their own decision, their own scenarios and their own wire-priority rule. | +| D-111 | MIDI is two jacks, in and out, and a jam link is two TRS type A cables, each device's out to the other's in. §7.6 lists them as two ports, §11 no longer says "one TRS MIDI cable", D-065's port count is five, and the BOM's unswitched-jack line goes from three to four. The optocoupler count does not change: MIDI in was always one H11L1M input circuit and MIDI out one resistor pair, sharing a jack; only the jack splits (hardware files follow in the commit that drives the pins). | 2026-09-03, user ruling. One TRS type A cable is a single current loop in a single direction, so with one jack the jam roles would be decided by which end was plugged in before anybody pressed play, and §11's two gestures could only work one way. The documents already disagreed and this settles them: §7.6 counted one MIDI port, D-055 says "jacks" plural, D-065 counted four ports and the BOM prices three unswitched TRS jacks. Two jacks is what every MIDI device has, so the port stays a MIDI port for a DAW or a synth as §7.6 promises, and it is the only shape in which both §11 gestures work in both directions. Rejected: one jack and a one-way jam (cheapest, and it quietly stops the port being a MIDI port); a non-standard Rota-to-Rota jack with transmit on tip and receive on ring (two-way between Rotas, interoperable with nothing). | Never, in this hardware generation: a port count cannot be revisited after the PCB. v2's radio (D-007) is where the cable count stops mattering. | +| D-112 | Two linked devices lock cycles, not only beats: the follower counts MIDI clock pulses from the leader's Start modulo 96 — 24 to a beat and four beats to a cycle — and its first cycle begins on the leader's, so a play press while following waits up to one whole cycle. The wait is shown as a count-in: play's backlight pulses on the incoming beat and the message row reads `waiting for clock`. T-19 stands as written. | 2026-09-03, user ruling. Beat lock alone leaves the two rings up to three quarters of a cycle apart — 1.8 s at 100 bpm against T-19's 3 ms — and it has no repair gesture, because a 24 PPQN stream carries a tempo and no downbeat: "stop and play" would realign to a beat, not to the leader's bar, so bar alignment would be permanent luck. Start is the only cycle reference the wire carries and 96 ticks is exactly one Rota cycle, so using it costs nothing and needs no Song Position Pointer, which would put a data payload in the HAL's pulse stream. The cost is the wait, at most 2.4 s at 100 bpm and 1.33 s at 180: long enough that a tester may read the button as broken, which is why the count-in exists — a pulsing light on the incoming beat is the one idiom a musician reads instantly, and one bar is what Ableton's global quantize defaults to for the same reason. Rejected: beat lock with T-19 struck through (cheaper, and it gives up the thing the jam link is for); cycle lock only when a Start has been seen and beat lock otherwise (the same button behaving two ways depending on invisible state). | Usability round 1, on the wait specifically. The fall back to beat lock is one constant, so if testers press play twice while counting in, take it. | +| D-113 | A port being followed is never driven; every other out port is. So a follower still clocks everything downstream of it, §11's "always on while playing (configurable)" stays true as written, and `midi clock out` and `sync out` keep meaning what the rows say. | 2026-09-03, user ruling. A follower that went silent would override two settings rows with nothing on screen to explain it, and would stop Rota sitting in the middle of a chain — which is the §5 secondary user's whole reason for the port. The pulses cost nothing extra because the follower's own grid already generates them. The rule has to be per port rather than "a follower re-transmits": with D-111's two cables between two Rotas, re-transmitting onto the wire you are following makes a feedback ring, each device chasing the other's echo. Not driving the followed port kills that outright and still leaves PO on sync in → Rota → a second Rota on MIDI out working. It is also the rule the sync jack already needed, since in and out share one conductor there and the device cannot drive it while reading it. Rejected: silence while following with §11 amended and the row reading `out (following)` (honest, and it gives up the chain). | If a bring-up bench finds a real cabling in which a driven out port feeds back despite the rule. | diff --git a/PRD.md b/PRD.md index 65bc7ff..155455c 100644 --- a/PRD.md +++ b/PRD.md @@ -180,7 +180,8 @@ Edits (add, remove, split, skip, swap, speed) are applied at the next beat bound | 3.5 mm headphone | Stereo, with detect. | | 3.5 mm line out | Stereo, fixed level. | | 3.5 mm sync in / out | Pocket Operator–compatible pulse clock (in and out on one TRS, PO convention). | -| 3.5 mm MIDI in / out (TRS type A) | MIDI clock and notes. Jam link uses this cable (§11). | +| 3.5 mm MIDI in (TRS type A) | MIDI clock. | +| 3.5 mm MIDI out (TRS type A) | MIDI clock. The jam link uses both jacks and two cables (§11, D-111). | ### 7.7 Power - 2500 mAh Li-Po, target 20 hours of play on headphones, 10 on speaker. @@ -351,12 +352,13 @@ A song is the four section bodies (everything after `RT2:`, without lineage) joi ## 11. Sync and jam -- **Clock out**: PO-style pulse on sync out and MIDI clock on MIDI out, always on while playing (configurable). +- **Clock out**: PO-style pulse on sync out and MIDI clock on MIDI out, always on while playing (configurable). A port being followed is never driven, so a follower still clocks everything downstream of it (D-113). - **Clock in**: if a pulse or MIDI clock is present on the input, the device follows it; the speed knob then shows "ext". -- **Jam link**: two devices connected by one TRS MIDI cable. The device that pressed play first is the clock. Patterns are exchanged as SysEx containing the share code. Gestures: +- **Jam link**: two devices connected by two TRS MIDI cables, each device's out to the other's in (D-111). The device that pressed play first is the clock: a device that presses play having heard no clock becomes it, and one that presses play while clocks are arriving follows (D-113). Patterns are exchanged as SysEx containing the share code. Gestures: - Hold show + press a pad: send that pad's track to the other device (it lands on the same pad, replacing, with undo). - Hold show + press dice: send the whole loop. - Jam must survive unplugging: each device keeps playing its own parts on its own clock. +- Two linked devices lock cycles, not only beats: the follower counts from the leader's Start and its first cycle begins on the leader's, so play may wait up to one cycle and counts in on play's backlight (D-112). - Latency between linked devices ≤ 3 ms at the audio output. --- From 0abfddf7791233f1d4c16cb1f29b005a0644dbb7 Mon Sep 17 00:00:00 2001 From: Deva Date: Thu, 3 Sep 2026 19:48:23 +0530 Subject: [PATCH 02/12] Ask one object how long this beat is, so a clock has somewhere to answer from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit frames_of_beat was a free function in scheduler.cpp's anonymous namespace with one caller. It becomes app::Clock::beat_frames, and the scheduler is handed a Clock& beside its kit and asks it at every beat boundary instead of computing the length itself. No behaviour changes: the clock answers exactly what the moved line answered. The point is the seam — the length is about to come from a MIDI or sync wire, and the scheduler has no business owning the state that will decide it. The whole existing suite is the characterization test for the move. Co-Authored-By: Claude Opus 4.8 --- firmware/src/app/app.cpp | 7 +++++-- firmware/src/app/clock.cpp | 14 ++++++++++++++ firmware/src/app/clock.h | 21 +++++++++++++++++++++ firmware/src/app/scheduler.cpp | 12 ++++-------- firmware/src/app/scheduler.h | 4 +++- tests/app_test.cpp | 3 ++- 6 files changed, 49 insertions(+), 12 deletions(-) create mode 100644 firmware/src/app/clock.cpp create mode 100644 firmware/src/app/clock.h diff --git a/firmware/src/app/app.cpp b/firmware/src/app/app.cpp index 8f4818c..0977ea7 100644 --- a/firmware/src/app/app.cpp +++ b/firmware/src/app/app.cpp @@ -5,6 +5,7 @@ #include #include "app/card.h" +#include "app/clock.h" #include "app/controller.h" #include "app/params.h" #include "app/scheduler.h" @@ -56,7 +57,8 @@ sound::SampleBank the_samples; // scheduler's 30 KB event list and the queues stay with the ordinary statics. HAL_BULK_MEMORY sound::Engine sound_engine; HAL_BULK_MEMORY Model the_model(the_kit); -Scheduler scheduler(the_kit); +Clock the_clock; +Scheduler scheduler(the_kit, the_clock); Controller controller(the_kit); AudioPath audio; FiredLog the_fired_log; @@ -334,7 +336,8 @@ void init() { hal::lock(); // a timer already ticking (the harness re-initialises) cannot see the app half made new (&sound_engine) sound::Engine(); new (&the_model) Model(the_kit); - new (&scheduler) Scheduler(the_kit); + new (&the_clock) Clock(); + new (&scheduler) Scheduler(the_kit, the_clock); new (&controller) Controller(the_kit); audio.reset(); the_fired_log = FiredLog{}; diff --git a/firmware/src/app/clock.cpp b/firmware/src/app/clock.cpp new file mode 100644 index 0000000..86283d8 --- /dev/null +++ b/firmware/src/app/clock.cpp @@ -0,0 +1,14 @@ +#include "app/clock.h" + +#include "sound/limits.h" + +namespace app { + +namespace { +constexpr int kSecondsPerMinute = 60; +} // namespace + +// One beat is 60 / bpm seconds, rounded to a frame; a cycle is four of them (§6.1). +int Clock::beat_frames(int bpm) const { return (sound::kSampleRate * kSecondsPerMinute + bpm / 2) / bpm; } + +} // namespace app diff --git a/firmware/src/app/clock.h b/firmware/src/app/clock.h new file mode 100644 index 0000000..07ea6b7 --- /dev/null +++ b/firmware/src/app/clock.h @@ -0,0 +1,21 @@ +#pragma once + +// Where the beat's length comes from (PRD §6.1). The scheduler asks at every beat +// boundary and lays that beat's hits out over the answer. Today the answer is always +// the playing section's own tempo; §11's clock in, sync in and jam link will answer +// it from a wire instead. It is an object and not the line it replaces because that +// answer is about to hold state the scheduler has no business owning, and because the +// scheduler will not stay its only caller. +// +// Frames, never seconds: the audio callback's frame counter is the master clock +// (D-084). Nothing in engine/ sees this; a time there is a fraction of one cycle. +namespace app { + +class Clock { + public: + // How many frames long a beat at `bpm` is. §6.3 keeps bpm in 60–180, so the answer + // is 16000 to 48000 frames at 48 kHz. + int beat_frames(int bpm) const; +}; + +} // namespace app diff --git a/firmware/src/app/scheduler.cpp b/firmware/src/app/scheduler.cpp index 7881af8..47ad975 100644 --- a/firmware/src/app/scheduler.cpp +++ b/firmware/src/app/scheduler.cpp @@ -7,11 +7,6 @@ namespace app { namespace { -constexpr int kSecondsPerMinute = 60; - -// One beat is 60 / bpm seconds, rounded to a frame; a cycle is four of them (§6.1). -int frames_of_beat(int bpm) { return (sound::kSampleRate * kSecondsPerMinute + bpm / 2) / bpm; } - bool before(engine::Fraction time, int beat_in_cycle) { return static_cast(time.num) * kBeatsPerCycle < static_cast(beat_in_cycle) * time.den; } @@ -24,13 +19,14 @@ engine::State without_mutes(const engine::State& state) { } // namespace -Scheduler::Scheduler(const engine::Kit& kit) +Scheduler::Scheduler(const engine::Kit& kit, Clock& clock) : kit_(&kit), + clock_(&clock), seed_(0), generation_(0), running_(false), beat_start_(0), - beat_frames_(frames_of_beat(engine::kDefaultBpm)), + beat_frames_(clock.beat_frames(engine::kDefaultBpm)), beat_in_cycle_(0), cycle_index_(0), previous_cycle_start_(0), @@ -97,7 +93,7 @@ void Scheduler::begin_beat(Model& model, int64_t at, bool first, AudioPath& audi if (beat_in_cycle_ == 0) cross_cycle(model, first); const engine::State& live = model.sections[model.playing].state(); playing_ = without_mutes(live); - beat_frames_ = frames_of_beat(playing_.bpm); + beat_frames_ = clock_->beat_frames(playing_.bpm); engine::events(playing_, *kit_, cycle_index_, seed_, list_); next_event_ = 0; while (next_event_ < list_.count && before(list_.items[next_event_].time, beat_in_cycle_)) ++next_event_; diff --git a/firmware/src/app/scheduler.h b/firmware/src/app/scheduler.h index 88d969f..5ffe61d 100644 --- a/firmware/src/app/scheduler.h +++ b/firmware/src/app/scheduler.h @@ -3,6 +3,7 @@ #include #include "app/audio_path.h" +#include "app/clock.h" #include "app/model.h" #include "engine/events.h" #include "engine/kit.h" @@ -25,7 +26,7 @@ constexpr int kStartDelayBlocks = 2; // the first beat be class Scheduler { public: - explicit Scheduler(const engine::Kit& kit); + Scheduler(const engine::Kit& kit, Clock& clock); // The session's seed for chance and humanize (D-034); set once at init. void set_seed(uint32_t seed); @@ -59,6 +60,7 @@ class Scheduler { engine::Fraction fraction_of(int64_t sample) const; const engine::Kit* kit_; + Clock* clock_; // asked how long every beat is; shared, since app/ feeds it too uint32_t seed_; uint32_t generation_; bool running_; diff --git a/tests/app_test.cpp b/tests/app_test.cpp index 71329a9..8350c5a 100644 --- a/tests/app_test.cpp +++ b/tests/app_test.cpp @@ -331,7 +331,8 @@ TEST_CASE("T-83 The audio clock carries on past the 32-bit block count") { auto model = std::make_unique(engine::kits::kLofi); engine::tap(model->sections[0], Pad::kick, engine::kits::kLofi); // a kick at 0 model->transport = true; - app::Scheduler scheduler(engine::kits::kLofi); + app::Clock clock; + app::Scheduler scheduler(engine::kits::kLofi, clock); scheduler.set_seed(42); audio->reset(wrap - 4); scheduler.start(*model, *audio); // the first beat two blocks on: cycle 0 starts at wrap - 2 blocks From 5a9ee418b0437d0a0cad1e7229b50422681de502 Mon Sep 17 00:00:00 2001 From: Deva Date: Thu, 3 Sep 2026 19:49:03 +0530 Subject: [PATCH 03/12] Give the HAL both wires and the audio side an anchor to time them by MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connectivity surface lands in hal.h (D-114): read_clock_in hands over every MIDI clock, Start, Continue and Stop byte and every sync edge stamped with the microsecond the platform saw it, each port its own ring; send_clock_out is given a deadline, not a byte, so a pulse is armed a lookahead early rather than emitted when a 2 ms timer notices it; midi_read and midi_send are the rest of the wire, midi_send one byte at a time so a clock byte never queues behind a pattern; midi_port_open says whether this build has a port at all. Both platforms answer honestly for now — no port, nothing read, nothing sent — so neither build pretends to hardware that has not run. The device's Serial1 and the simulator's UDP link arrive with the commits that put a scope on them. The audio side publishes an AudioAnchor — the block's first frame beside the microsecond it was rendered — once a block, which is how app/ will convert a beat's frame into a wire deadline and an arriving pulse's stamp into a frame; AudioPath::reset drains it so a test's first anchor is its own. hal_fake gains scripted clock and MIDI queues and the two recorders, reserved up front so nothing grows inside the timer callback. Co-Authored-By: Claude Opus 4.8 --- DECISIONS.md | 1 + firmware/src/app/audio_path.cpp | 3 + firmware/src/app/audio_path.h | 13 ++++ firmware/src/hal/hal.h | 69 ++++++++++++++++++++++ firmware/src/hal/sdl/hal_sdl.cpp | 9 +++ firmware/src/hal/teensy/hal_teensy.cpp | 10 ++++ tests/hal_fake.cpp | 82 ++++++++++++++++++++++++++ tests/hal_fake.h | 35 +++++++++++ 8 files changed, 222 insertions(+) diff --git a/DECISIONS.md b/DECISIONS.md index 543105f..7aae94d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -117,3 +117,4 @@ What was decided, why, and when to look again. One row per decision. IDs are seq | D-111 | MIDI is two jacks, in and out, and a jam link is two TRS type A cables, each device's out to the other's in. §7.6 lists them as two ports, §11 no longer says "one TRS MIDI cable", D-065's port count is five, and the BOM's unswitched-jack line goes from three to four. The optocoupler count does not change: MIDI in was always one H11L1M input circuit and MIDI out one resistor pair, sharing a jack; only the jack splits (hardware files follow in the commit that drives the pins). | 2026-09-03, user ruling. One TRS type A cable is a single current loop in a single direction, so with one jack the jam roles would be decided by which end was plugged in before anybody pressed play, and §11's two gestures could only work one way. The documents already disagreed and this settles them: §7.6 counted one MIDI port, D-055 says "jacks" plural, D-065 counted four ports and the BOM prices three unswitched TRS jacks. Two jacks is what every MIDI device has, so the port stays a MIDI port for a DAW or a synth as §7.6 promises, and it is the only shape in which both §11 gestures work in both directions. Rejected: one jack and a one-way jam (cheapest, and it quietly stops the port being a MIDI port); a non-standard Rota-to-Rota jack with transmit on tip and receive on ring (two-way between Rotas, interoperable with nothing). | Never, in this hardware generation: a port count cannot be revisited after the PCB. v2's radio (D-007) is where the cable count stops mattering. | | D-112 | Two linked devices lock cycles, not only beats: the follower counts MIDI clock pulses from the leader's Start modulo 96 — 24 to a beat and four beats to a cycle — and its first cycle begins on the leader's, so a play press while following waits up to one whole cycle. The wait is shown as a count-in: play's backlight pulses on the incoming beat and the message row reads `waiting for clock`. T-19 stands as written. | 2026-09-03, user ruling. Beat lock alone leaves the two rings up to three quarters of a cycle apart — 1.8 s at 100 bpm against T-19's 3 ms — and it has no repair gesture, because a 24 PPQN stream carries a tempo and no downbeat: "stop and play" would realign to a beat, not to the leader's bar, so bar alignment would be permanent luck. Start is the only cycle reference the wire carries and 96 ticks is exactly one Rota cycle, so using it costs nothing and needs no Song Position Pointer, which would put a data payload in the HAL's pulse stream. The cost is the wait, at most 2.4 s at 100 bpm and 1.33 s at 180: long enough that a tester may read the button as broken, which is why the count-in exists — a pulsing light on the incoming beat is the one idiom a musician reads instantly, and one bar is what Ableton's global quantize defaults to for the same reason. Rejected: beat lock with T-19 struck through (cheaper, and it gives up the thing the jam link is for); cycle lock only when a Start has been seen and beat lock otherwise (the same button behaving two ways depending on invisible state). | Usability round 1, on the wait specifically. The fall back to beat lock is one constant, so if testers press play twice while counting in, take it. | | D-113 | A port being followed is never driven; every other out port is. So a follower still clocks everything downstream of it, §11's "always on while playing (configurable)" stays true as written, and `midi clock out` and `sync out` keep meaning what the rows say. | 2026-09-03, user ruling. A follower that went silent would override two settings rows with nothing on screen to explain it, and would stop Rota sitting in the middle of a chain — which is the §5 secondary user's whole reason for the port. The pulses cost nothing extra because the follower's own grid already generates them. The rule has to be per port rather than "a follower re-transmits": with D-111's two cables between two Rotas, re-transmitting onto the wire you are following makes a feedback ring, each device chasing the other's echo. Not driving the followed port kills that outright and still leaves PO on sync in → Rota → a second Rota on MIDI out working. It is also the rule the sync jack already needed, since in and out share one conductor there and the device cannot drive it while reading it. Rejected: silence while following with §11 amended and the row reading `out (following)` (honest, and it gives up the chain). | If a bring-up bench finds a real cabling in which a driven out port feeds back despite the rule. | +| D-114 | The HAL carries the two wires as bytes and one stamped pulse stream, not as messages (§7.6, §11): `read_clock_in` hands over every MIDI clock, Start, Continue and Stop byte and every sync edge with the microsecond the platform saw it, each port with its own ring; `send_clock_out` is given a deadline rather than a byte and the platform's one-shot sends it; `midi_read` and `midi_send` are the rest of the wire, with `midi_send` taking one byte at a time and refusing until it has left; `midi_port_open` says whether this build has a port at all. The audio side publishes an `AudioAnchor` — the block's first frame beside the microsecond it was rendered — once a block, which is how app/ converts either way. | 2026-09-03. A jam message is 246 bytes, 79 ms of wire, and a clock pulse has to leave in the middle of it; MIDI allows exactly that, because a System Real Time byte may sit between any two bytes of any other message, so a message-shaped surface could express it only by holding the wire for a fifth of a second or by adding a second lane beside it. The stamp has to be taken where the byte or the edge arrives: the main loop's gap is milliseconds and §11 allows three, so a stamp taken at the read would spend the whole budget before the follower had done anything — the same argument that put a time on `InputEvent` (D-088). A deadline rather than a byte for the same reason: the beat is decided in the 2 ms timer, and 2 ms of jitter is most of the budget. One byte at a time on send, because the caller cannot jump a TX FIFO it does not own: with io/ offering a payload byte every other byte time the wire runs half empty and a clock byte waits at most 320 µs, where six bytes a tick — the first arithmetic tried — would have kept the UART 96 % busy and made it wait 1.9 ms. The real-time bytes are lifted in `hal/` and not in `io/`, which is where §12 puts MIDI: only the code holding the UART can stamp a byte where it lands, and it is one comparison (`byte >= 0xF8`) and a demultiplex rather than validation — io/ still validates every SysEx payload. Each port gets its own ring so a shorted sync jack cannot crowd out the MIDI clock it is meant to lose to, and a full ring drops the newest pulse, since a ring that fills means nothing is draining it and a gap is then honest where a stale stamp is not. Rejected: whole messages with an all-or-nothing write (cannot interleave a pulse); reading the wire inside the 2 ms timer to dodge the main loop's gap (puts io/ work under `lock()`, which on the Teensy is `noInterrupts()`); raising the sync line from the audio callback (±1.3 ms and forbidden work in that callback, §12 rule 4). | If USB MIDI lands (§7.6), which arrives as a second pair of ports rather than as a change to this surface. | diff --git a/firmware/src/app/audio_path.cpp b/firmware/src/app/audio_path.cpp index 260c58f..ced975e 100644 --- a/firmware/src/app/audio_path.cpp +++ b/firmware/src/app/audio_path.cpp @@ -37,6 +37,8 @@ void AudioPath::reset(uint64_t blocks) { } sound::Params stale; params.take(stale); + AudioAnchor old_anchor; // an unclaimed anchor outlives take(), and a test's first must be its own + anchor.take(old_anchor); audio_blocks_ = blocks; low_blocks_.store(static_cast(blocks)); control_blocks_ = blocks; @@ -110,6 +112,7 @@ void AudioPath::render(float* left, float* right) { engine_->render(triggers, count, block_); std::memcpy(left, block_.left, sizeof block_.left); std::memcpy(right, block_.right, sizeof block_.right); + anchor.publish(AudioAnchor{block_start, hal::now_us()}); audio_blocks_ += 1; low_blocks_.store(static_cast(audio_blocks_), std::memory_order_release); } diff --git a/firmware/src/app/audio_path.h b/firmware/src/app/audio_path.h index 3ece25f..5c2f7dc 100644 --- a/firmware/src/app/audio_path.h +++ b/firmware/src/app/audio_path.h @@ -33,6 +33,18 @@ struct Immediate { uint64_t pressed_us; // when the platform saw the press; 0 = do not measure }; +// The two clocks side by side: the first frame of the block the audio side has just +// rendered, and the microsecond it read while rendering it. app/ needs the pair both +// ways — to turn a beat's frame into a deadline for the wire, and an arriving pulse's +// stamp into a frame — and neither clock can answer for the other, since one counts +// samples the codec asked for and the other counts real time. Published once a block; +// the reader latches the newest pair it has seen rather than treating a mailbox that +// answers false as a clock that stopped (D-114). +struct AudioAnchor { + int64_t frames; + uint64_t time_us; +}; + // A hit the sound engine was handed, with the frame it started on. struct Fired { int64_t sample; @@ -87,6 +99,7 @@ class AudioPath { SpscQueue immediate; SpscQueue fired; Mailbox params; + Mailbox anchor; std::atomic live_generation; // written by the scheduler at start and stop private: diff --git a/firmware/src/hal/hal.h b/firmware/src/hal/hal.h index 7f701bf..7ad0e31 100644 --- a/firmware/src/hal/hal.h +++ b/firmware/src/hal/hal.h @@ -39,6 +39,75 @@ struct InputEvent { uint64_t time_us; // now_us() when the platform saw it, for latency measurement }; +// The clock ports (§7.6, §11): one MIDI wire at 31250 baud and one Pocket Operator +// sync jack, both open all the time. Which of them the device listens to or talks +// on is a settings row (§9.4), not something the HAL decides. +enum class ClockPort : uint8_t { midi, sync }; +constexpr int kClockPortCount = 2; +constexpr uint32_t kMidiByteUs = 320; // 31250 baud, ten bits a byte: 3125 bytes a second and no more + +// One pulse of somebody's clock. `tick` is a MIDI clock byte or one edge on the +// sync jack; the other three are MIDI's transport messages, which the sync wire has +// no room for and never carries. +enum class ClockPulse : uint8_t { tick, start, resume, stop }; + +// A pulse a port saw, stamped by the platform where it arrived and not where it is +// read: a clock's whole value is when it came, and the main loop's gap is +// milliseconds, so reading late must cost how soon an estimate moves and never how +// right it is. InputEvent carries a time for the same reason (D-088). MIDI's four +// System Real Time bytes are lifted out of the byte stream by the platform, because +// one of them may sit between any two bytes of any other message, a SysEx included, +// and only the code holding the UART can stamp it where it lands; every other byte +// stays below for io/ to parse (D-114). +struct ClockIn { + ClockPort port; + ClockPulse pulse; + uint64_t time_us; +}; + +// Each port has its own ring this deep, so a shorted sync jack cannot crowd out the +// MIDI clock it is meant to lose to. A full ring drops the newest pulse: a ring that +// fills means nothing is draining it, and then a gap is honest where a stale stamp +// would not be. +constexpr int kClockInCapacity = 64; + +// Moves the pulses seen since the last call into `out`, oldest first, the two ports +// interleaved by time. Returns how many. From the main loop, beside read_input. +int read_clock_in(ClockIn* out, int capacity); + +// Sends one pulse on `port` at `at_us`, from the platform's own one-shot rather than +// in the caller's context. The beat is decided in a 2 ms timer and 2 ms is most of +// the 3 ms §11 allows between two linked devices, so what is handed over is the +// deadline and not the byte. A deadline already past is sent at once — which is all +// a platform without a one-shot ever does — and "already past" is a signed +// comparison, because these are microseconds since init and the difference wraps the +// other way otherwise. False when the port still has a pulse armed, and the caller +// offers that one again on its next tick. From the timer callback; never from the +// audio callback. +bool send_clock_out(ClockPort port, ClockPulse pulse, uint64_t at_us); + +// The MIDI wire itself, minus the real-time bytes read_clock_in already took: the +// jam link's SysEx (§11) and nothing else this firmware knows. midi_read moves what +// arrived since the last call into `out`, oldest first. midi_send takes at most one +// byte and refuses until that byte has left the platform, so a pulse armed by +// send_clock_out waits behind at most one byte time; io/ then offers a payload byte +// every other byte time, which leaves the wire half empty and the worst wait for a +// clock byte at 320 µs. Neither call blocks, waits or spins whatever the platform's +// own driver would do, since a spin under lock() is a hang with interrupts off +// rather than a delay. +constexpr int kMidiInputCapacity = 640; // a 512-character message and a quarter second of wire behind it +int midi_read(uint8_t* out, int capacity); +int midi_send(const uint8_t* bytes, int count); + +// Whether this build has a MIDI port at all: the device has one, the simulator only +// when it was started as one end of a link, the fake when a test gives it one. Said +// in a result rather than left to a send that takes nothing, which is what a busy +// wire looks like too; app/ then leaves the clock and the jam gestures quiet and says +// so, as a board with no PSRAM leaves the sample pads silent (T-100). A cable is a +// different question: no MIDI or sync jack can detect one, so an unplugged link is +// silence, which app/ reads as a clock that stopped (T-20). +bool midi_port_open(); + // Fills one block of planar float audio, exactly kAudioBlockFrames per channel. // Runs on the platform's audio thread or interrupt: no allocation, no locks, no // logging inside it (§12 rule 4). diff --git a/firmware/src/hal/sdl/hal_sdl.cpp b/firmware/src/hal/sdl/hal_sdl.cpp index a7569d8..d97637a 100644 --- a/firmware/src/hal/sdl/hal_sdl.cpp +++ b/firmware/src/hal/sdl/hal_sdl.cpp @@ -291,6 +291,15 @@ int battery_percent() { bool headphones_inserted() { return false; } +// No MIDI DIN on a desk machine. Two simulators share a cable over UDP behind +// ROTA_LINK, which link_sdl.cpp adds; with no link there is no port, and the +// simulator behaves exactly as it did before there was a wire at all. +int read_clock_in(ClockIn*, int) { return 0; } +bool send_clock_out(ClockPort, ClockPulse, uint64_t) { return false; } +int midi_read(uint8_t*, int) { return 0; } +int midi_send(const uint8_t*, int) { return 0; } +bool midi_port_open() { return false; } + void log(const char* line) { std::puts(line); std::fflush(stdout); diff --git a/firmware/src/hal/teensy/hal_teensy.cpp b/firmware/src/hal/teensy/hal_teensy.cpp index c96abd0..9b846f3 100644 --- a/firmware/src/hal/teensy/hal_teensy.cpp +++ b/firmware/src/hal/teensy/hal_teensy.cpp @@ -74,6 +74,16 @@ void start_timer(uint32_t period_us, TimerCallback callback) { void lock() { noInterrupts(); } void unlock() { interrupts(); } +// The MIDI wire and the sync jack, still undriven: Serial1 and the pins WIRING.md +// reserves are wired up in link_teensy.cpp, which arrives with the commit that +// puts a scope on them. Until then the device says it has no port, which is the +// truth about this firmware and not about the hardware. +int read_clock_in(ClockIn*, int) { return 0; } +bool send_clock_out(ClockPort, ClockPulse, uint64_t) { return false; } +int midi_read(uint8_t*, int) { return 0; } +int midi_send(const uint8_t*, int) { return 0; } +bool midi_port_open() { return false; } + void log(const char* line) { if (Serial) Serial.println(line); } diff --git a/tests/hal_fake.cpp b/tests/hal_fake.cpp index 36c7205..7e2feb8 100644 --- a/tests/hal_fake.cpp +++ b/tests/hal_fake.cpp @@ -22,6 +22,14 @@ std::map writes_by_path_; uint16_t framebuffer_[hal::kScreenWidth * hal::kScreenHeight]; int presented_ = 0; std::map> files_; +// One ring a port, as the device has: a flooded sync jack must not crowd out MIDI. +std::deque clock_in_[hal::kClockPortCount]; +std::vector clock_out_; +std::deque midi_in_; +std::vector midi_sent_; +bool midi_port_open_ = false; // a test that wants a wire asks for one +bool choke_midi_ = false; +bool refuse_clock_out_ = false; } // namespace @@ -44,9 +52,39 @@ void reset() { std::memset(framebuffer_, 0, sizeof framebuffer_); presented_ = 0; files_.clear(); + for (auto& port : clock_in_) port.clear(); + clock_out_.clear(); + midi_in_.clear(); + midi_sent_.clear(); + // Reserved, never grown mid-run: a push_back that allocates inside the timer + // callback would break the very rule T-79 exists to check (§12 rule 4). + clock_out_.reserve(16384); + midi_sent_.reserve(4096); + midi_port_open_ = false; + choke_midi_ = false; + refuse_clock_out_ = false; } void set_time_us(uint64_t now_us) { now_us_ = now_us; } + +void push_clock_in(hal::ClockPort port, hal::ClockPulse pulse, uint64_t time_us) { + std::deque& ring = clock_in_[static_cast(port)]; + if (static_cast(ring.size()) >= hal::kClockInCapacity) return; // full: the newest goes, as on the device + ring.push_back(hal::ClockIn{port, pulse, time_us}); +} + +void push_midi(const uint8_t* bytes, int count) { + for (int i = 0; i < count; ++i) { + if (static_cast(midi_in_.size()) >= hal::kMidiInputCapacity) return; + midi_in_.push_back(bytes[i]); + } +} + +void set_midi_port_open(bool open) { midi_port_open_ = open; } +void choke_midi(bool choke) { choke_midi_ = choke; } +void refuse_clock_out(bool refuse) { refuse_clock_out_ = refuse; } +const std::vector& clock_out() { return clock_out_; } +const std::vector& midi_sent() { return midi_sent_; } void refuse_writes(bool refuse) { refuse_writes_ = refuse; } void refuse_sample_memory(bool refuse) { refuse_sample_memory_ = refuse; } void push(const hal::InputEvent& event) { input_.push_back(event); } @@ -82,6 +120,50 @@ int read_input(InputEvent* out, int capacity) { return count; } +// Oldest first with the two ports interleaved by time, as the device delivers them: +// a test that pushes onto both gets them in the order they happened, not per port. +int read_clock_in(ClockIn* out, int capacity) { + int count = 0; + while (count < capacity) { + int from = -1; + for (int port = 0; port < kClockPortCount; ++port) { + if (clock_in_[port].empty()) continue; + if (from < 0 || clock_in_[port].front().time_us < clock_in_[from].front().time_us) from = port; + } + if (from < 0) break; + out[count++] = clock_in_[from].front(); + clock_in_[from].pop_front(); + } + return count; +} + +// The deadline is recorded, not waited for: a test reads clock_out() and checks the +// microsecond each pulse was armed for against the frame it belongs to. +bool send_clock_out(ClockPort port, ClockPulse pulse, uint64_t at_us) { + if (refuse_clock_out_) return false; + clock_out_.push_back(hal_fake::ClockOut{port, pulse, at_us}); + return true; +} + +int midi_read(uint8_t* out, int capacity) { + int count = 0; + while (count < capacity && !midi_in_.empty()) { + out[count++] = midi_in_.front(); + midi_in_.pop_front(); + } + return count; +} + +// One byte at a time, as hal.h promises, so a caller that offers more has to come +// back for the rest and the test sees the pacing the wire would impose. +int midi_send(const uint8_t* bytes, int count) { + if (choke_midi_ || count <= 0) return 0; + midi_sent_.push_back(bytes[0]); + return 1; +} + +bool midi_port_open() { return midi_port_open_; } + void start_audio(AudioCallback callback) { audio_callback_ = callback; } int audio_buffer_frames() { return kAudioBlockFrames; } diff --git a/tests/hal_fake.h b/tests/hal_fake.h index e47a3cd..59c3122 100644 --- a/tests/hal_fake.h +++ b/tests/hal_fake.h @@ -33,6 +33,41 @@ uint32_t timer_period_us(); // A board with no PSRAM fitted, which is every board until bring-up (T-100). void refuse_sample_memory(bool refuse); +// The clock ports and the MIDI wire (§7.6, §11). A test scripts what arrives and +// reads back what left, so a follower's arithmetic is checked against a partner it +// writes itself rather than against a second device. +// +// A pulse is pushed with the time it arrived, since that stamp is the whole of what +// a clock says; the fake takes it as given and does not stamp it with its own clock, +// so a test can place a pulse anywhere against the audio it renders. +void push_clock_in(hal::ClockPort port, hal::ClockPulse pulse, uint64_t time_us); +void push_midi(const uint8_t* bytes, int count); + +// A wire at all. Off to begin with, as both platforms are until their link files +// land, so a case that says nothing about the ports behaves exactly as it did before +// there were any. +void set_midi_port_open(bool open); + +// A port that still has a pulse armed, so every send_clock_out is refused: the path +// where the app has to offer the same pulse again (T-103). +void refuse_clock_out(bool refuse); + +// A wire that will not take a byte, for the path where a send has to be offered +// again: every midi_send takes nothing while this is on. +void choke_midi(bool choke); + +// One pulse the app armed, in the order the ports were asked. +struct ClockOut { + hal::ClockPort port; + hal::ClockPulse pulse; + uint64_t at_us; +}; +const std::vector& clock_out(); + +// Every byte the app put on the wire, in order, the pulses excluded: those carry a +// deadline rather than a byte and are in clock_out(). +const std::vector& midi_sent(); + // Every hal::write_file the card was asked for, refused ones included (T-99); with // a path, only the ones for that file. int writes(); From fc7578f2bdce82525698fcb90730d919601eb627 Mon Sep 17 00:00:00 2001 From: Deva Date: Thu, 3 Sep 2026 19:50:07 +0530 Subject: [PATCH 04/12] Put the beat on the wire, since a follower will need something to follow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app::Clock grows the out direction (D-115). It arms every pulse of the beat the scheduler just began — 24 to the beat on the MIDI port, 2 on the sync jack — before the same horizon the hits go out on, each with the microsecond its frame reaches the output. The deadline is when the frame is heard, one platform output buffer after the block that rendered it, so a pulse coincides with our own sound and not with the render: without that the host's 10.7 ms of SDL buffering would spend T-19's whole 3 ms budget. Every frame-to-microsecond conversion subtracts as int64, because a pulse is almost always behind the newest anchor and an unsigned subtraction would wrap. A refused pulse is offered again on the next tick and abandoned only at the beat boundary, never dropped: the ports carry a tempo and no downbeat, so a lost pulse is a phase error nothing afterwards can correct, and 24 arriving together would jump a listener's sequencer forward. The §9.4 rows gate the arming and never the counting, so a row switched on mid-play lands in phase. Start goes one byte early, since two bytes cannot leave a 31250 baud wire at once and it is the tick that must land on the beat; Continue is never sent, because a Rota stop always rewinds. Both platforms still report no port, so this changes no sound yet; T-102 and T-103 drive it through the fake. Co-Authored-By: Claude Opus 4.8 --- DECISIONS.md | 1 + firmware/src/app/app.cpp | 1 + firmware/src/app/clock.cpp | 92 +++++++++++++ firmware/src/app/clock.h | 66 ++++++++- firmware/src/app/scheduler.cpp | 7 +- spec/scenarios.md | 5 +- tests/clock_test.cpp | 238 +++++++++++++++++++++++++++++++++ 7 files changed, 401 insertions(+), 9 deletions(-) create mode 100644 tests/clock_test.cpp diff --git a/DECISIONS.md b/DECISIONS.md index 7aae94d..17663b9 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -118,3 +118,4 @@ What was decided, why, and when to look again. One row per decision. IDs are seq | D-112 | Two linked devices lock cycles, not only beats: the follower counts MIDI clock pulses from the leader's Start modulo 96 — 24 to a beat and four beats to a cycle — and its first cycle begins on the leader's, so a play press while following waits up to one whole cycle. The wait is shown as a count-in: play's backlight pulses on the incoming beat and the message row reads `waiting for clock`. T-19 stands as written. | 2026-09-03, user ruling. Beat lock alone leaves the two rings up to three quarters of a cycle apart — 1.8 s at 100 bpm against T-19's 3 ms — and it has no repair gesture, because a 24 PPQN stream carries a tempo and no downbeat: "stop and play" would realign to a beat, not to the leader's bar, so bar alignment would be permanent luck. Start is the only cycle reference the wire carries and 96 ticks is exactly one Rota cycle, so using it costs nothing and needs no Song Position Pointer, which would put a data payload in the HAL's pulse stream. The cost is the wait, at most 2.4 s at 100 bpm and 1.33 s at 180: long enough that a tester may read the button as broken, which is why the count-in exists — a pulsing light on the incoming beat is the one idiom a musician reads instantly, and one bar is what Ableton's global quantize defaults to for the same reason. Rejected: beat lock with T-19 struck through (cheaper, and it gives up the thing the jam link is for); cycle lock only when a Start has been seen and beat lock otherwise (the same button behaving two ways depending on invisible state). | Usability round 1, on the wait specifically. The fall back to beat lock is one constant, so if testers press play twice while counting in, take it. | | D-113 | A port being followed is never driven; every other out port is. So a follower still clocks everything downstream of it, §11's "always on while playing (configurable)" stays true as written, and `midi clock out` and `sync out` keep meaning what the rows say. | 2026-09-03, user ruling. A follower that went silent would override two settings rows with nothing on screen to explain it, and would stop Rota sitting in the middle of a chain — which is the §5 secondary user's whole reason for the port. The pulses cost nothing extra because the follower's own grid already generates them. The rule has to be per port rather than "a follower re-transmits": with D-111's two cables between two Rotas, re-transmitting onto the wire you are following makes a feedback ring, each device chasing the other's echo. Not driving the followed port kills that outright and still leaves PO on sync in → Rota → a second Rota on MIDI out working. It is also the rule the sync jack already needed, since in and out share one conductor there and the device cannot drive it while reading it. Rejected: silence while following with §11 amended and the row reading `out (following)` (honest, and it gives up the chain). | If a bring-up bench finds a real cabling in which a driven out port feeds back despite the rule. | | D-114 | The HAL carries the two wires as bytes and one stamped pulse stream, not as messages (§7.6, §11): `read_clock_in` hands over every MIDI clock, Start, Continue and Stop byte and every sync edge with the microsecond the platform saw it, each port with its own ring; `send_clock_out` is given a deadline rather than a byte and the platform's one-shot sends it; `midi_read` and `midi_send` are the rest of the wire, with `midi_send` taking one byte at a time and refusing until it has left; `midi_port_open` says whether this build has a port at all. The audio side publishes an `AudioAnchor` — the block's first frame beside the microsecond it was rendered — once a block, which is how app/ converts either way. | 2026-09-03. A jam message is 246 bytes, 79 ms of wire, and a clock pulse has to leave in the middle of it; MIDI allows exactly that, because a System Real Time byte may sit between any two bytes of any other message, so a message-shaped surface could express it only by holding the wire for a fifth of a second or by adding a second lane beside it. The stamp has to be taken where the byte or the edge arrives: the main loop's gap is milliseconds and §11 allows three, so a stamp taken at the read would spend the whole budget before the follower had done anything — the same argument that put a time on `InputEvent` (D-088). A deadline rather than a byte for the same reason: the beat is decided in the 2 ms timer, and 2 ms of jitter is most of the budget. One byte at a time on send, because the caller cannot jump a TX FIFO it does not own: with io/ offering a payload byte every other byte time the wire runs half empty and a clock byte waits at most 320 µs, where six bytes a tick — the first arithmetic tried — would have kept the UART 96 % busy and made it wait 1.9 ms. The real-time bytes are lifted in `hal/` and not in `io/`, which is where §12 puts MIDI: only the code holding the UART can stamp a byte where it lands, and it is one comparison (`byte >= 0xF8`) and a demultiplex rather than validation — io/ still validates every SysEx payload. Each port gets its own ring so a shorted sync jack cannot crowd out the MIDI clock it is meant to lose to, and a full ring drops the newest pulse, since a ring that fills means nothing is draining it and a gap is then honest where a stale stamp is not. Rejected: whole messages with an all-or-nothing write (cannot interleave a pulse); reading the wire inside the 2 ms timer to dodge the main loop's gap (puts io/ work under `lock()`, which on the Teensy is `noInterrupts()`); raising the sync line from the audio callback (±1.3 ms and forbidden work in that callback, §12 rule 4). | If USB MIDI lands (§7.6), which arrives as a second pair of ports rather than as a change to this surface. | +| D-115 | The beat goes out from the scheduler's own grid, not from a timer that notices it: `app::Clock` is asked how long every beat is — the line that used to sit inside `Scheduler::begin_beat` — and it arms every pulse of that beat due before the same horizon the hits go out on, each with the microsecond its frame reaches the output. A pulse a port refuses is offered again on the next tick and abandoned only at the beat boundary; the §9.4 rows gate the arming and never the counting. | 2026-09-03. One object owns both directions because the in and out grids are the same grid — a sync pulse is exactly every twelfth MIDI tick — so two would compute it twice and could disagree. Arming a lookahead early rather than sending when the 2 ms timer notices keeps the timer's period out of the wire's timing, which matters because 2 ms is most of the 3 ms §11 allows between two linked devices. The deadline is the microsecond the frame is *heard*, one platform output buffer after the block that rendered it, since a pulse that coincided with the render would run ahead of our own sound by the buffer — 2.7 ms on the device and 10.7 ms on the host, which would spend the whole budget on the reference platform. A refused pulse is retried rather than dropped because the ports carry a tempo and no downbeat, so a lost pulse is a phase error nothing afterwards can correct; it is abandoned at the beat boundary rather than queued, because 24 pulses arriving together would jump a listener's sequencer forward, and the next beat's first pulse lands on the beat either way. Counting while a row is off is what lets a row switched on mid-play land in phase instead of bursting. Rejected: raising the line from the audio callback (±1.3 ms, and forbidden work in that callback, §12 rule 4); sending Start and its tick at one deadline (a byte is 320 µs of wire, so Start goes one byte early and the tick keeps the beat). | When the follower lands: `begin_beat` then answers from a port as well as from the section, and this row is where the arithmetic it replaces is written down. | diff --git a/firmware/src/app/app.cpp b/firmware/src/app/app.cpp index 0977ea7..91f0817 100644 --- a/firmware/src/app/app.cpp +++ b/firmware/src/app/app.cpp @@ -368,6 +368,7 @@ void tick() { hal::lock(); for (int i = 0; i < count; ++i) controller.handle(events[i], the_model, scheduler, audio); controller.tick(now_us, the_model, scheduler, audio); + the_clock.set_ports(the_model.settings.midi_clock_out, the_model.settings.sync_out); hal::unlock(); Fired fired; diff --git a/firmware/src/app/clock.cpp b/firmware/src/app/clock.cpp index 86283d8..e1a4d36 100644 --- a/firmware/src/app/clock.cpp +++ b/firmware/src/app/clock.cpp @@ -5,10 +5,102 @@ namespace app { namespace { + constexpr int kSecondsPerMinute = 60; +constexpr int64_t kMicrosecondsPerSecond = 1000000; + +int pulses_per_beat(int port) { + return port == static_cast(hal::ClockPort::midi) ? kMidiPulsesPerBeat : kSyncPulsesPerBeat; +} + } // namespace +Clock::Clock() + : anchor_{0, 0}, + anchored_(false), + running_(false), + start_pending_(false), + enabled_{true, true}, + beat_start_(0), + beat_frames_(0), + next_pulse_{0, 0} {} + // One beat is 60 / bpm seconds, rounded to a frame; a cycle is four of them (§6.1). int Clock::beat_frames(int bpm) const { return (sound::kSampleRate * kSecondsPerMinute + bpm / 2) / bpm; } +void Clock::set_ports(bool midi_out, bool sync_out) { + enabled_[static_cast(hal::ClockPort::midi)] = midi_out; + enabled_[static_cast(hal::ClockPort::sync)] = sync_out; +} + +void Clock::start_transport() { + running_ = true; + start_pending_ = enabled_[static_cast(hal::ClockPort::midi)]; +} + +void Clock::stop_transport() { + running_ = false; + start_pending_ = false; + // Now, not on a grid: the sound stops now. The sync jack carries no transport, so + // there is nothing to tell the Pocket Operator except the absence of pulses. + if (enabled_[static_cast(hal::ClockPort::midi)] && hal::midi_port_open()) { + hal::send_clock_out(hal::ClockPort::midi, hal::ClockPulse::stop, hal::now_us()); + } +} + +int Clock::begin_beat(int64_t at, int bpm) { + beat_start_ = at; + beat_frames_ = beat_frames(bpm); + for (int port = 0; port < hal::kClockPortCount; ++port) next_pulse_[port] = 0; + return beat_frames_; +} + +void Clock::emit_until(int64_t horizon, AudioPath& audio) { + AudioAnchor fresh; + if (audio.anchor.take(fresh)) { // take() answers false until the next publish, so latch it + anchor_ = fresh; + anchored_ = true; + } + if (!running_ || !anchored_ || beat_frames_ <= 0) return; + if (!hal::midi_port_open()) return; // no wire in this build: count nothing, arm nothing + arm(static_cast(hal::ClockPort::midi), hal::ClockPort::midi, horizon); + arm(static_cast(hal::ClockPort::sync), hal::ClockPort::sync, horizon); +} + +uint64_t Clock::deadline_of(int64_t frame) const { + const int64_t ahead = frame - anchor_.frames + hal::audio_buffer_frames(); // signed: a frame behind is behind + const int64_t us = ahead * kMicrosecondsPerSecond / sound::kSampleRate; + return static_cast(static_cast(anchor_.time_us) + us); +} + +// Pulse `index` of this beat, spaced over the beat's own length so a tempo change +// lands on the beat as everything else does. +int64_t Clock::pulse_frame(int port, int index) const { + return beat_start_ + static_cast(index) * beat_frames_ / pulses_per_beat(port); +} + +void Clock::arm(int port, hal::ClockPort wire, int64_t horizon) { + const int per_beat = pulses_per_beat(port); + while (next_pulse_[port] < per_beat) { + const int64_t frame = pulse_frame(port, next_pulse_[port]); + if (frame >= horizon) return; + if (!enabled_[port]) { // the row is off: the count still moves, so switching it on lands in phase + next_pulse_[port] += 1; + continue; + } + // A Start byte and the tick it belongs to cannot leave at once — a byte is 320 µs + // of wire — so Start goes one byte early and the tick lands on the beat. + if (start_pending_ && wire == hal::ClockPort::midi) { + const uint64_t at_us = deadline_of(frame); + if (!hal::send_clock_out(wire, hal::ClockPulse::start, at_us - hal::kMidiByteUs)) return; + start_pending_ = false; + } + // A refused pulse is offered again on the next tick rather than dropped: the + // ports carry a tempo and no downbeat, so a lost pulse is a phase error nothing + // afterwards can correct, while a late one costs at most one timer period. + if (!hal::send_clock_out(wire, hal::ClockPulse::tick, deadline_of(frame))) return; + next_pulse_[port] += 1; + } +} + } // namespace app diff --git a/firmware/src/app/clock.h b/firmware/src/app/clock.h index 07ea6b7..874862e 100644 --- a/firmware/src/app/clock.h +++ b/firmware/src/app/clock.h @@ -1,21 +1,75 @@ #pragma once -// Where the beat's length comes from (PRD §6.1). The scheduler asks at every beat -// boundary and lays that beat's hits out over the answer. Today the answer is always -// the playing section's own tempo; §11's clock in, sync in and jam link will answer -// it from a wire instead. It is an object and not the line it replaces because that -// answer is about to hold state the scheduler has no business owning, and because the -// scheduler will not stay its only caller. +#include + +#include "app/audio_path.h" +#include "hal/hal.h" + +// Where the beat's length comes from, and where the beat goes out (PRD §6.1, §7.6, +// §11). The scheduler asks how long every beat is and lays that beat's hits out over +// the answer; the same grid is what the MIDI clock and the sync jack carry, which is +// why one object owns both directions instead of two computing it twice. +// +// Today the answer is always the playing section's own tempo; §11's clock in and sync +// in will answer it from a wire instead. // // Frames, never seconds: the audio callback's frame counter is the master clock // (D-084). Nothing in engine/ sees this; a time there is a fraction of one cycle. namespace app { +// MIDI clock is 24 PPQN and a Rota beat is a quarter note (§6.1), so a cycle is 96 +// pulses — which is what lets a follower find the leader's cycle and not just its +// beat (D-112). The sync jack carries the Pocket Operator's 2 PPQN, one pulse every +// eighth of a cycle. +constexpr int kMidiPulsesPerBeat = 24; +constexpr int kSyncPulsesPerBeat = 2; + class Clock { public: + Clock(); + // How many frames long a beat at `bpm` is. §6.3 keeps bpm in 60–180, so the answer // is 16000 to 48000 frames at 48 kHz. int beat_frames(int bpm) const; + + // Which out ports the player has turned on (§9.4). Set from the main loop under + // the lock, every pass: two stores cannot drift from the settings they mirror. The + // rows gate the arming and never the counting, so a row switched back on lands the + // next pulse in phase instead of firing a burst to catch up. + void set_ports(bool midi_out, bool sync_out); + + // Play and stop, from the scheduler's own transport. Start goes out with the first + // pulse of the first beat, so a listener's pulse count and ours agree from the same + // instant; Stop leaves at once, since the hits already handed over stop sounding at + // once too (T-82). Continue is never sent: a Rota stop always rewinds. + void start_transport(); + void stop_transport(); + + // The beat starting at frame `at`, under a section playing at `bpm`. Returns the + // beat's length in frames and takes the grid the out ports count over. + int begin_beat(int64_t at, int bpm); + + // Arms every pulse of this beat due before `horizon`, each with the microsecond its + // frame will be heard at. From the timer callback, under hal::lock(); it reads the + // audio side's anchor mailbox and never touches the wire itself. + void emit_until(int64_t horizon, AudioPath& audio); + + private: + // The microsecond `frame` reaches the output, which is later than the microsecond it + // was rendered by whatever the platform holds between the two: our own pulse has to + // coincide with our own sound, not with the render that produced it. + uint64_t deadline_of(int64_t frame) const; + int64_t pulse_frame(int port, int index) const; + void arm(int port, hal::ClockPort wire, int64_t horizon); + + AudioAnchor anchor_; // the newest pair the audio side has published + bool anchored_; // false until it has published one: nothing can be timed before that + bool running_; + bool start_pending_; // the Start byte, waiting for the first pulse to give it a time + bool enabled_[hal::kClockPortCount]; + int64_t beat_start_; + int beat_frames_; + int next_pulse_[hal::kClockPortCount]; // the first pulse of this beat not yet armed }; } // namespace app diff --git a/firmware/src/app/scheduler.cpp b/firmware/src/app/scheduler.cpp index 47ad975..67b3c2e 100644 --- a/firmware/src/app/scheduler.cpp +++ b/firmware/src/app/scheduler.cpp @@ -42,6 +42,7 @@ void Scheduler::set_seed(uint32_t seed) { seed_ = seed; } void Scheduler::start(Model& model, AudioPath& audio) { running_ = true; + clock_->start_transport(); generation_ += 1; audio.live_generation.store(generation_, std::memory_order_release); const int64_t at = audio.position() + static_cast(kStartDelayBlocks) * sound::kBlockSize; @@ -51,6 +52,7 @@ void Scheduler::start(Model& model, AudioPath& audio) { void Scheduler::stop(AudioPath& audio) { running_ = false; + clock_->stop_transport(); generation_ += 1; audio.live_generation.store(generation_, std::memory_order_release); } @@ -65,9 +67,10 @@ void Scheduler::tick(Model& model, AudioPath& audio) { continue; } const int64_t until = beat_end < horizon ? beat_end : horizon; - if (!push_window(model, until, audio.scheduled)) return; // the queue is full: the rest waits for the next tick + if (!push_window(model, until, audio.scheduled)) break; // the queue is full: the rest waits for the next tick scheduled_until_ = until; } + clock_->emit_until(horizon, audio); } // A beat boundary: where an edit lands (§6.7). The playing section's live state @@ -93,7 +96,7 @@ void Scheduler::begin_beat(Model& model, int64_t at, bool first, AudioPath& audi if (beat_in_cycle_ == 0) cross_cycle(model, first); const engine::State& live = model.sections[model.playing].state(); playing_ = without_mutes(live); - beat_frames_ = clock_->beat_frames(playing_.bpm); + beat_frames_ = clock_->begin_beat(beat_start_, playing_.bpm); engine::events(playing_, *kit_, cycle_index_, seed_, list_); next_event_ = 0; while (next_event_ < list_.count && before(list_.items[next_event_].time, beat_in_cycle_)) ++next_event_; diff --git a/spec/scenarios.md b/spec/scenarios.md index 6cceed7..bcaefe7 100644 --- a/spec/scenarios.md +++ b/spec/scenarios.md @@ -1,6 +1,6 @@ # Acceptance scenarios -Every behaviour in the PRD has one row here, and every engine test names the ID it covers (PRD §12 rule 2). T-01–T-24 are PRD §13 unchanged. T-25 onward were added on 2026-09-02 for §6 behaviours that had no scenario, T-85 onward on 2026-09-03 for the §9 views and the lights, T-95 and T-96 the same day for the last two §8.2 gestures, T-97–T-99 for what the card keeps and T-100 and T-101 for the kit; the PRD section each comes from is in brackets. +Every behaviour in the PRD has one row here, and every engine test names the ID it covers (PRD §12 rule 2). T-01–T-24 are PRD §13 unchanged. T-25 onward were added on 2026-09-02 for §6 behaviours that had no scenario, T-85 onward on 2026-09-03 for the §9 views and the lights, T-95 and T-96 the same day for the last two §8.2 gestures, T-97–T-99 for what the card keeps and T-100 and T-101 for the kit, and T-102 onward the same day for §7.6 and §11's clock, sync jack and jam link; the PRD section each comes from is in brackets. Conventions: fractions are of one cycle; the default kit (lofi), C minor and 100 bpm are assumed unless stated; "tap ×n" means n taps on that pad starting from empty, following the kit's smart defaults (§6.6); per-track modifier strings are the share-code form from `spec/share-format.md` §3. IDs are never reused: retire a scenario by striking it through, not by deleting it. @@ -108,6 +108,9 @@ Conventions: fractions are of one cycle; the default kit (lofi), C minor and 100 | T-100 | Boot with the kit's WAVs on the card; then with one missing, one that is not 16-bit 48 kHz mono, one longer than the two seconds a sample may be, and one that is not a WAVE at all; then with no card, and on a board with no PSRAM fitted (§7.5, §12 rule 6, D-081, D-108) | Each sample pad plays the samples in its own file, packed one after another into the PSRAM and none of them overlapping; a pad whose file is missing or is not a sample this firmware can play is silent and the log names the file and what was wrong with it, while every other pad still sounds. With no card every sample pad is silent and the synth pads play on; with no PSRAM there is nowhere to put a sample at all, which is said once rather than per pad. A pad whose sample came off the card is heard: the same tap is loud with it and inaudible without. | | T-101 | Read `kits/lofi/kit.txt` as tools/kit_builder.py wrote it; then a file with no `RTK1`, one with a line this firmware does not know, one a pad short, one whose filter is 11, and one whose template holds a character no step can be; then a kit whose id is not the folder it sits in, one whose pad names a source outside its folder, and a settings file whose `kit=` could climb out of `kits/`; then boot with the settings naming a kit on the card, and naming one that is not there (§7.5, §12 rule 6, D-109) | The kit read off the card is equal, field for field, to the one compiled into the firmware — which is what keeps the builder's two outputs saying the same thing. Every malformed file is refused whole and logged: a kit is not a place to guess, so an unknown line fails rather than being skipped, unlike the settings, and a file missing any field it should have — including the ones with no count, whose absence would otherwise leave a zero behind — does not load, nor does one that says a single-valued field twice. Nothing a card holds becomes a path: a kit id is 1–12 of `a`–`z` and `0`–`9` as share-format §2 spells one, a pad's source is a plain file name, and a kit whose id is not the folder it was found in is refused, since its samples would be hunted for somewhere else. Booting with `kit=` naming a kit on the card plays that kit, and a fresh loop takes its swing, filter and fx from it; naming a kit that is not there logs the path and plays the one built in, so the device always comes up. | +| T-102 | Kick ×4 at 100 bpm with `midi clock out` and `sync out` on; play, two cycles, stop; then each row off, and one switched back on mid-play (§7.6, §11, §9.4, D-113, D-114) | 24 pulses a beat on the MIDI port and 2 on the sync jack — 96 and 8 to the cycle, which is what lets a follower find the cycle and not just the beat (D-112). Each pulse is armed for the microsecond its frame reaches the output, one platform buffer after the block that rendered it, so a pulse coincides with the sound it belongs to and not with the render; across two cycles nothing accumulates, because every pulse's frame is derived from the beat it is in. Start goes one byte time before the first tick, since two bytes cannot leave a 31250 baud wire at once and it is the tick that must land on the beat; the sync jack carries no transport, so it gets no Start. Stop leaves at once when play stops, as the handed-over hits stop sounding at once (T-82), and Continue is never sent, because a Rota stop always rewinds. A row switched off silences its own port and no other, and takes its Start with it; the pulse count keeps moving while a row is off, so a row switched back on mid-play lands on the grid the port would have been on all along instead of firing a burst to catch up. | +| T-103 | Clock out at 60 bpm and at 180 bpm; then a timer pass that overslept half a beat; then a port that refuses every pulse for a beat (§6.3, §7.6, §11, D-114) | Sync pulses 500 ms apart at 60 bpm and 167 ms at 180, both inside the 12.5 ms to 1.5 s a Pocket Operator's input accepts, and MIDI pulses 41.7 ms and 13.9 ms apart. The pulses an overslept timer missed go out with the deadlines they were due — late rather than lost, because 24 PPQN carries a tempo and no downbeat, so a dropped pulse is a phase error nothing afterwards can correct while a late one costs at most one timer period. A port that refuses takes nothing at all, and what it refused is offered again and lands back on the same grid one pulse at a time: however long the wire was busy, the backlog never leaves as a burst. | + ## Watch in testing Design bets with a known fallback. Observe them in usability round 1 (PRD §14, phase 2); the fallback is written down so nobody re-derives it. diff --git a/tests/clock_test.cpp b/tests/clock_test.cpp new file mode 100644 index 0000000..fdde4d4 --- /dev/null +++ b/tests/clock_test.cpp @@ -0,0 +1,238 @@ +// The clock on the wire (PRD §7.6, §11): spec/scenarios.md T-102, T-103. +// +// The fake's clock is the audio clock — the harness sets now_us from the frame count +// before every callback — so a pulse due on a frame has one right microsecond and the +// test can name it. What it cannot check is a real UART or a real pin; that is T-118, +// on the bench. +#include +#include + +#include "app_support.h" +#include "engine/kits/lofi.h" +#include "ui/settings.h" + +using namespace app_support; + +namespace { + +// A pulse due on `frame` is armed for the microsecond that frame reaches the output, +// which is one platform buffer later than the block that rendered it: our own pulse +// has to coincide with our own sound (D-114). +uint64_t heard_at(int64_t frame) { return us_of(frame + hal::audio_buffer_frames()); } + +// The conversion floors twice — the anchor's own microsecond, then the offset from it +// — so a deadline may sit a microsecond either side of the exact answer. +constexpr uint64_t kRounding = 2; + +bool near_us(uint64_t got, uint64_t want) { + return got > want ? got - want <= kRounding : want - got <= kRounding; +} + +std::vector deadlines(hal::ClockPort port, hal::ClockPulse pulse) { + std::vector out; + for (const hal_fake::ClockOut& sent : hal_fake::clock_out()) { + if (sent.port == port && sent.pulse == pulse) out.push_back(sent.at_us); + } + return out; +} + +int count_of(hal::ClockPort port, hal::ClockPulse pulse) { return static_cast(deadlines(port, pulse).size()); } + +// Every pulse on `port` due before `until`, so the ones armed a lookahead past the +// end of the run are not counted against it. The boundary is nudged by the rounding, +// since a pulse due exactly on `until` belongs to what comes after. +int ticks_before(hal::ClockPort port, int64_t until) { + int count = 0; + for (const uint64_t at_us : deadlines(port, hal::ClockPulse::tick)) { + if (at_us + kRounding < heard_at(until)) count += 1; + } + return count; +} + +// The grid point nearest `at_us`, as the microsecond it would be heard at. Reading a +// microsecond back as a frame is coarse — one microsecond is 48 frames — but grid +// points are thousands of frames apart, so which one is meant is never in doubt, and +// the comparison itself is then exact. +uint64_t nearest_grid(uint64_t at_us, int64_t origin, int64_t step) { + const int64_t frame = + static_cast(at_us) * sound::kSampleRate / 1000000 - hal::audio_buffer_frames(); + const int64_t index = (frame - origin + step / 2) / step; + return heard_at(origin + index * step); +} + +// Every pulse from `from` onwards sits on the grid `origin` and `step` describe, and +// no two of them are closer together than the grid: a backlog is never fired off as a +// burst, however long the wire was busy. +void check_on_grid(const std::vector& pulses, size_t from, int64_t origin, int64_t step) { + for (size_t i = from; i < pulses.size(); ++i) { + CHECK(near_us(pulses[i], nearest_grid(pulses[i], origin, step))); + if (i > from) CHECK(pulses[i] - pulses[i - 1] >= us_of(step) - kRounding); + } +} + +void open_settings(World& w) { + w.button_down(hal::Button::undo); + w.run_for(kSecond / 10); + w.button_down(hal::Button::show); + w.run_for(kSecond / 2); + w.button_up(hal::Button::show); + w.button_up(hal::Button::undo); + REQUIRE(w.model().view == app::View::settings); +} + +// Puts the cursor on `row` and turns the filter knob, which is what sets a row (D-096). +void set_row(World& w, ui::SettingsRow row, bool on) { + w.turn(hal::Encoder::speed, static_cast(row) - w.model().settings_cursor); + REQUIRE(w.model().settings_cursor == static_cast(row)); + w.turn(hal::Encoder::filter, on ? 1 : -1); +} + +} // namespace + +TEST_CASE("T-102 The beat goes out on both ports while playing, and each row can stop it") { + World w; + hal_fake::set_midi_port_open(true); + w.tap(Pad::kick, 4); + REQUIRE(w.state(0).bpm == 100); + w.play(); + w.run_until(w.cycle_start(2)); + + // 24 to the beat and four beats to a cycle is 96, which is the number that lets a + // follower find the cycle and not just the beat (D-112); the sync jack carries the + // Pocket Operator's two. + const int64_t beat = kBeatFrames; + CHECK(ticks_before(hal::ClockPort::midi, w.cycle_start(2)) == 2 * 96); + CHECK(ticks_before(hal::ClockPort::sync, w.cycle_start(2)) == 2 * 8); + + // Every pulse on its own frame, and the last of the two cycles exactly two cycles + // on: the spacing is derived from the beat each time, so nothing accumulates. + const std::vector midi = deadlines(hal::ClockPort::midi, hal::ClockPulse::tick); + const std::vector sync = deadlines(hal::ClockPort::sync, hal::ClockPulse::tick); + REQUIRE(midi.size() >= 192); + REQUIRE(sync.size() >= 16); + for (int i = 0; i < 192; ++i) { + CHECK(near_us(midi[static_cast(i)], heard_at(w.origin + static_cast(i) * beat / 24))); + } + for (int i = 0; i < 16; ++i) { + CHECK(near_us(sync[static_cast(i)], heard_at(w.origin + static_cast(i) * beat / 2))); + } + + // Start is one byte early so the tick it belongs to lands on the beat: two bytes + // cannot leave a 31250 baud wire at once. + REQUIRE(count_of(hal::ClockPort::midi, hal::ClockPulse::start) == 1); + CHECK(deadlines(hal::ClockPort::midi, hal::ClockPulse::start)[0] == midi[0] - hal::kMidiByteUs); + CHECK(count_of(hal::ClockPort::sync, hal::ClockPulse::start) == 0); // the sync wire has no room for a transport + + // Stop leaves at once, because the hits already handed over stop sounding at once + // (T-82), and Continue is never sent at all: a Rota stop always rewinds. + CHECK(count_of(hal::ClockPort::midi, hal::ClockPulse::stop) == 0); + w.press(hal::Button::play); + w.frame(); + CHECK(count_of(hal::ClockPort::midi, hal::ClockPulse::stop) == 1); + CHECK(count_of(hal::ClockPort::midi, hal::ClockPulse::resume) == 0); + + SUBCASE("a row switched off stops its own port and leaves the other one alone") { + open_settings(w); + set_row(w, ui::SettingsRow::midi_clock_out, false); + w.press(hal::Button::show); + const int midi_before = count_of(hal::ClockPort::midi, hal::ClockPulse::tick); + const int sync_before = count_of(hal::ClockPort::sync, hal::ClockPulse::tick); + w.play(); + w.run_until(w.cycle_start(1)); + CHECK(count_of(hal::ClockPort::midi, hal::ClockPulse::tick) == midi_before); + CHECK(count_of(hal::ClockPort::sync, hal::ClockPulse::tick) > sync_before); + // No Start either: a Start byte with no clock behind it would tell a listener to + // run on a tempo it will never be given. + CHECK(count_of(hal::ClockPort::midi, hal::ClockPulse::start) == 1); + } + + SUBCASE("a row switched on mid-play lands in phase instead of firing a burst") { + open_settings(w); + set_row(w, ui::SettingsRow::sync_out, false); + w.press(hal::Button::show); + w.play(); + w.run_until(w.cycle_start(1)); + const int quiet = count_of(hal::ClockPort::sync, hal::ClockPulse::tick); + open_settings(w); + set_row(w, ui::SettingsRow::sync_out, true); + w.press(hal::Button::show); + w.run_for(2 * kBeatFrames); + const std::vector sync = deadlines(hal::ClockPort::sync, hal::ClockPulse::tick); + REQUIRE(static_cast(sync.size()) > quiet); + // The count kept moving while the row was off, so what comes back is the grid the + // port would have been on all along — not a burst timed from the moment it + // returned, and not a pulse dropped because it was already due. + check_on_grid(sync, static_cast(quiet), w.origin, kBeatFrames / app::kSyncPulsesPerBeat); + } +} + +TEST_CASE("T-103 The pulse spacing follows the tempo, and a refused pulse is not turned into a burst") { + World w; + hal_fake::set_midi_port_open(true); + w.tap(Pad::kick, 4); + + SUBCASE("at 60 bpm and at 180 bpm the sync pulses stay inside a Pocket Operator's window") { + w.turn(hal::Encoder::speed, -100); // clamped at the bottom of §6.3's range + REQUIRE(w.state(0).bpm == 60); + w.play(); + w.run_until(w.origin + 2 * kSecond); + const std::vector slow = deadlines(hal::ClockPort::sync, hal::ClockPulse::tick); + REQUIRE(slow.size() >= 3); + const uint64_t slow_step = slow[1] - slow[0]; + CHECK(near_us(slow_step, 500000)); // 2 PPQN at 60 bpm: half a second + CHECK(near_us(slow[2] - slow[1], slow_step)); + const std::vector slow_midi = deadlines(hal::ClockPort::midi, hal::ClockPulse::tick); + REQUIRE(slow_midi.size() >= 2); + CHECK(near_us(slow_midi[1] - slow_midi[0], 41666)); // 24 PPQN at 60 bpm + + w.press(hal::Button::play); + w.frame(); + w.turn(hal::Encoder::speed, 120); // one bpm a detent, clamped at the top of the range + REQUIRE(w.state(0).bpm == 180); + const int before = count_of(hal::ClockPort::sync, hal::ClockPulse::tick); + w.play(); + w.run_until(w.origin + kSecond); + const std::vector fast = deadlines(hal::ClockPort::sync, hal::ClockPulse::tick); + REQUIRE(static_cast(fast.size()) >= before + 3); + const uint64_t fast_step = fast[static_cast(before) + 1] - fast[static_cast(before)]; + CHECK(near_us(fast_step, 166666)); // 2 PPQN at 180 bpm + // Both ends of §6.3's tempo range sit inside the 12.5 ms to 1.5 s a Pocket + // Operator's input accepts (BOM, PO-16 guide), so the jack works across it. + CHECK(fast_step > 12500); + CHECK(slow_step < 1500000); + } + + SUBCASE("an overslept timer sends the pulses it missed rather than dropping them") { + w.play(); + w.run_for(kBeatFrames / 4); + const int before = count_of(hal::ClockPort::midi, hal::ClockPulse::tick); + const int64_t resumes = w.skip_timer_until(w.frames + kBeatFrames / 2); // no tick for half a beat + w.run_until(resumes + kBlock); + w.run_for(kBlock); + // The pulses of that half beat are on the wire with the deadlines they were due, + // late rather than lost: a lost pulse is a phase error nothing afterwards can + // correct, since 24 PPQN carries a tempo and no downbeat. + CHECK(count_of(hal::ClockPort::midi, hal::ClockPulse::tick) > before + 8); + const std::vector midi = deadlines(hal::ClockPort::midi, hal::ClockPulse::tick); + for (size_t i = 1; i < midi.size(); ++i) { + CHECK(near_us(midi[i] - midi[i - 1], us_of(kBeatFrames / 24))); // still one grid, still no drift + } + } + + SUBCASE("a port that still has a pulse armed is offered the next one on the next tick") { + w.play(); + w.run_for(kBeatFrames / 4); + const int before = count_of(hal::ClockPort::midi, hal::ClockPulse::tick); + REQUIRE(before > 0); + hal_fake::refuse_clock_out(true); + w.run_for(kBeatFrames / 2); + CHECK(count_of(hal::ClockPort::midi, hal::ClockPulse::tick) == before); // nothing was taken + hal_fake::refuse_clock_out(false); + w.run_for(2 * kBeatFrames); + const std::vector midi = deadlines(hal::ClockPort::midi, hal::ClockPulse::tick); + REQUIRE(static_cast(midi.size()) > before); + // What the wire refused is offered again and lands back on the same grid, one + // pulse at a time: a beat's worth of refusals never becomes a burst of 24. + check_on_grid(midi, static_cast(before), w.origin, kBeatFrames / app::kMidiPulsesPerBeat); + } +} From 6c03034ea74e3fede86a40bbfff735b258bf75b7 Mon Sep 17 00:00:00 2001 From: Deva Date: Thu, 3 Sep 2026 20:07:59 +0530 Subject: [PATCH 05/12] Spell a loop as a SysEx message, since a share code already survives the wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit io::MidiPort formats and parses the jam link's messages (D-116), with no app wiring yet: the envelope F0 7D 'R' 'T' 01 F7 around an ordinary share code, so there is no second grammar to keep and no RT3 — the code inside is unchanged and the envelope carries its own version byte. format_loop and format_track differ only in the two header bytes; both send the whole loop with its own id (io::shared_code), and the receiver takes what it asked for. The parser is a byte-at-a-time state machine: F0 starts and re-syncs, F7 ends, a payload byte (all 0x2C-0x7E by §10) is gathered, and any byte with the top bit set that is not F0/F7 is skipped — so a real-time byte the HAL missed, or wire noise, cannot derail a message. It refuses a foreign id, a missing tag, an unknown version, an out-of-range pad (the one field an untrusted wire controls, checked like a card path, D-109), a non-code payload, and a code past D-106's cap, which stays the only cap. spec/jam-link.md is the wire format's normative form with two golden messages; T-111 asserts the empty loop byte-for-byte, T-112 the refusals and re-sync. Co-Authored-By: Claude Opus 4.8 --- DECISIONS.md | 1 + firmware/src/io/midi.cpp | 81 ++++++++++++++++++ firmware/src/io/midi.h | 84 ++++++++++++++++++ spec/jam-link.md | 65 ++++++++++++++ spec/scenarios.md | 5 +- tests/midi_test.cpp | 181 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 firmware/src/io/midi.cpp create mode 100644 firmware/src/io/midi.h create mode 100644 spec/jam-link.md create mode 100644 tests/midi_test.cpp diff --git a/DECISIONS.md b/DECISIONS.md index 17663b9..31b2939 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -119,3 +119,4 @@ What was decided, why, and when to look again. One row per decision. IDs are seq | D-113 | A port being followed is never driven; every other out port is. So a follower still clocks everything downstream of it, §11's "always on while playing (configurable)" stays true as written, and `midi clock out` and `sync out` keep meaning what the rows say. | 2026-09-03, user ruling. A follower that went silent would override two settings rows with nothing on screen to explain it, and would stop Rota sitting in the middle of a chain — which is the §5 secondary user's whole reason for the port. The pulses cost nothing extra because the follower's own grid already generates them. The rule has to be per port rather than "a follower re-transmits": with D-111's two cables between two Rotas, re-transmitting onto the wire you are following makes a feedback ring, each device chasing the other's echo. Not driving the followed port kills that outright and still leaves PO on sync in → Rota → a second Rota on MIDI out working. It is also the rule the sync jack already needed, since in and out share one conductor there and the device cannot drive it while reading it. Rejected: silence while following with §11 amended and the row reading `out (following)` (honest, and it gives up the chain). | If a bring-up bench finds a real cabling in which a driven out port feeds back despite the rule. | | D-114 | The HAL carries the two wires as bytes and one stamped pulse stream, not as messages (§7.6, §11): `read_clock_in` hands over every MIDI clock, Start, Continue and Stop byte and every sync edge with the microsecond the platform saw it, each port with its own ring; `send_clock_out` is given a deadline rather than a byte and the platform's one-shot sends it; `midi_read` and `midi_send` are the rest of the wire, with `midi_send` taking one byte at a time and refusing until it has left; `midi_port_open` says whether this build has a port at all. The audio side publishes an `AudioAnchor` — the block's first frame beside the microsecond it was rendered — once a block, which is how app/ converts either way. | 2026-09-03. A jam message is 246 bytes, 79 ms of wire, and a clock pulse has to leave in the middle of it; MIDI allows exactly that, because a System Real Time byte may sit between any two bytes of any other message, so a message-shaped surface could express it only by holding the wire for a fifth of a second or by adding a second lane beside it. The stamp has to be taken where the byte or the edge arrives: the main loop's gap is milliseconds and §11 allows three, so a stamp taken at the read would spend the whole budget before the follower had done anything — the same argument that put a time on `InputEvent` (D-088). A deadline rather than a byte for the same reason: the beat is decided in the 2 ms timer, and 2 ms of jitter is most of the budget. One byte at a time on send, because the caller cannot jump a TX FIFO it does not own: with io/ offering a payload byte every other byte time the wire runs half empty and a clock byte waits at most 320 µs, where six bytes a tick — the first arithmetic tried — would have kept the UART 96 % busy and made it wait 1.9 ms. The real-time bytes are lifted in `hal/` and not in `io/`, which is where §12 puts MIDI: only the code holding the UART can stamp a byte where it lands, and it is one comparison (`byte >= 0xF8`) and a demultiplex rather than validation — io/ still validates every SysEx payload. Each port gets its own ring so a shorted sync jack cannot crowd out the MIDI clock it is meant to lose to, and a full ring drops the newest pulse, since a ring that fills means nothing is draining it and a gap is then honest where a stale stamp is not. Rejected: whole messages with an all-or-nothing write (cannot interleave a pulse); reading the wire inside the 2 ms timer to dodge the main loop's gap (puts io/ work under `lock()`, which on the Teensy is `noInterrupts()`); raising the sync line from the audio callback (±1.3 ms and forbidden work in that callback, §12 rule 4). | If USB MIDI lands (§7.6), which arrives as a second pair of ports rather than as a change to this surface. | | D-115 | The beat goes out from the scheduler's own grid, not from a timer that notices it: `app::Clock` is asked how long every beat is — the line that used to sit inside `Scheduler::begin_beat` — and it arms every pulse of that beat due before the same horizon the hits go out on, each with the microsecond its frame reaches the output. A pulse a port refuses is offered again on the next tick and abandoned only at the beat boundary; the §9.4 rows gate the arming and never the counting. | 2026-09-03. One object owns both directions because the in and out grids are the same grid — a sync pulse is exactly every twelfth MIDI tick — so two would compute it twice and could disagree. Arming a lookahead early rather than sending when the 2 ms timer notices keeps the timer's period out of the wire's timing, which matters because 2 ms is most of the 3 ms §11 allows between two linked devices. The deadline is the microsecond the frame is *heard*, one platform output buffer after the block that rendered it, since a pulse that coincided with the render would run ahead of our own sound by the buffer — 2.7 ms on the device and 10.7 ms on the host, which would spend the whole budget on the reference platform. A refused pulse is retried rather than dropped because the ports carry a tempo and no downbeat, so a lost pulse is a phase error nothing afterwards can correct; it is abandoned at the beat boundary rather than queued, because 24 pulses arriving together would jump a listener's sequencer forward, and the next beat's first pulse lands on the beat either way. Counting while a row is off is what lets a row switched on mid-play land in phase instead of bursting. Rejected: raising the line from the audio callback (±1.3 ms, and forbidden work in that callback, §12 rule 4); sending Start and its tick at one deadline (a byte is 320 µs of wire, so Start goes one byte early and the tick keeps the beat). | When the follower lands: `begin_beat` then answers from a port as well as from the section, and this row is where the arithmetic it replaces is written down. | +| D-116 | The jam link is MIDI System Exclusive wrapping an unchanged `RT2` share code (§11, spec/jam-link.md): `F0 7D 'R' 'T' 01 F7`, where 0x7D is MIDI's non-commercial id (C-06), `RT` marks the message ours, 01 is the envelope's own version, `` is `L` for a whole loop or `T` for one track, and `` is 0 for a loop or 0–7 for a track. A track carries the whole loop and the receiver takes only the named pad. The parser skips any byte with the top bit set that is not `F0`/`F7`, re-syncs on `F0`, and refuses a foreign id, a missing tag, an unknown version, an out-of-range pad, a non-code payload or one past D-106's cap. | 2026-09-03. §11 says patterns are exchanged as SysEx containing the share code, and the code is already the loop's canonical spelling, so wrapping it beats inventing a binary pattern format: one encoder path, one decoder path, and no new grammar to version — the RT2 landmine does not fire, because the code inside is unchanged and the envelope carries its own version byte for its own future. Every code byte is 0x2C–0x7E, so the top bit distinguishes payload from structure and nothing is escaped; the four real-time bytes the HAL lifts (D-114) are skipped here too, so a corrupt or interspersed status byte cannot derail a message. A track sends the whole code rather than a one-track grammar, because there is no share code for a single track and adding one would be an RT3; the cost is about 150 wasted bytes on an occasional gesture. The pad byte is the one field an untrusted wire controls and it becomes an array index, so it is range-checked like every card string that reaches a path (D-109). Rejected: a bespoke binary pattern message (a second format to keep in step with the engine); escaping the payload (unnecessary, since it is 7-bit by construction); a one-track share grammar (an RT3 for a saving of a few bytes). | If USB MIDI adds a second transport, or if a real manufacturer id replaces 0x7D — both change bytes 1–2, not the shape, and the version byte is how a receiver tells the generations apart. | diff --git a/firmware/src/io/midi.cpp b/firmware/src/io/midi.cpp new file mode 100644 index 0000000..630db2c --- /dev/null +++ b/firmware/src/io/midi.cpp @@ -0,0 +1,81 @@ +#include "io/midi.h" + +#include "io/share.h" + +namespace io { + +namespace { + +int format(uint8_t type, int pad, const engine::State& state, const engine::Kit& kit, uint8_t* out) { + const engine::SectionCode code = shared_code(state, kit); + int i = 0; + out[i++] = kSysExStart; + out[i++] = kManufacturer; + out[i++] = kTagR; + out[i++] = kTagT; + out[i++] = kVersion; + out[i++] = type; + out[i++] = static_cast(pad); + for (const char* c = code.text; *c != '\0'; ++c) out[i++] = static_cast(*c); + out[i++] = kSysExEnd; + return i; +} + +} // namespace + +int format_loop(const engine::State& state, const engine::Kit& kit, uint8_t* out) { + return format(kTypeLoop, 0, state, kit, out); +} + +int format_track(const engine::State& state, const engine::Kit& kit, int pad, uint8_t* out) { + return format(kTypeTrack, pad, state, kit, out); +} + +void MidiPort::reset() { + in_message_ = false; + overflow_ = false; + length_ = 0; +} + +bool MidiPort::feed(uint8_t byte, const engine::Kit& kit, Received& out) { + if (byte == kSysExStart) { // a new message begins, and discards any half-read one + in_message_ = true; + overflow_ = false; + length_ = 0; + return false; + } + if (!in_message_) return false; + + if (byte != kSysExEnd) { + if (byte >= 0x80) return false; // a status byte in the middle is not payload; skip it, stay in the message + if (length_ < static_cast(sizeof buffer_) - 1) { + buffer_[length_++] = byte; // room kept for the NUL the decoder reads + } else { + overflow_ = true; // a code longer than the engine takes; keep counting to F7, then drop + } + return false; + } + + // F7: the message is whole. Whatever the outcome, the next byte starts fresh. + in_message_ = false; + if (overflow_ || length_ < kHeaderBytes) return false; + if (buffer_[0] != kManufacturer || buffer_[1] != kTagR || buffer_[2] != kTagT || buffer_[3] != kVersion) return false; + const uint8_t type = buffer_[4]; + const int pad = buffer_[5]; + if (type == kTypeLoop) { + if (pad != 0) return false; + } else if (type == kTypeTrack) { + if (pad >= engine::kTrackCount) return false; // a data byte reaches here 0–127; a track is 0–7 + } else { + return false; + } + buffer_[length_] = '\0'; + const engine::Decoded decoded = engine::decode(reinterpret_cast(buffer_ + kHeaderBytes), kit); + if (!decoded.ok) return false; + out.track = type == kTypeTrack; + out.pad = pad; + out.decoded = decoded; + return true; +} + +} // namespace io diff --git a/firmware/src/io/midi.h b/firmware/src/io/midi.h new file mode 100644 index 0000000..d7f6945 --- /dev/null +++ b/firmware/src/io/midi.h @@ -0,0 +1,84 @@ +#pragma once + +#include + +#include "engine/kit.h" +#include "engine/share.h" +#include "engine/state.h" + +// The jam link on the wire (PRD §11, D-111, D-114). Two devices trade patterns as +// MIDI System Exclusive, and the payload is an ordinary RT2 share code — so there is +// no second grammar to keep and no RT3 to version. This file is the envelope around +// that code and nothing else: no clock, no transport, no app state. The clock bytes +// that share the wire never reach here; the HAL lifts them (D-114). +// +// The envelope is +// F0 7D 'R' 'T' 01 F7 +// 0x7D is the SysEx id MIDI reserves for non-commercial and educational use (C-06); +// 'R' 'T' mark the message as ours, so another maker's 0x7D message is dropped rather +// than parsed; 01 is the protocol version, so a later firmware can change its mind by +// refusing an older one. is 'L' for a whole loop or 'T' for one track, and +// is the pad a track was sent from (0 for a loop). The code carries the loop's +// own id, as the share view's does (§10.2, D-105); the receiver decides whether to +// keep it as lineage (a loop does, a track does not — that is app/'s call, §11). +// +// Every code byte is one of `A–Z a–z 0–9 : . , - ~ ; /` (§10), so its top bit is +// clear and none can be mistaken for a status byte: nothing is escaped, and the only +// bytes with the top bit set are the envelope's own F0 and F7. A byte the wire drops +// in the middle re-syncs on the next F0. +namespace io { + +constexpr uint8_t kSysExStart = 0xF0; +constexpr uint8_t kSysExEnd = 0xF7; +constexpr uint8_t kManufacturer = 0x7D; +constexpr uint8_t kTagR = 'R'; +constexpr uint8_t kTagT = 'T'; +constexpr uint8_t kVersion = 1; +constexpr uint8_t kTypeLoop = 'L'; +constexpr uint8_t kTypeTrack = 'T'; +constexpr int kHeaderBytes = 6; // manufacturer, R, T, version, type, pad + +// The most an outgoing message is: the envelope around a section code, which is at +// most kSectionCodeCapacity including the NUL the code carries and this does not. +constexpr int kMessageCapacity = kHeaderBytes + engine::kSectionCodeCapacity + 1; // +1 for F7 + +// What arrived, once a whole well-formed message has. `track` false is a loop, whose +// pad is 0; true is one track, on `pad` 0–7. `decoded` is the payload, always ok. +struct Received { + bool track; + int pad; + engine::Decoded decoded; +}; + +// Writes the SysEx for the whole loop, or for one track, into `out` (which must hold +// kMessageCapacity), and returns the byte count. The payload is `state` as a share +// code with its own id (io::shared_code); a track message differs only in the two +// header bytes, so both spell the whole loop and the receiver takes what it asked for. +int format_loop(const engine::State& state, const engine::Kit& kit, uint8_t* out); +int format_track(const engine::State& state, const engine::Kit& kit, int pad, uint8_t* out); + +// The receiver: a byte at a time off hal::midi_read, so a message can arrive across +// as many reads as the wire's pace spreads it over. Feed each byte; the call returns +// true when `byte` completed a well-formed message, whose contents are then in `out`. +// A message with a foreign id, a missing tag, the wrong version, an out-of-range pad, +// a payload that is not a code, or one longer than the engine accepts (D-106) returns +// false and changes nothing; the parser re-syncs on the next F0 either way. +class MidiPort { + public: + MidiPort() { reset(); } + void reset(); + bool feed(uint8_t byte, const engine::Kit& kit, Received& out); + + private: + // The engine caps a section code at kMaxSectionCodeInput (D-106); one past that, + // plus the NUL the decoder reads, is all this holds, so the engine is the one place + // that refuses an oversize code and the read here stays bounded. + static constexpr int kPayloadCapacity = engine::kMaxSectionCodeInput + 2; + + bool in_message_; + bool overflow_; + int length_; // bytes gathered after F0, header included + uint8_t buffer_[kHeaderBytes + kPayloadCapacity]; +}; + +} // namespace io diff --git a/spec/jam-link.md b/spec/jam-link.md new file mode 100644 index 0000000..dae1b47 --- /dev/null +++ b/spec/jam-link.md @@ -0,0 +1,65 @@ +# Jam link — the wire format + +Normative form of the jam link's SysEx (PRD §11, D-111, D-114), the counterpart to +`share-format.md` for the cable rather than the QR. Two devices trade patterns as MIDI +System Exclusive, and the payload is an ordinary `RT2` share code (`share-format.md`), +so there is no second grammar to keep and no `RT3` to version. `io/midi.cpp` is the +envelope around that code; `firmware/src/io/midi.h` is the source of truth and this +file explains it. Golden messages are byte-identical across host and firmware builds, +and `tests/midi_test.cpp` asserts them (T-111). + +## The envelope + +``` +F0 7D 'R' 'T' 01 F7 +``` + +| Byte | Value | Why | +|---|---|---| +| `F0` | SysEx start | The parser starts a message here, and re-syncs here after any refusal. | +| `7D` | Manufacturer id | The id MIDI 1.0 reserves for non-commercial and educational use, and forbids on a shipped product (C-06). A real id is budgeted with the certification work (§7.7). | +| `52 54` | `'R' 'T'` | Marks the message as Rota's, so another maker's `7D` message is dropped rather than parsed. | +| `01` | Protocol version | A version this firmware does not know is refused, which is how a later firmware changes the format by refusing an older one. | +| `4C` / `54` | `'L'` / `'T'` | A whole loop, or one track. | +| `00`–`07` | Pad | The pad a track was sent from. A loop must name pad `00`; a track names `00`–`07`. Any other value is refused — this is the one field an untrusted wire controls, and it becomes an array index, so it is checked like every card string that reaches a path (D-109). | +| `RT2 code …` | The share code | A section code with the loop's own id after `~` (§10.2, D-105), so a receiver that keeps a whole loop can say what it is based on. A track message carries the same whole code and the receiver takes only the named pad; the seven others cost about 150 bytes on a gesture made a few times a minute, which buys one encoder path and one decoder path (D-111). | +| `F7` | SysEx end | A code past the engine's `kMaxSectionCodeInput` cap (D-106) is dropped while the parser counts on to here, so the read stays bounded and the stream re-syncs. | + +## Why nothing is escaped + +Every byte of an `RT2` code is one of `A–Z a–z 0–9 : . , - ~ ; /` (§10), all in `2C`–`7E`. +So every payload byte has its top bit clear: none can be mistaken for a status byte, +none is `F7`, and none is `00`. The only bytes in a message with the top bit set are the +envelope's own `F0` and `F7`. A byte the wire corrupts into the `80`–`FF` range is not a +payload byte, so the parser skips it and the message survives; a stray `F0` restarts the +message and a stray `F7` ends it. MIDI's four System Real Time bytes (`F8`, `FA`, `FB`, +`FC`) may fall anywhere in a stream, including inside a SysEx; the HAL lifts them before +`io/midi` sees the wire (D-114), and the parser skips them regardless. + +## Golden messages + +Bytes in hex, `payload` shown as text. The id after `~` is FNV-1a over the bare code +(D-105), so it is fixed for a given loop. + +### G-JAM-01 — an empty loop (64 bytes) + +payload `RT2:lofi:100:10:2:0:15:cm:e1-e1-e1-e1-e1-e1-e1-e1~av0s9e` + +``` +F0 7D 52 54 01 4C 00 52 54 32 3A 6C 6F 66 69 3A 31 30 30 3A 31 30 3A 32 3A 30 3A +31 35 3A 63 6D 3A 65 31 2D 65 31 2D 65 31 2D 65 31 2D 65 31 2D 65 31 2D 65 31 2D +65 31 7E 61 76 30 73 39 65 F7 +``` + +### G-JAM-02 — the classic beat, T-05 (76 bytes) + +payload `RT2:lofi:100:10:2:0:15:cm:e10000-e1.0.0-e10000-e1-e1-e1-e1-e1~46uwma` + +``` +F0 7D 52 54 01 4C 00 52 54 32 3A 6C 6F 66 69 3A 31 30 30 3A 31 30 3A 32 3A 30 3A +31 35 3A 63 6D 3A 65 31 30 30 30 30 2D 65 31 2E 30 2E 30 2D 65 31 30 30 30 30 2D +65 31 2D 65 31 2D 65 31 2D 65 31 2D 65 31 7E 34 36 75 77 6D 61 F7 +``` + +The same loop sent as **one track** from pad 3 is identical but for two header bytes: +`… 01 54 03 …` in place of `… 01 4C 00 …`. diff --git a/spec/scenarios.md b/spec/scenarios.md index bcaefe7..21dc2d4 100644 --- a/spec/scenarios.md +++ b/spec/scenarios.md @@ -1,6 +1,6 @@ # Acceptance scenarios -Every behaviour in the PRD has one row here, and every engine test names the ID it covers (PRD §12 rule 2). T-01–T-24 are PRD §13 unchanged. T-25 onward were added on 2026-09-02 for §6 behaviours that had no scenario, T-85 onward on 2026-09-03 for the §9 views and the lights, T-95 and T-96 the same day for the last two §8.2 gestures, T-97–T-99 for what the card keeps and T-100 and T-101 for the kit, and T-102 onward the same day for §7.6 and §11's clock, sync jack and jam link; the PRD section each comes from is in brackets. +Every behaviour in the PRD has one row here, and every engine test names the ID it covers (PRD §12 rule 2). T-01–T-24 are PRD §13 unchanged. T-25 onward were added on 2026-09-02 for §6 behaviours that had no scenario, T-85 onward on 2026-09-03 for the §9 views and the lights, T-95 and T-96 the same day for the last two §8.2 gestures, T-97–T-99 for what the card keeps and T-100 and T-101 for the kit, and T-102 onward the same day for §7.6 and §11's clock, sync jack and jam link (T-111 and T-112 the jam link's SysEx); the PRD section each comes from is in brackets. Conventions: fractions are of one cycle; the default kit (lofi), C minor and 100 bpm are assumed unless stated; "tap ×n" means n taps on that pad starting from empty, following the kit's smart defaults (§6.6); per-track modifier strings are the share-code form from `spec/share-format.md` §3. IDs are never reused: retire a scenario by striking it through, not by deleting it. @@ -111,6 +111,9 @@ Conventions: fractions are of one cycle; the default kit (lofi), C minor and 100 | T-102 | Kick ×4 at 100 bpm with `midi clock out` and `sync out` on; play, two cycles, stop; then each row off, and one switched back on mid-play (§7.6, §11, §9.4, D-113, D-114) | 24 pulses a beat on the MIDI port and 2 on the sync jack — 96 and 8 to the cycle, which is what lets a follower find the cycle and not just the beat (D-112). Each pulse is armed for the microsecond its frame reaches the output, one platform buffer after the block that rendered it, so a pulse coincides with the sound it belongs to and not with the render; across two cycles nothing accumulates, because every pulse's frame is derived from the beat it is in. Start goes one byte time before the first tick, since two bytes cannot leave a 31250 baud wire at once and it is the tick that must land on the beat; the sync jack carries no transport, so it gets no Start. Stop leaves at once when play stops, as the handed-over hits stop sounding at once (T-82), and Continue is never sent, because a Rota stop always rewinds. A row switched off silences its own port and no other, and takes its Start with it; the pulse count keeps moving while a row is off, so a row switched back on mid-play lands on the grid the port would have been on all along instead of firing a burst to catch up. | | T-103 | Clock out at 60 bpm and at 180 bpm; then a timer pass that overslept half a beat; then a port that refuses every pulse for a beat (§6.3, §7.6, §11, D-114) | Sync pulses 500 ms apart at 60 bpm and 167 ms at 180, both inside the 12.5 ms to 1.5 s a Pocket Operator's input accepts, and MIDI pulses 41.7 ms and 13.9 ms apart. The pulses an overslept timer missed go out with the deadlines they were due — late rather than lost, because 24 PPQN carries a tempo and no downbeat, so a dropped pulse is a phase error nothing afterwards can correct while a late one costs at most one timer period. A port that refuses takes nothing at all, and what it refused is offered again and lands back on the same grid one pulse at a time: however long the wire was busy, the backlog never leaves as a burst. | +| T-111 | Format a loop and a track for the empty loop, the classic beat (T-05) and the worst case (G-14), then read each back; and check the empty loop against spec/jam-link.md G-JAM-01 (§10, §11, D-105, D-111, D-114) | Each opens `F0 7D R T 01`, then `L` with pad 0 or `T` with the pad it was sent from, then the section's own share code with its own six-character id, then `F7`; every payload byte is under 0x80, so none is a status byte and nothing is escaped. The empty loop is byte-identical to G-JAM-01. Read back, a loop arrives as a loop and a track as a track on its pad, both decoding to the same loop the sender held; the id the code carries is the loop's own, so a receiver can keep it as lineage. | +| T-112 | Feed the receiver a foreign manufacturer id, our id without the `RT` tag, a version of 2, a payload that is not a code, an `RT2S` song code, a payload past kMaxSectionCodeInput, a loop that names a pad, a track whose pad is 8, and a message cut off before `F7`; then a good message with a clock byte scattered through it (§10, §11, D-106, D-111, D-114) | Every malformed message is refused: nothing decodes, and a good message straight after still arrives, because the parser re-syncs on the next `F0`. An oversize payload is dropped while the parser counts on to `F7`, so the read stays bounded and D-106 is the one cap. A status byte in the middle of a good message is skipped and the message still arrives whole, since payload bytes are all under 0x80. | + ## Watch in testing Design bets with a known fallback. Observe them in usability round 1 (PRD §14, phase 2); the fallback is written down so nobody re-derives it. diff --git a/tests/midi_test.cpp b/tests/midi_test.cpp new file mode 100644 index 0000000..5e08a23 --- /dev/null +++ b/tests/midi_test.cpp @@ -0,0 +1,181 @@ +// The jam link's SysEx (PRD §11, D-111, D-114): spec/scenarios.md T-111, T-112. +// io::MidiPort is a pure parser and formatter — no app state, no clock — so these +// drive it directly with byte arrays. +#include +#include + +#include "engine_support.h" +#include "io/midi.h" +#include "io/share.h" + +using namespace support; + +namespace { + +std::vector loop_bytes(const engine::State& state) { + uint8_t out[io::kMessageCapacity]; + const int n = io::format_loop(state, lofi(), out); + return std::vector(out, out + n); +} + +std::vector track_bytes(const engine::State& state, int pad) { + uint8_t out[io::kMessageCapacity]; + const int n = io::format_track(state, lofi(), pad, out); + return std::vector(out, out + n); +} + +// Feeds every byte; returns true if one of them completed a well-formed message, and +// leaves that message in `out`. A parser that refuses never returns true. +bool feed_all(io::MidiPort& port, const std::vector& bytes, io::Received& out) { + bool got = false; + for (const uint8_t b : bytes) { + if (port.feed(b, lofi(), out)) got = true; + } + return got; +} + +engine::State beat(const char* code) { return engine::decode(code, lofi()).state; } + +// Two states carry the same loop when their share codes with their own ids match: +// the id is a hash of the bare code, so equal patterns give equal codes (D-105). +std::string same_loop(const engine::State& state) { return io::shared_code(state, lofi()).text; } + +const char* kEmpty = "RT2:lofi:100:10:2:0:15:cm:e1-e1-e1-e1-e1-e1-e1-e1"; +const char* kClassic = "RT2:lofi:100:10:2:0:15:cm:e10000-e1.0.0-e10000-e1-e1-e1-e1-e1"; +const char* kWorst = + "RT2:lofi:180:10:10:10:100:csdor:fhoooooooooooooooo,7959-fhoooooooooooooooo,7959-fhoooooooooooooooo,7959-" + "fhoooooooooooooooo,7959-fhvvvvvvvvvvvvvvvv,7959-fhvvvvvvvvvvvvvvvv,7959-fhvvvvvvvvvvvvvvvv,7959-" + "fhoooooooooooooooo,7959~zzzzzz"; + +} // namespace + +TEST_CASE("T-111 A loop and a track go out as SysEx and read back as the same loop") { + io::MidiPort port; + io::Received got; + + SUBCASE("the envelope is F0 7D R T 01, the type and pad, the code, F7") { + const std::vector bytes = loop_bytes(beat(kClassic)); + REQUIRE(bytes.size() >= 8); + CHECK(bytes[0] == io::kSysExStart); + CHECK(bytes[1] == io::kManufacturer); + CHECK(bytes[2] == io::kTagR); + CHECK(bytes[3] == io::kTagT); + CHECK(bytes[4] == io::kVersion); + CHECK(bytes[5] == io::kTypeLoop); + CHECK(bytes[6] == 0); // a loop names pad 0 + CHECK(bytes.back() == io::kSysExEnd); + // Every payload byte has its top bit clear, so none can be mistaken for a status + // byte and nothing is escaped (§10, D-114). + for (size_t i = io::kHeaderBytes + 1; i + 1 < bytes.size(); ++i) CHECK(bytes[i] < 0x80); + } + + SUBCASE("a loop round-trips: empty, the classic beat, and the worst case G-14") { + for (const char* code : {kEmpty, kClassic, kWorst}) { + port.reset(); + const engine::State state = beat(code); + REQUIRE(feed_all(port, loop_bytes(state), got)); + CHECK_FALSE(got.track); + CHECK(got.pad == 0); + REQUIRE(got.decoded.ok); + CHECK(same_loop(got.decoded.state) == same_loop(state)); + } + } + + SUBCASE("G-JAM-01 the empty loop is byte-identical to spec/jam-link.md") { + const uint8_t golden[] = { + 0xF0, 0x7D, 0x52, 0x54, 0x01, 0x4C, 0x00, 0x52, 0x54, 0x32, 0x3A, 0x6C, 0x6F, 0x66, 0x69, 0x3A, + 0x31, 0x30, 0x30, 0x3A, 0x31, 0x30, 0x3A, 0x32, 0x3A, 0x30, 0x3A, 0x31, 0x35, 0x3A, 0x63, 0x6D, + 0x3A, 0x65, 0x31, 0x2D, 0x65, 0x31, 0x2D, 0x65, 0x31, 0x2D, 0x65, 0x31, 0x2D, 0x65, 0x31, 0x2D, + 0x65, 0x31, 0x2D, 0x65, 0x31, 0x2D, 0x65, 0x31, 0x7E, 0x61, 0x76, 0x30, 0x73, 0x39, 0x65, 0xF7}; + const std::vector bytes = loop_bytes(beat(kEmpty)); + REQUIRE(bytes.size() == sizeof golden); + for (size_t i = 0; i < bytes.size(); ++i) CHECK(bytes[i] == golden[i]); + } + + SUBCASE("a track carries the pad it was sent from and the whole loop behind it") { + const engine::State state = beat(kClassic); + const std::vector bytes = track_bytes(state, 4); + CHECK(bytes[5] == io::kTypeTrack); + CHECK(bytes[6] == 4); + REQUIRE(feed_all(port, bytes, got)); + CHECK(got.track); + CHECK(got.pad == 4); + CHECK(same_loop(got.decoded.state) == same_loop(state)); + } + + SUBCASE("the id the code carries is the loop's own, so a receiver can keep it as lineage") { + const engine::State state = beat(kClassic); + REQUIRE(feed_all(port, loop_bytes(state), got)); + // shared_code appends the bare code's own id after `~`; decode reads it as lineage. + CHECK(got.decoded.state.lineage[0] != '\0'); + CHECK(std::string(got.decoded.state.lineage) == std::string(io::shared_code(state, lofi()).text).substr( + std::string(io::shared_code(state, lofi()).text).find('~') + 1)); + } +} + +TEST_CASE("T-112 A malformed message changes nothing and the parser re-syncs on the next F0") { + io::MidiPort port; + io::Received got; + const engine::State state = beat(kClassic); + + auto refused = [&](std::vector bytes) { + port.reset(); + const bool got_one = feed_all(port, bytes, got); + CHECK_FALSE(got_one); + // Whatever was refused, a good message straight after still arrives. + CHECK(feed_all(port, loop_bytes(state), got)); + }; + + SUBCASE("a foreign manufacturer id") { + std::vector b = loop_bytes(state); + b[1] = 0x43; // not 0x7D + refused(b); + } + SUBCASE("our id but not our tag") { + std::vector b = loop_bytes(state); + b[2] = 'X'; // the 'R' is gone + refused(b); + } + SUBCASE("a protocol version this firmware does not know") { + std::vector b = loop_bytes(state); + b[4] = 2; + refused(b); + } + SUBCASE("a payload that is not a code") { + std::vector b = {io::kSysExStart, io::kManufacturer, io::kTagR, io::kTagT, + io::kVersion, io::kTypeLoop, 0, 'n', + 'o', 'p', 'e', io::kSysExEnd}; + refused(b); + } + SUBCASE("a song code where a section code belongs") { + std::vector b = {io::kSysExStart, io::kManufacturer, io::kTagR, io::kTagT, io::kVersion, io::kTypeLoop, 0}; + for (const char* c = "RT2S:lofi"; *c; ++c) b.push_back(static_cast(*c)); + b.push_back(io::kSysExEnd); + refused(b); + } + SUBCASE("a payload longer than the engine accepts (D-106)") { + std::vector b = {io::kSysExStart, io::kManufacturer, io::kTagR, io::kTagT, io::kVersion, io::kTypeLoop, 0}; + for (int i = 0; i < 600; ++i) b.push_back('e'); // past kMaxSectionCodeInput + b.push_back(io::kSysExEnd); + refused(b); + } + SUBCASE("a loop that names a pad, or a track whose pad is not a track") { + std::vector loop = loop_bytes(state); + loop[6] = 5; // a loop must be pad 0 + refused(loop); + std::vector track = track_bytes(state, 0); + track[6] = 8; // there is no track 8 + refused(track); + } + SUBCASE("a message cut short, then a whole one") { + std::vector truncated = loop_bytes(state); + truncated.pop_back(); // no F7 + refused(truncated); // the next F0 in loop_bytes discards the truncated one + } + SUBCASE("a status byte scattered through a good message does not break it") { + std::vector b = loop_bytes(state); + b.insert(b.begin() + io::kHeaderBytes + 3, 0xF8); // a clock byte the HAL would normally have lifted + REQUIRE(feed_all(port, b, got)); + CHECK(same_loop(got.decoded.state) == same_loop(state)); + } +} From 7f2c49fc8f724d0e476007d1916d5d3b189c28c2 Mon Sep 17 00:00:00 2001 From: Deva Date: Thu, 3 Sep 2026 20:46:15 +0530 Subject: [PATCH 06/12] Follow somebody else's clock by changing the beat's length and nothing else MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app::Clock gains the in direction (D-117..D-122). follow() drains the pulses read_clock_in handed the main loop, measures each port's tempo from time_us deltas alone — a round-to-nearest EMA with a missing-tick detector — and picks a source: MIDI whenever MIDI is alive, sync only when it is quiet. The anchor is latched here (it runs every pass, so a clock arriving at a stopped device is seen) and used only to place phase in frame-space, never the tempo, so the host's bursty audio callback cannot jitter what is followed. begin_beat keeps its length-only signature: while following it returns the measured length pulled toward the leader by at most a beat/128 and clamped to [16000,48000] frames — the clamp is what stops a garbage-fast wire spinning push_window with interrupts off. The one large phase move is acquisition, done once: play while following opens a count-in (Scheduler::waiting_for_clock_) that waits for the leader's cycle downbeat via Clock::cycle_boundary and places the first beat there, so two devices lock cycles and not just beats (D-112); cycle lock is MIDI-only, since sync carries no Start (D-120). If the leader vanishes mid-count-in the loop falls back to free-run. Loss is two separate things (D-119): a stale anchor (the audio path stalled) freezes the beat and says nothing; a quiet wire or a MIDI Stop ends the follow and adopts the last measured tempo into the sections through the tap-tempo path (D-122), so the screen agrees with the sound and nothing is heard at the unplug (T-20). The wire never writes engine::State::bpm, so the share code, the undo stack and the card are untouched by a cable. The speed knob shows `ext` while following and the ring's corner shows the followed tempo; a followed port is never driven, so no feedback ring forms (D-113). Every frame/microsecond conversion subtracts as int64, since a pulse is almost always stamped before the newest anchor. T-104..T-109 drive it all through the fake against a leader the test clocks itself; T-19's two-device figure stays a bench measurement. Co-Authored-By: Claude Opus 4.8 --- DECISIONS.md | 6 + firmware/src/app/app.cpp | 24 +++- firmware/src/app/app.h | 3 + firmware/src/app/clock.cpp | 237 +++++++++++++++++++++++++++++--- firmware/src/app/clock.h | 158 ++++++++++++++++++--- firmware/src/app/controller.cpp | 17 +++ firmware/src/app/controller.h | 4 + firmware/src/app/scheduler.cpp | 22 +++ firmware/src/app/scheduler.h | 7 + firmware/src/ui/ring.cpp | 6 +- firmware/src/ui/ring.h | 1 + spec/scenarios.md | 9 +- tests/follow_test.cpp | 220 +++++++++++++++++++++++++++++ 13 files changed, 669 insertions(+), 45 deletions(-) create mode 100644 tests/follow_test.cpp diff --git a/DECISIONS.md b/DECISIONS.md index 31b2939..2bda31e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -120,3 +120,9 @@ What was decided, why, and when to look again. One row per decision. IDs are seq | D-114 | The HAL carries the two wires as bytes and one stamped pulse stream, not as messages (§7.6, §11): `read_clock_in` hands over every MIDI clock, Start, Continue and Stop byte and every sync edge with the microsecond the platform saw it, each port with its own ring; `send_clock_out` is given a deadline rather than a byte and the platform's one-shot sends it; `midi_read` and `midi_send` are the rest of the wire, with `midi_send` taking one byte at a time and refusing until it has left; `midi_port_open` says whether this build has a port at all. The audio side publishes an `AudioAnchor` — the block's first frame beside the microsecond it was rendered — once a block, which is how app/ converts either way. | 2026-09-03. A jam message is 246 bytes, 79 ms of wire, and a clock pulse has to leave in the middle of it; MIDI allows exactly that, because a System Real Time byte may sit between any two bytes of any other message, so a message-shaped surface could express it only by holding the wire for a fifth of a second or by adding a second lane beside it. The stamp has to be taken where the byte or the edge arrives: the main loop's gap is milliseconds and §11 allows three, so a stamp taken at the read would spend the whole budget before the follower had done anything — the same argument that put a time on `InputEvent` (D-088). A deadline rather than a byte for the same reason: the beat is decided in the 2 ms timer, and 2 ms of jitter is most of the budget. One byte at a time on send, because the caller cannot jump a TX FIFO it does not own: with io/ offering a payload byte every other byte time the wire runs half empty and a clock byte waits at most 320 µs, where six bytes a tick — the first arithmetic tried — would have kept the UART 96 % busy and made it wait 1.9 ms. The real-time bytes are lifted in `hal/` and not in `io/`, which is where §12 puts MIDI: only the code holding the UART can stamp a byte where it lands, and it is one comparison (`byte >= 0xF8`) and a demultiplex rather than validation — io/ still validates every SysEx payload. Each port gets its own ring so a shorted sync jack cannot crowd out the MIDI clock it is meant to lose to, and a full ring drops the newest pulse, since a ring that fills means nothing is draining it and a gap is then honest where a stale stamp is not. Rejected: whole messages with an all-or-nothing write (cannot interleave a pulse); reading the wire inside the 2 ms timer to dodge the main loop's gap (puts io/ work under `lock()`, which on the Teensy is `noInterrupts()`); raising the sync line from the audio callback (±1.3 ms and forbidden work in that callback, §12 rule 4). | If USB MIDI lands (§7.6), which arrives as a second pair of ports rather than as a change to this surface. | | D-115 | The beat goes out from the scheduler's own grid, not from a timer that notices it: `app::Clock` is asked how long every beat is — the line that used to sit inside `Scheduler::begin_beat` — and it arms every pulse of that beat due before the same horizon the hits go out on, each with the microsecond its frame reaches the output. A pulse a port refuses is offered again on the next tick and abandoned only at the beat boundary; the §9.4 rows gate the arming and never the counting. | 2026-09-03. One object owns both directions because the in and out grids are the same grid — a sync pulse is exactly every twelfth MIDI tick — so two would compute it twice and could disagree. Arming a lookahead early rather than sending when the 2 ms timer notices keeps the timer's period out of the wire's timing, which matters because 2 ms is most of the 3 ms §11 allows between two linked devices. The deadline is the microsecond the frame is *heard*, one platform output buffer after the block that rendered it, since a pulse that coincided with the render would run ahead of our own sound by the buffer — 2.7 ms on the device and 10.7 ms on the host, which would spend the whole budget on the reference platform. A refused pulse is retried rather than dropped because the ports carry a tempo and no downbeat, so a lost pulse is a phase error nothing afterwards can correct; it is abandoned at the beat boundary rather than queued, because 24 pulses arriving together would jump a listener's sequencer forward, and the next beat's first pulse lands on the beat either way. Counting while a row is off is what lets a row switched on mid-play land in phase instead of bursting. Rejected: raising the line from the audio callback (±1.3 ms, and forbidden work in that callback, §12 rule 4); sending Start and its tick at one deadline (a byte is 320 µs of wire, so Start goes one byte early and the tick keeps the beat). | When the follower lands: `begin_beat` then answers from a port as well as from the section, and this row is where the arithmetic it replaces is written down. | | D-116 | The jam link is MIDI System Exclusive wrapping an unchanged `RT2` share code (§11, spec/jam-link.md): `F0 7D 'R' 'T' 01 F7`, where 0x7D is MIDI's non-commercial id (C-06), `RT` marks the message ours, 01 is the envelope's own version, `` is `L` for a whole loop or `T` for one track, and `` is 0 for a loop or 0–7 for a track. A track carries the whole loop and the receiver takes only the named pad. The parser skips any byte with the top bit set that is not `F0`/`F7`, re-syncs on `F0`, and refuses a foreign id, a missing tag, an unknown version, an out-of-range pad, a non-code payload or one past D-106's cap. | 2026-09-03. §11 says patterns are exchanged as SysEx containing the share code, and the code is already the loop's canonical spelling, so wrapping it beats inventing a binary pattern format: one encoder path, one decoder path, and no new grammar to version — the RT2 landmine does not fire, because the code inside is unchanged and the envelope carries its own version byte for its own future. Every code byte is 0x2C–0x7E, so the top bit distinguishes payload from structure and nothing is escaped; the four real-time bytes the HAL lifts (D-114) are skipped here too, so a corrupt or interspersed status byte cannot derail a message. A track sends the whole code rather than a one-track grammar, because there is no share code for a single track and adding one would be an RT3; the cost is about 150 wasted bytes on an occasional gesture. The pad byte is the one field an untrusted wire controls and it becomes an array index, so it is range-checked like every card string that reaches a path (D-109). Rejected: a bespoke binary pattern message (a second format to keep in step with the engine); escaping the payload (unnecessary, since it is 7-bit by construction); a one-track share grammar (an RT3 for a saving of a few bytes). | If USB MIDI adds a second transport, or if a real manufacturer id replaces 0x7D — both change bytes 1–2, not the shape, and the version byte is how a receiver tells the generations apart. | +| D-117 | `Clock::begin_beat` stays length-only (its signature does not change); while following it returns the measured beat length pulled toward the leader's boundary by at most nominal/kPhasePullDenom (~0.8%) and clamped to [16000, 48000] frames, and the one large phase move — acquisition — is done once by the scheduler placing the first `beat_start_` on the leader's cycle downbeat through a `waiting_for_clock_` count-in that polls `Clock::cycle_boundary`. A `{start, length}` seam that re-bases `beat_start_` every beat is rejected. | 2026-09-03. The scheduler already lays beats end to end, so a phase-corrected length is all the seam needs: nudging a beat's length moves only the next boundary, never one already fixed. That preserves T-82 (the clamp keeps beat_end ahead of scheduled_until_, since the floor is 16000 > 0), T-83 (the identity S_N = S_{N-1} + L_{N-1} holds and fraction_of at a boundary is b/4 for any length, so the playhead never steps back at a boundary — within a cycle a per-beat length change can retroject the reported playhead by under 1e-4 of a cycle, ~0.02 px, which is bounded and invisible and is the only place strict monotonicity gives way to a bounded step), T-84 (roll_step = beat_frames/4 >= 4000 > 0) and T-95 (bpm is untouched). Re-basing beat_start_ breaks the S_N identity and the T-83 proof for a larger, riskier change; the half-beat-lurch fear only applies to acquiring phase by nudging a length, which nothing does, since acquisition is a free placement where nothing is scheduled yet. Within one bound of the range ends the phase pull becomes one-sided (the clamp eats the other direction), which real leaders inside 60–180 never reach. | If a bench finds the phase loop too slow to lock a jittery DAW. | +| D-118 | The followed tempo comes only from pulse `time_us` deltas — a per-port EMA (round-to-nearest, alpha 1/8 on MIDI, 1/4 on sync) with a missing-tick detector that reads an interval near 1.5× the running mean as two ticks and one past ~4.5× as a discontinuity that reacquires. The `AudioAnchor` is used only to place phase in frame-space (`frame_of`, the exact inverse of `deadline_of`); no slope is fitted from anchors. | 2026-09-03. Read latency is common-mode across consecutive ticks and cancels in the delta, so per-tick intervals are clean on both platforms, while the host's 512-frame audio burst jitters the anchor's intercept by up to 10.7 ms — routing rate through the anchor would import that jitter. The sample rate is the master clock and treated as exact (D-084), so frames-per-microsecond is a compile-time constant with no drift to fit, and a constant phase offset is invisible to the bounded per-beat phase loop; a second smoothing beside that loop would be two ways to do one thing. Round-to-nearest removes the arithmetic-shift bias that otherwise read a steady 120 as 119. Accepted limit: a tempo halved in one step looks like one two-tick interval and is interpolated, not reacquired, so the estimate lags a beat before it catches up — the loop stays audible and its playhead monotone through it. | If a real DAW's ramp or jitter needs a proportional term or a wider reacquire window. | +| D-119 | Two independent loss conditions, kept apart. A stale anchor — `now_us - anchor.time_us` over 50 ms, meaning the audio path stalled — freezes the beat at its last followed length, arms nothing and announces nothing. Only a quiet followed port (1.5 s) or a MIDI Stop ends the follow, adopts the last measured tempo and announces it. Re-following needs a fresh anchor and a whole beat of accepted ticks. | 2026-09-03. A stalled audio path and a quiet wire must not give two different audible outcomes; the audio stalling is not the leader's fault and nothing is sounding anyway, so freezing silently is right, while a real wire loss is the moment to make the screen agree with the sound. The whole-beat hysteresis stops one stray pulse after a loss from grabbing the phase. 50 ms sits well above one host audio burst and far below the 1.5 s wire timeout, so the two never collide. | If the 50 ms proves too tight for a loaded host or too loose to hide a real dropout. | +| D-120 | Cycle lock (counting MIDI pulses modulo 96 from the leader's Start) is MIDI-only. The sync jack carries no Start or transport (D-114), so following sync beat-locks to the pulse grid and acquires on the next sync beat, with no cross-cycle bar alignment; MIDI followed before any Start has been seen also degrades to beat-lock. | 2026-09-03. D-112's cycle reference is the MIDI Start byte and 96 pulses, which the sync wire simply does not carry, so promising bar alignment on sync would be a guess; beat-locking to what the wire actually carries is honest and gives a short acquire wait. T-19's sub-3 ms figure is a Rota-to-Rota MIDI link, so sync's coarser alignment does not violate it. | If a sync source is found that also carries a bar marker Rota could use. | +| D-121 | A port whose own 64-deep ring (hal::kClockInCapacity) saturates in one drain pass is marked unusable for that pass and reacquired; arbitration skips an unusable port. This is separate from the followed-and-driven question (D-113). | 2026-09-03. The per-port rings exist so a shorted or chattering sync jack cannot crowd out the MIDI clock it is meant to lose to (D-114); the follower acts on that by excluding the dead port while it keeps following and driving the live one, rather than letting garbage poison the estimate. The guard is a ring-saturation test, not an edge-rate one, so sub-saturation chatter can still nudge the estimate — the estimator's own smoothing absorbs it, and a physical edge-rate ceiling is a bring-up refinement. | If bench chatter below saturation proves to need an edge-rate limit. | +| D-122 | The tempo adopted when a follow ends is applied by the controller through the existing tap-tempo path (`edited_sections` then `sections[t].state().bpm`), triggered by `Clock::take_lost_bpm()`; `follow()` and the controller's application run inside one main-loop lock so no timer `begin_beat` sees an inconsistent bpm. The wire never writes `engine::State::bpm` itself. | 2026-09-03. D-112 writes the last tempo 'into the sections a knob would reach', which is exactly what tap tempo (T-95) does, so reuse it rather than add a second bpm-writing path; keeping the engine write in the controller and out of the Clock keeps the wire from ever touching bpm, and the single-lock ordering makes the quiet-loss handover seamless. | — | diff --git a/firmware/src/app/app.cpp b/firmware/src/app/app.cpp index 91f0817..4d23ac3 100644 --- a/firmware/src/app/app.cpp +++ b/firmware/src/app/app.cpp @@ -97,6 +97,8 @@ struct Frame { bool tap_tempo; engine::Fraction playhead; uint32_t cycle_index; + bool following; // a wire owns the tempo (§11): the ring shows it with `ext` + int measured_bpm; // the followed tempo, for the ring's corner }; Frame frame; @@ -178,7 +180,8 @@ void draw_ring(uint16_t* framebuffer, int64_t position, int bottom) { ring.cycle_index = frame.cycle_index; ring.playhead = frame.playhead; ring.playing = frame.transport; - ring.bpm = frame_state.bpm; + ring.external = frame.following; + ring.bpm = frame.following && frame.measured_bpm > 0 ? frame.measured_bpm : frame_state.bpm; ring.section = letter_of(frame.current); ring.song = frame.song; ring.battery = hal::battery_percent(); @@ -225,6 +228,8 @@ void draw(uint64_t now_us) { frame_position = position; frame.playhead = scheduler.playhead(position); frame.cycle_index = scheduler.cycle_index(); + frame.following = the_clock.following(); + frame.measured_bpm = the_clock.measured_bpm(); frame.view = the_model.view; frame.status = the_model.status; frame.knob = the_model.knob; @@ -365,8 +370,11 @@ void tick() { last_tick_us = now_us; hal::InputEvent events[kInputBatch]; const int count = hal::read_input(events, kInputBatch); + hal::ClockIn pulses[kClockInDrain]; // drained outside the lock, as input is + const int pulse_count = hal::read_clock_in(pulses, kClockInDrain); hal::lock(); for (int i = 0; i < count; ++i) controller.handle(events[i], the_model, scheduler, audio); + the_clock.follow(pulses, pulse_count, now_us, audio); // latch the anchor, fold the wire in before the controller reads it controller.tick(now_us, the_model, scheduler, audio); the_clock.set_ports(the_model.settings.midi_clock_out, the_model.settings.sync_out); hal::unlock(); @@ -397,4 +405,18 @@ int64_t audio_position() { return position; } +bool clock_following() { + hal::lock(); + const bool following = the_clock.following(); + hal::unlock(); + return following; +} + +int clock_measured_bpm() { + hal::lock(); + const int bpm = the_clock.measured_bpm(); + hal::unlock(); + return bpm; +} + } // namespace app diff --git a/firmware/src/app/app.h b/firmware/src/app/app.h index 136e6b9..6fdd1d0 100644 --- a/firmware/src/app/app.h +++ b/firmware/src/app/app.h @@ -28,5 +28,8 @@ void tick(); const Model& model(); const FiredLog& fired_log(); int64_t audio_position(); +// The clock's follow state, for the ring and the tests (read on the main loop). +bool clock_following(); +int clock_measured_bpm(); } // namespace app diff --git a/firmware/src/app/clock.cpp b/firmware/src/app/clock.cpp index e1a4d36..b062235 100644 --- a/firmware/src/app/clock.cpp +++ b/firmware/src/app/clock.cpp @@ -13,6 +13,20 @@ int pulses_per_beat(int port) { return port == static_cast(hal::ClockPort::midi) ? kMidiPulsesPerBeat : kSyncPulsesPerBeat; } +int clamp_bpm(int64_t bpm) { + if (bpm < sound::kMinBpm) return sound::kMinBpm; + if (bpm > sound::kMaxBpm) return sound::kMaxBpm; + return static_cast(bpm); +} + +int64_t clamp_length(int64_t frames) { + if (frames < kMinBeatFrames) return kMinBeatFrames; + if (frames > kMaxBeatFrames) return kMaxBeatFrames; + return frames; +} + +PortFollow fresh_port() { return PortFollow{-1, 0, 0, 0, 0, 0, true, false}; } + } // namespace Clock::Clock() @@ -23,7 +37,15 @@ Clock::Clock() enabled_{true, true}, beat_start_(0), beat_frames_(0), - next_pulse_{0, 0} {} + next_pulse_{0, 0}, + ports_{fresh_port(), fresh_port()}, + source_(Source::none), + stopped_by_byte_(false), + follow_beat_us_(0), + follow_boundary_us_(0), + follow_cycle_us_(0), + last_follow_frames_(0), + lost_bpm_(0) {} // One beat is 60 / bpm seconds, rounded to a frame; a cycle is four of them (§6.1). int Clock::beat_frames(int bpm) const { return (sound::kSampleRate * kSecondsPerMinute + bpm / 2) / bpm; } @@ -35,36 +57,214 @@ void Clock::set_ports(bool midi_out, bool sync_out) { void Clock::start_transport() { running_ = true; - start_pending_ = enabled_[static_cast(hal::ClockPort::midi)]; + // A followed MIDI port is never driven, so no Start goes back to the leader (D-113). + start_pending_ = enabled_[static_cast(hal::ClockPort::midi)] && source_ != Source::midi; } void Clock::stop_transport() { running_ = false; start_pending_ = false; - // Now, not on a grid: the sound stops now. The sync jack carries no transport, so - // there is nothing to tell the Pocket Operator except the absence of pulses. - if (enabled_[static_cast(hal::ClockPort::midi)] && hal::midi_port_open()) { + if (enabled_[static_cast(hal::ClockPort::midi)] && source_ != Source::midi && hal::midi_port_open()) { hal::send_clock_out(hal::ClockPort::midi, hal::ClockPulse::stop, hal::now_us()); } } +int64_t Clock::frame_of(uint64_t time_us) const { + const int64_t since = static_cast(time_us) - static_cast(anchor_.time_us); // signed + const int64_t frames = since * static_cast(sound::kSampleRate) / kMicrosecondsPerSecond; + return anchor_.frames - static_cast(hal::audio_buffer_frames()) + frames; +} + +bool Clock::anchor_fresh(uint64_t now_us) const { + return anchored_ && static_cast(now_us) - static_cast(anchor_.time_us) <= kAnchorStaleUs; +} + +void Clock::follow(const hal::ClockIn* pulses, int count, uint64_t now_us, AudioPath& audio) { + AudioAnchor fresh; + if (audio.anchor.take(fresh)) { // the sole consumer of the mailbox; emit_until reads the latch + anchor_ = fresh; + anchored_ = true; + } + // A port whose own ring saturated this pass is chattering or shorted; it is excluded + // until it settles, and the other port keeps being followed (D-121). + int seen[hal::kClockPortCount] = {0, 0}; + for (int i = 0; i < count; ++i) seen[static_cast(pulses[i].port)] += 1; + for (int p = 0; p < hal::kClockPortCount; ++p) ports_[p].usable = seen[p] < hal::kClockInCapacity; + + for (int i = 0; i < count; ++i) { + const hal::ClockIn& e = pulses[i]; + const int p = static_cast(e.port); + PortFollow& f = ports_[p]; + const bool is_midi = e.port == hal::ClockPort::midi; + + if (e.pulse == hal::ClockPulse::start) { // sync never carries transport + if (is_midi) { + f.index = 0; + f.saw_start = true; + f.per_tick_us = 0; + f.last_us = -1; + f.beat_us = static_cast(e.time_us); + f.cycle_us = f.beat_us; + f.locked_ticks = 1; + stopped_by_byte_ = false; + } + continue; + } + if (e.pulse == hal::ClockPulse::stop) { + if (is_midi) stopped_by_byte_ = true; + continue; + } + if (e.pulse == hal::ClockPulse::resume) continue; // Rota never sends it; ignored + + const int ppb = pulses_per_beat(p); + const int64_t t = static_cast(e.time_us); + + if (!is_midi && f.per_tick_us > 0 && f.last_us >= 0 && (t - f.last_us) < f.per_tick_us / kSyncBounceDivisor) { + continue; // a sync contact bouncing; not a pulse (D-118) + } + if (f.last_us < 0) { // bootstrap + f.last_us = t; + f.beat_us = t; + if (!(is_midi && f.saw_start)) f.cycle_us = t; + f.locked_ticks = 1; + continue; + } + const int64_t raw = t - f.last_us; // > 0: stamps are monotone per port + int ticks; + if (f.per_tick_us == 0) { + ticks = 1; + f.per_tick_us = raw; // seed + } else { + ticks = static_cast((raw + f.per_tick_us / 2) / f.per_tick_us); // >= 1.5x mean -> 2 (D-118) + if (ticks < 1) ticks = 1; + if (ticks > kMaxTicksPerInterval) { // a discontinuity: this port reacquires + f.per_tick_us = 0; + f.last_us = t; + f.beat_us = t; + if (!(is_midi && f.saw_start)) f.cycle_us = t; + f.locked_ticks = 1; + continue; + } + const int shift = is_midi ? kEmaShiftMidi : kEmaShiftSync; + const int64_t per = raw / ticks; + const int64_t delta = per - f.per_tick_us; + const int64_t half = static_cast(1) << (shift - 1); + f.per_tick_us += (delta + (delta >= 0 ? half : -half)) >> shift; // EMA, rounded to nearest so bpm has no bias + } + f.index = (f.index + ticks) % kMidiPulsesPerCycle; + if (f.index % ppb == 0) { // a beat boundary + f.beat_us = t; + if (is_midi && f.saw_start) { + if (f.index == 0) f.cycle_us = t; // the true cycle downbeat + } else { + f.cycle_us = t; // sync or no Start: the beat is the reference (D-120) + } + } + f.last_us = t; + f.locked_ticks += ticks; + if (f.locked_ticks > 2 * kMidiPulsesPerCycle) f.locked_ticks = 2 * kMidiPulsesPerCycle; + } + + // Arbitration, on now_us and never the anchor. MIDI wins whenever MIDI is alive; a + // source is followed only after a whole beat of ticks, so one stray pulse cannot + // grab the phase (D-118 hysteresis). + auto alive = [&](int p) { + return ports_[p].usable && ports_[p].last_us >= 0 && ports_[p].per_tick_us > 0 && + static_cast(now_us) - ports_[p].last_us <= kQuietTimeoutUs; + }; + auto ready = [&](int p) { return alive(p) && ports_[p].locked_ticks >= pulses_per_beat(p); }; + const int midi = static_cast(hal::ClockPort::midi); + const int sync = static_cast(hal::ClockPort::sync); + const Source next = stopped_by_byte_ ? Source::none : ready(midi) ? Source::midi : ready(sync) ? Source::sync + : Source::none; + + if (source_ != Source::none && next == Source::none) { // the one loss-announce edge (D-119) + const int p = source_ == Source::midi ? midi : sync; + const int64_t beat_us = ports_[p].per_tick_us * pulses_per_beat(p); + // The reacquire path can zero per_tick_us in the very pass that loses the source, + // so fall back to the last tempo that was actually published rather than divide by + // zero; if there is none, keep the section's bpm (lost_bpm_ stays 0). + if (beat_us > 0) { + lost_bpm_ = clamp_bpm((kSecondsPerMinute * kMicrosecondsPerSecond + beat_us / 2) / beat_us); + } else if (follow_beat_us_ > 0) { + lost_bpm_ = clamp_bpm((kSecondsPerMinute * kMicrosecondsPerSecond + follow_beat_us_ / 2) / follow_beat_us_); + } + last_follow_frames_ = 0; + } + source_ = next; + if (source_ != Source::none) { + const int p = source_ == Source::midi ? midi : sync; + follow_beat_us_ = ports_[p].per_tick_us * pulses_per_beat(p); + follow_boundary_us_ = ports_[p].beat_us; + follow_cycle_us_ = ports_[p].cycle_us; + } +} + +int Clock::followed_length(int64_t at) const { + const int64_t nominal = + clamp_length((follow_beat_us_ * static_cast(sound::kSampleRate) + kMicrosecondsPerSecond / 2) / + kMicrosecondsPerSecond); + const int64_t b = frame_of(static_cast(follow_boundary_us_)); // the leader's beat in frame-space + int64_t k = (at + nominal - b + nominal / 2) / nominal; // whole beats to near (at + nominal) + if (k < 1) k = 1; + int64_t phase_err = (b + k * nominal) - (at + nominal); + const int64_t bound = nominal / kPhasePullDenom; + if (phase_err > bound) phase_err = bound; + if (phase_err < -bound) phase_err = -bound; + return static_cast(clamp_length(nominal + phase_err)); +} + int Clock::begin_beat(int64_t at, int bpm) { beat_start_ = at; - beat_frames_ = beat_frames(bpm); for (int port = 0; port < hal::kClockPortCount; ++port) next_pulse_[port] = 0; + if (source_ != Source::none && anchor_fresh(hal::now_us())) { + beat_frames_ = followed_length(at); + last_follow_frames_ = beat_frames_; + } else if (source_ != Source::none && last_follow_frames_ > 0) { + beat_frames_ = last_follow_frames_; // freeze at the last followed length: the anchor is stale (D-119) + } else { + beat_frames_ = beat_frames(bpm); // free-run; a quiet loss already wrote bpm, or the wire was never followed + last_follow_frames_ = 0; + } return beat_frames_; } +bool Clock::cycle_boundary(int64_t not_before, int64_t& at) const { + if (source_ == Source::none) return false; + if (!anchor_fresh(hal::now_us())) return false; // no fresh anchor: stay in the count-in + const int p = source_ == Source::midi ? static_cast(hal::ClockPort::midi) : static_cast(hal::ClockPort::sync); + const PortFollow& f = ports_[p]; + if (f.locked_ticks < pulses_per_beat(p)) return false; // not confident yet + const int64_t nominal = clamp_length(f.per_tick_us * pulses_per_beat(p) * static_cast(sound::kSampleRate) / + kMicrosecondsPerSecond); + const bool bar = source_ == Source::midi && f.saw_start; // MIDI with a Start locks the cycle; else the beat (D-120) + const int64_t step = bar ? kBeatsPerCycleClock * nominal : nominal; + const int64_t base = frame_of(static_cast(f.cycle_us)); + int64_t n = (not_before - base + step - 1) / step; // the first boundary at or after not_before + if (n < 0) n = 0; + at = base + n * step; + return true; +} + +int Clock::take_lost_bpm() { + const int v = lost_bpm_; + lost_bpm_ = 0; + return v; +} + +int Clock::measured_bpm() const { + if (source_ == Source::none || follow_beat_us_ <= 0) return 0; + return clamp_bpm((kSecondsPerMinute * kMicrosecondsPerSecond + follow_beat_us_ / 2) / follow_beat_us_); +} + +bool Clock::following() const { return source_ != Source::none && anchor_fresh(hal::now_us()); } + void Clock::emit_until(int64_t horizon, AudioPath& audio) { - AudioAnchor fresh; - if (audio.anchor.take(fresh)) { // take() answers false until the next publish, so latch it - anchor_ = fresh; - anchored_ = true; - } + (void)audio; // the anchor is latched by follow(), which runs every pass; this only reads it if (!running_ || !anchored_ || beat_frames_ <= 0) return; if (!hal::midi_port_open()) return; // no wire in this build: count nothing, arm nothing - arm(static_cast(hal::ClockPort::midi), hal::ClockPort::midi, horizon); - arm(static_cast(hal::ClockPort::sync), hal::ClockPort::sync, horizon); + if (source_ != Source::midi) arm(static_cast(hal::ClockPort::midi), hal::ClockPort::midi, horizon); + if (source_ != Source::sync) arm(static_cast(hal::ClockPort::sync), hal::ClockPort::sync, horizon); } uint64_t Clock::deadline_of(int64_t frame) const { @@ -73,8 +273,8 @@ uint64_t Clock::deadline_of(int64_t frame) const { return static_cast(static_cast(anchor_.time_us) + us); } -// Pulse `index` of this beat, spaced over the beat's own length so a tempo change -// lands on the beat as everything else does. +// Pulse `index` of this beat, spaced over the beat's own length so a tempo change lands +// on the beat as everything else does. int64_t Clock::pulse_frame(int port, int index) const { return beat_start_ + static_cast(index) * beat_frames_ / pulses_per_beat(port); } @@ -88,16 +288,13 @@ void Clock::arm(int port, hal::ClockPort wire, int64_t horizon) { next_pulse_[port] += 1; continue; } - // A Start byte and the tick it belongs to cannot leave at once — a byte is 320 µs - // of wire — so Start goes one byte early and the tick lands on the beat. + // A Start byte and the tick it belongs to cannot leave at once — a byte is 320 µs of + // wire — so Start goes one byte early and the tick lands on the beat. if (start_pending_ && wire == hal::ClockPort::midi) { const uint64_t at_us = deadline_of(frame); if (!hal::send_clock_out(wire, hal::ClockPulse::start, at_us - hal::kMidiByteUs)) return; start_pending_ = false; } - // A refused pulse is offered again on the next tick rather than dropped: the - // ports carry a tempo and no downbeat, so a lost pulse is a phase error nothing - // afterwards can correct, while a late one costs at most one timer period. if (!hal::send_clock_out(wire, hal::ClockPulse::tick, deadline_of(frame))) return; next_pulse_[port] += 1; } diff --git a/firmware/src/app/clock.h b/firmware/src/app/clock.h index 874862e..755c68e 100644 --- a/firmware/src/app/clock.h +++ b/firmware/src/app/clock.h @@ -10,20 +10,92 @@ // the answer; the same grid is what the MIDI clock and the sync jack carry, which is // why one object owns both directions instead of two computing it twice. // -// Today the answer is always the playing section's own tempo; §11's clock in and sync -// in will answer it from a wire instead. +// The answer is the playing section's own tempo — unless a wire is speaking, and then +// it is the tempo measured off that wire (D-117). Following changes only the beat's +// length and phase; engine::State::bpm is never written by the wire, so the share +// code, the undo stack and the card are untouched by a cable in the socket (D-112). // // Frames, never seconds: the audio callback's frame counter is the master clock // (D-084). Nothing in engine/ sees this; a time there is a fraction of one cycle. namespace app { -// MIDI clock is 24 PPQN and a Rota beat is a quarter note (§6.1), so a cycle is 96 -// pulses — which is what lets a follower find the leader's cycle and not just its -// beat (D-112). The sync jack carries the Pocket Operator's 2 PPQN, one pulse every -// eighth of a cycle. +// MIDI clock is 24 PPQN and a Rota beat is a quarter note (§6.1). The sync jack +// carries the Pocket Operator's 2 PPQN, one pulse every eighth of a cycle. constexpr int kMidiPulsesPerBeat = 24; constexpr int kSyncPulsesPerBeat = 2; +// A cycle is four beats (§6.1), so 96 MIDI pulses — the only cycle reference the wire +// carries. The follower counts from the leader's Start modulo 96 to lock cycles and +// not just beats (D-112, D-120). Kept here so clock/ needs no scheduler.h. +constexpr int kBeatsPerCycleClock = 4; +constexpr int kMidiPulsesPerCycle = kMidiPulsesPerBeat * kBeatsPerCycleClock; // 96 + +// The followed beat length is clamped to the §6.3 range in frames so a garbage-fast +// or garbage-slow wire can never drive roll_step = beat_frames/kRollsPerBeat to 0 and +// spin push_window with interrupts off. beat_frames(180) = 16000, beat_frames(60) = +// 48000. Within one bound of these ends the phase pull below becomes one-sided, which +// real leaders inside 60–180 never reach (D-117). +constexpr int kMinBeatFrames = 16000; +constexpr int kMaxBeatFrames = 48000; + +// Tracking nudges a beat's length by at most nominal/kPhasePullDenom (~0.8%, ~3.9 ms +// at 120 bpm): inaudible per beat, and enough to close jitter and drift because +// acquisition already put the first beat on the leader's downbeat (D-117). +constexpr int kPhasePullDenom = 128; + +// The per-tick interval is an exponential moving average; MIDI carries twelve times +// the pulses sync does, so it smooths harder. shift 3 is alpha 1/8, shift 2 alpha 1/4. +constexpr int kEmaShiftMidi = 3; +constexpr int kEmaShiftSync = 2; + +// Sync-only debounce, as a fraction of that port's own running interval: an absolute +// threshold near 12.5 ms would swallow a real 13.9 ms MIDI pulse at 180 bpm, so the +// rule cannot be shared and MIDI is never debounced (D-118). +constexpr int kSyncBounceDivisor = 4; + +// An interval wider than this many mean ticks is a discontinuity — a restart or a +// cable event — not a gap to interpolate: the port reacquires (D-118). A tempo halved +// in one step looks like one interval of two ticks, which the estimator interpolates +// rather than reacquires; that is an accepted limit, the loop stays audible and its +// playhead monotone through it (D-118). +constexpr int kMaxTicksPerInterval = 4; + +// Silence this long on the followed port ends the follow and adopts the last measured +// tempo (D-119): the one path that announces loss. A MIDI Stop ends it sooner. +constexpr int64_t kQuietTimeoutUs = 1500000; // 1.5 s + +// An anchor older than this means the audio callback itself stalled, not that the wire +// went quiet: the follower freezes the beat at its last length and announces nothing +// (D-119). Well above one host audio burst (512 frames = 10.7 ms) and far below +// kQuietTimeoutUs, so the two loss outcomes never collide. +constexpr int64_t kAnchorStaleUs = 50000; // 50 ms + +// Enough to empty both 64-deep rings (hal::kClockInCapacity) in one main-loop pass; +// follow() bounds its own input, as everything crossing the boundary must. +constexpr int kClockInDrain = 2 * hal::kClockInCapacity; // 128 + +// The wire the length and phase come from now; none = free-run on the section's bpm. +// Deliberately not aligned to hal::ClockPort's values: following a port and driving it +// are two different questions (D-113). +enum class Source : uint8_t { none, midi, sync }; + +// One wire's tempo and phase, rebuilt from the pulses it delivered. Written only in +// the main loop under hal::lock() (where read_clock_in is drained, D-114); read in the +// timer's begin_beat / cycle_boundary. per_tick_us is a rate from time_us deltas +// alone — the anchor never touches the tempo, only the phase projection uses it — so +// the host's bursty audio callback cannot jitter the followed tempo (D-118). +struct PortFollow { + int64_t last_us; // arrival of the last accepted tick; -1 = none since reset + int64_t per_tick_us; // EMA of one tick's interval; 0 = not yet established + int64_t beat_us; // arrival of the most recent beat boundary (index % ppb == 0) + int64_t cycle_us; // the most recent acquire reference: a MIDI cycle downbeat after + // a Start, else the last beat (D-120) + int index; // ticks since Start (MIDI) or the first tick (sync), mod 96 + int locked_ticks; // accepted ticks since the last (re)acquire, saturating (hysteresis) + bool usable; // this pass's ring did not saturate (D-121) + bool saw_start; // MIDI: a Start has fixed the cycle phase; false = beat-lock only +}; + class Clock { public: Clock(); @@ -32,35 +104,67 @@ class Clock { // is 16000 to 48000 frames at 48 kHz. int beat_frames(int bpm) const; - // Which out ports the player has turned on (§9.4). Set from the main loop under - // the lock, every pass: two stores cannot drift from the settings they mirror. The - // rows gate the arming and never the counting, so a row switched back on lands the - // next pulse in phase instead of firing a burst to catch up. + // Which out ports the player has turned on (§9.4). Set from the main loop under the + // lock, every pass. The rows gate the arming and never the counting, so a row + // switched back on lands the next pulse in phase instead of firing a burst. void set_ports(bool midi_out, bool sync_out); - // Play and stop, from the scheduler's own transport. Start goes out with the first - // pulse of the first beat, so a listener's pulse count and ours agree from the same - // instant; Stop leaves at once, since the hits already handed over stop sounding at - // once too (T-82). Continue is never sent: a Rota stop always rewinds. + // Play and stop, from the scheduler's transport. Start goes out with the first pulse + // of the first beat, unless MIDI is the source, when nothing is sent on it (D-113); + // Stop leaves at once. Continue is never sent: a Rota stop always rewinds. void start_transport(); void stop_transport(); - // The beat starting at frame `at`, under a section playing at `bpm`. Returns the - // beat's length in frames and takes the grid the out ports count over. + // Drain the pulses read_clock_in handed the main loop, fold each into its port's + // estimate, and re-run arbitration. From the main loop under hal::lock(), beside + // set_ports (D-114). `pulses` is oldest-first with the ports interleaved; `count` is + // bounded by kClockInDrain. `now_us` times the quiet timeout, never the anchor. It + // also latches the audio anchor, since it runs every main-loop pass whether or not + // the transport is playing — so a clock arriving at a stopped device is still seen. + void follow(const hal::ClockIn* pulses, int count, uint64_t now_us, AudioPath& audio); + + // The beat starting at frame `at`, under a section playing at `bpm`. When following + // it returns the measured length pulled toward the leader's boundary (bounded, + // clamped); frozen at the last length while the anchor is stale; the section's own + // beat_frames(bpm) when free-running. The pulse spacing it lays the out ports over is + // (i * beat_frames) / pulses_per_beat, 16000/24 apart at 180 bpm. int begin_beat(int64_t at, int bpm); - // Arms every pulse of this beat due before `horizon`, each with the microsecond its - // frame will be heard at. From the timer callback, under hal::lock(); it reads the - // audio side's anchor mailbox and never touches the wire itself. + // Arms every out pulse of this beat due before `horizon`, each timed to when its + // frame is heard; the followed port is never driven (D-113). From the timer callback + // under hal::lock(); reads the anchor follow() latched and never touches the wire. void emit_until(int64_t horizon, AudioPath& audio); + // A wire is being followed right now: a source is chosen and the latched anchor is + // fresh enough to place its phase. The scheduler asks at play to choose the count-in, + // the controller to show `ext`. + bool following() const; + + // D-112 cycle lock. True once the follower can commit the leader's next acquire + // boundary at a frame >= `not_before`, which it writes to `at`: a MIDI cycle downbeat + // when a Start has been seen, else the next beat (D-120). False keeps the count-in + // waiting. From the timer (Scheduler::tick) under the lock. + bool cycle_boundary(int64_t not_before, int64_t& at) const; + + // The tempo to write into the sections a knob would reach when a follow just ended + // (whole bpm, clamped 60–180); 0 when nothing was lost. Polled once by the + // controller, which applies it through the tap-tempo path (D-122). The wire never + // writes bpm itself. + int take_lost_bpm(); + + // The followed tempo for the ring's top-left corner (whole bpm); 0 when not following. + int measured_bpm() const; + private: - // The microsecond `frame` reaches the output, which is later than the microsecond it - // was rendered by whatever the platform holds between the two: our own pulse has to - // coincide with our own sound, not with the render that produced it. uint64_t deadline_of(int64_t frame) const; int64_t pulse_frame(int port, int index) const; void arm(int port, hal::ClockPort wire, int64_t horizon); + // The audio frame whose sound is heard at `time_us`: the exact inverse of + // deadline_of, the same audio_buffer_frames() term subtracted. Every subtraction is + // int64, because an arriving pulse is almost always stamped before the newest anchor. + int64_t frame_of(uint64_t time_us) const; + bool anchor_fresh(uint64_t now_us) const; + int followed_length(int64_t at) const; AudioAnchor anchor_; // the newest pair the audio side has published bool anchored_; // false until it has published one: nothing can be timed before that @@ -70,6 +174,16 @@ class Clock { int64_t beat_start_; int beat_frames_; int next_pulse_[hal::kClockPortCount]; // the first pulse of this beat not yet armed + + PortFollow ports_[hal::kClockPortCount]; + Source source_; // the wire being followed, chosen by arbitration in follow() + bool stopped_by_byte_; // a MIDI Stop ended the follow at once; cleared on the next Start + int64_t follow_beat_us_; // published: microseconds per beat of the source + int64_t follow_boundary_us_; // published: the source's most recent beat boundary + int64_t follow_cycle_us_; // published: the source's most recent acquire reference + int last_follow_frames_; // the last length begin_beat returned while following; 0 = none. + // Held across a stale anchor so a freeze keeps the tempo. + int lost_bpm_; // one-shot: the tempo to adopt on loss; 0 = none }; } // namespace app diff --git a/firmware/src/app/controller.cpp b/firmware/src/app/controller.cpp index a81fb4f..956ef35 100644 --- a/firmware/src/app/controller.cpp +++ b/firmware/src/app/controller.cpp @@ -135,6 +135,11 @@ void Controller::tick(uint64_t now_us, Model& model, Scheduler& scheduler, Audio pad_hold(i, now_us, model); } } + // The wire, folded in by app::tick's Clock::follow just before this call. + following_ = scheduler.clock().following(); + const int lost = scheduler.clock().take_lost_bpm(); // one-shot: a follow just ended + if (lost > 0) adopt_bpm(lost, now_us, model, audio); + if (scheduler.waiting_for_clock()) say(model, now_us, kStatusUs, "waiting for clock"); // the count-in (D-112) } // Pads (§8.1, D-085): the sound at once, the mute while held, the edit on release. @@ -530,6 +535,14 @@ void Controller::tap_tempo(uint64_t at_us, Model& model, AudioPath& audio) { say(model, at_us, kStatusUs, "%d bpm", bpm); } +void Controller::adopt_bpm(int bpm, uint64_t at_us, Model& model, AudioPath& audio) { + int targets[2]; + const int count = edited_sections(model, targets); + for (int i = 0; i < count; ++i) model.sections[targets[i]].state().bpm = static_cast(bpm); + publish_params(model, audio); + say(model, at_us, kStatusUs, "ext off, %d bpm", bpm); +} + // Stop leaves nothing pending: no song, no switch, no roll (T-81). void Controller::stop_transport(Model& model, Scheduler& scheduler, AudioPath& audio) { model.transport = false; @@ -673,6 +686,10 @@ void Controller::global_knob(hal::Encoder encoder, int detents, uint64_t at_us, engine::State& state = section.state(); switch (encoder) { case hal::Encoder::speed: { + if (following_) { // a MIDI or sync clock owns the tempo; the knob says so (§11, C-09) + show_knob(model, at_us, "ext"); + return; + } int bpm = static_cast(state.bpm) + detents * kBpmPerDetent; if (bpm < sound::kMinBpm) bpm = sound::kMinBpm; if (bpm > sound::kMaxBpm) bpm = sound::kMaxBpm; diff --git a/firmware/src/app/controller.h b/firmware/src/app/controller.h index 056b856..d176ecf 100644 --- a/firmware/src/app/controller.h +++ b/firmware/src/app/controller.h @@ -67,6 +67,9 @@ class Controller { int other_section_held(int except) const; void play_press(uint64_t at_us, Model& model, Scheduler& scheduler, AudioPath& audio); void tap_tempo(uint64_t at_us, Model& model, AudioPath& audio); + // Write a tempo the wire measured into the sections a knob would reach, the same + // path tap tempo uses (§11, D-112, D-122); the wire never writes bpm itself. + void adopt_bpm(int bpm, uint64_t at_us, Model& model, AudioPath& audio); void stop_transport(Model& model, Scheduler& scheduler, AudioPath& audio); void start_song(uint64_t at_us, Model& model, Scheduler& scheduler, AudioPath& audio); void stop_song(Model& model); @@ -79,6 +82,7 @@ class Controller { void set_mute(Model& model, int pad, bool mute); void publish_params(const Model& model, AudioPath& audio); bool any_pad_held() const; + bool following_ = false; // a wire owns the tempo this pass; the speed knob then shows `ext` (§11) // The tutorial waits for one gesture per step (§8.5); the rest is ignored. enum class TutorialEvent : uint8_t { kick_tap, snare_tap, chance_turn, share_opened, song_started }; diff --git a/firmware/src/app/scheduler.cpp b/firmware/src/app/scheduler.cpp index 67b3c2e..c7f0c2e 100644 --- a/firmware/src/app/scheduler.cpp +++ b/firmware/src/app/scheduler.cpp @@ -25,6 +25,7 @@ Scheduler::Scheduler(const engine::Kit& kit, Clock& clock) seed_(0), generation_(0), running_(false), + waiting_for_clock_(false), beat_start_(0), beat_frames_(clock.beat_frames(engine::kDefaultBpm)), beat_in_cycle_(0), @@ -45,6 +46,11 @@ void Scheduler::start(Model& model, AudioPath& audio) { clock_->start_transport(); generation_ += 1; audio.live_generation.store(generation_, std::memory_order_release); + if (clock_->following()) { // §11, D-112: come in on the leader's cycle, so schedule no beat yet + waiting_for_clock_ = true; + return; // generation was bumped first, so a stop still drops handed-over hits (T-82) + } + waiting_for_clock_ = false; const int64_t at = audio.position() + static_cast(kStartDelayBlocks) * sound::kBlockSize; scheduled_until_ = at; begin_beat(model, at, true, audio); @@ -52,6 +58,7 @@ void Scheduler::start(Model& model, AudioPath& audio) { void Scheduler::stop(AudioPath& audio) { running_ = false; + waiting_for_clock_ = false; clock_->stop_transport(); generation_ += 1; audio.live_generation.store(generation_, std::memory_order_release); @@ -59,6 +66,21 @@ void Scheduler::stop(AudioPath& audio) { void Scheduler::tick(Model& model, AudioPath& audio) { if (!running_) return; + if (waiting_for_clock_) { + const int64_t not_before = audio.position() + static_cast(kStartDelayBlocks) * sound::kBlockSize; + int64_t at; + if (clock_->cycle_boundary(not_before, at)) { + waiting_for_clock_ = false; + scheduled_until_ = at; // the first beat lands on the leader's cycle downbeat (D-112) + begin_beat(model, at, true, audio); + } else if (!clock_->following()) { // the leader vanished during the count-in: free-run instead + waiting_for_clock_ = false; + scheduled_until_ = not_before; + begin_beat(model, not_before, true, audio); + } else { + return; // still waiting; nothing is scheduled and nothing is driven out (no emit while counting in) + } + } const int64_t horizon = audio.position() + kLookaheadFrames; while (scheduled_until_ < horizon) { const int64_t beat_end = beat_start_ + beat_frames_; diff --git a/firmware/src/app/scheduler.h b/firmware/src/app/scheduler.h index 5ffe61d..6e34cf2 100644 --- a/firmware/src/app/scheduler.h +++ b/firmware/src/app/scheduler.h @@ -37,6 +37,12 @@ class Scheduler { void start(Model& model, AudioPath& audio); void stop(AudioPath& audio); bool running() const { return running_; } + // Between play and the leader's next cycle, while a followed clock is being + // waited on (§11, D-112); the controller shows the count-in from it. + bool waiting_for_clock() const { return waiting_for_clock_; } + // The clock, so the controller can adopt a lost tempo and read `ext` without a + // new parameter threaded through its own signatures. + Clock& clock() { return *clock_; } // From the timer, under hal::lock(): hands the audio side every hit due before // its position plus the lookahead. @@ -64,6 +70,7 @@ class Scheduler { uint32_t seed_; uint32_t generation_; bool running_; + bool waiting_for_clock_; // play pressed while following: the count-in waits for the leader (D-112) int64_t beat_start_; int beat_frames_; int beat_in_cycle_; diff --git a/firmware/src/ui/ring.cpp b/firmware/src/ui/ring.cpp index 11c480a..2715a53 100644 --- a/firmware/src/ui/ring.cpp +++ b/firmware/src/ui/ring.cpp @@ -156,7 +156,11 @@ void draw_playhead(Canvas& canvas, const Geometry& g, float fraction) { void draw_corners(Canvas& canvas, const RingModel& model) { char text[kBatteryTextCapacity]; - std::snprintf(text, sizeof text, "%d", model.bpm); + if (model.external) { + std::snprintf(text, sizeof text, "%d ext", model.bpm); // the number is what is playing; ext says whose (§11) + } else { + std::snprintf(text, sizeof text, "%d", model.bpm); + } draw_text(canvas, kMargin, kTopRow, text, kText); std::snprintf(text, sizeof text, "%c %d %d%%", model.section, model.song, model.battery); draw_text(canvas, kWidth - kMargin - text_width(text), kTopRow, text, kText); diff --git a/firmware/src/ui/ring.h b/firmware/src/ui/ring.h index 3700c4f..ae8e6bd 100644 --- a/firmware/src/ui/ring.h +++ b/firmware/src/ui/ring.h @@ -31,6 +31,7 @@ struct RingModel { engine::Fraction playhead; bool playing; int bpm; + bool external; // the tempo comes from a MIDI or sync clock (§11): the corner reads `bpm ext` char section; int song; int battery; diff --git a/spec/scenarios.md b/spec/scenarios.md index 21dc2d4..25ef921 100644 --- a/spec/scenarios.md +++ b/spec/scenarios.md @@ -1,6 +1,6 @@ # Acceptance scenarios -Every behaviour in the PRD has one row here, and every engine test names the ID it covers (PRD §12 rule 2). T-01–T-24 are PRD §13 unchanged. T-25 onward were added on 2026-09-02 for §6 behaviours that had no scenario, T-85 onward on 2026-09-03 for the §9 views and the lights, T-95 and T-96 the same day for the last two §8.2 gestures, T-97–T-99 for what the card keeps and T-100 and T-101 for the kit, and T-102 onward the same day for §7.6 and §11's clock, sync jack and jam link (T-111 and T-112 the jam link's SysEx); the PRD section each comes from is in brackets. +Every behaviour in the PRD has one row here, and every engine test names the ID it covers (PRD §12 rule 2). T-01–T-24 are PRD §13 unchanged. T-25 onward were added on 2026-09-02 for §6 behaviours that had no scenario, T-85 onward on 2026-09-03 for the §9 views and the lights, T-95 and T-96 the same day for the last two §8.2 gestures, T-97–T-99 for what the card keeps and T-100 and T-101 for the kit, and T-102 onward the same day for §7.6 and §11's clock, sync jack and jam link (T-104–T-109 following an external clock, T-111 and T-112 the jam link's SysEx); the PRD section each comes from is in brackets. Conventions: fractions are of one cycle; the default kit (lofi), C minor and 100 bpm are assumed unless stated; "tap ×n" means n taps on that pad starting from empty, following the kit's smart defaults (§6.6); per-track modifier strings are the share-code form from `spec/share-format.md` §3. IDs are never reused: retire a scenario by striking it through, not by deleting it. @@ -114,6 +114,13 @@ Conventions: fractions are of one cycle; the default kit (lofi), C minor and 100 | T-111 | Format a loop and a track for the empty loop, the classic beat (T-05) and the worst case (G-14), then read each back; and check the empty loop against spec/jam-link.md G-JAM-01 (§10, §11, D-105, D-111, D-114) | Each opens `F0 7D R T 01`, then `L` with pad 0 or `T` with the pad it was sent from, then the section's own share code with its own six-character id, then `F7`; every payload byte is under 0x80, so none is a status byte and nothing is escaped. The empty loop is byte-identical to G-JAM-01. Read back, a loop arrives as a loop and a track as a track on its pad, both decoding to the same loop the sender held; the id the code carries is the loop's own, so a receiver can keep it as lineage. | | T-112 | Feed the receiver a foreign manufacturer id, our id without the `RT` tag, a version of 2, a payload that is not a code, an `RT2S` song code, a payload past kMaxSectionCodeInput, a loop that names a pad, a track whose pad is 8, and a message cut off before `F7`; then a good message with a clock byte scattered through it (§10, §11, D-106, D-111, D-114) | Every malformed message is refused: nothing decodes, and a good message straight after still arrives, because the parser re-syncs on the next `F0`. An oversize payload is dropped while the parser counts on to `F7`, so the read stays bounded and D-106 is the one cap. A status byte in the middle of a good message is skipped and the message still arrives whole, since payload bytes are all under 0x80. | +| T-104 | A MIDI leader clocks Start then 24 PPQN at 120 bpm; a device on the lofi kick beat (its own bpm 100) follows, then play (§11, D-112, D-117, D-120) | The device follows once a beat of ticks has arrived: `following` is true and the corner reads 120. Play waits for the leader's cycle — the count-in — and the first beat lands on the leader's cycle downbeat, so every kick, heard one output buffer after its sample, sits on the leader's beat grid. The loop plays at 120 (a 24000-frame beat, not the section's 28800), and the kick's own bpm stays 100: the wire owns the tempo, never the pattern. | +| T-105 | The same, driven by 2 PPQN pulses on the sync jack with no MIDI present (§7.6, §11, D-119, D-120) | The follower runs from the sync grid, measured at two pulses to the beat rather than twenty-four, and the loop plays at the sync tempo. Sync carries no Start, so it beat-locks rather than cycle-locks; the coarser two-pulse rate takes a few beats to settle. | +| T-106 | Both ports carry a leader — MIDI at 120 and sync at 90 — at once (§11, D-118) | MIDI is followed whenever MIDI is alive and sync changes nothing while it is: the corner reads 120, not 90, and the answer does not depend on which cable went in first. | +| T-107 | While following 120, the leader goes quiet; then a run ended instead by a MIDI Stop (§11, T-20, D-118, D-121) | For a second and a half a gap costs nothing and the beat carries on at the last measured tempo, because a dropped pulse is commoner than a stopped sender. After that the follow ends, the sections take 120 bpm as if a knob had set it (rounded whole, clamped 60–180), the corner goes back to a number and the message row reads `ext off, 120 bpm`; the loop plays on with no glitch and no stop. A MIDI Stop byte ends the follow at once, well before the 1.5 s, and adopts the tempo the same way. | +| T-108 | A wire clocking far above the tempo range — MIDI spaced for ~400 bpm — is followed and played (§6.3, §11, T-84, D-117) | The followed beat length is clamped to the 16000-frame floor (180 bpm), never faster, so push_window's roll grid cannot reach a zero step and spin with interrupts off; the run completes and the loop plays at the ceiling rather than hanging. | +| T-109 | While following, a tick arrives stamped a millisecond before the newest anchor (§11, D-117) | The stamp maps to a frame just behind the anchor's, not about 1e13 frames ahead: every frame-to-microsecond conversion subtracts as int64, so an older stamp gives a negative difference rather than an unsigned wrap. The loop keeps following at a sane tempo. | + ## Watch in testing Design bets with a known fallback. Observe them in usability round 1 (PRD §14, phase 2); the fallback is written down so nobody re-derives it. diff --git a/tests/follow_test.cpp b/tests/follow_test.cpp new file mode 100644 index 0000000..0acc81c --- /dev/null +++ b/tests/follow_test.cpp @@ -0,0 +1,220 @@ +// Following an external clock (PRD §11, D-112, D-117..D-122): spec/scenarios.md +// T-104..T-114. The fake's now_us is set to us_of(frame) before every callback, so a +// pulse stamped at us_of(F) is placed at frame F (less the output buffer) — the same +// inverse deadline_of uses. The two-device 3 ms figure (T-19) is bench-only; here the +// follower's arithmetic is checked against a leader the test clocks itself. +#include +#include + +#include "app_support.h" +#include "engine/kits/lofi.h" + +using namespace app_support; + +namespace { + +int64_t midi_tick_frames(int bpm) { return kSecond * 60 / bpm / app::kMidiPulsesPerBeat; } +int64_t sync_pulse_frames(int bpm) { return kSecond * 60 / bpm / app::kSyncPulsesPerBeat; } +int64_t beat_frames(int bpm) { return kSecond * 60 / bpm; } + +// A leader the test clocks: it emits a pulse whenever the world reaches the next grid +// point, advancing the world one block at a time so the app drains and folds it as it +// would a real wire. +struct Leader { + World& w; + hal::ClockPort port; + int64_t per; + int64_t next; + + Leader(World& world, hal::ClockPort p, int bpm) + : w(world), port(p), per(p == hal::ClockPort::midi ? midi_tick_frames(bpm) : sync_pulse_frames(bpm)), next(0) {} + + void start() { // MIDI Start marks the leader's cycle 0; sync carries none + next = w.frames; + if (port == hal::ClockPort::midi) hal_fake::push_clock_in(port, hal::ClockPulse::start, us_of(w.frames)); + } + void run_to(int64_t target) { + while (w.frames < target) { + if (w.frames >= next) { + hal_fake::push_clock_in(port, hal::ClockPulse::tick, us_of(next)); // stamped at the grid, not the block + next += per; + } + w.run_for(kBlock); + } + } + void stop_byte() { hal_fake::push_clock_in(port, hal::ClockPulse::stop, us_of(w.frames)); } +}; + +std::vector hits(const World& w, Pad pad) { + std::vector out; + for (const app::Fired& f : w.fired) { + if (f.event.track == pad && !f.audition) out.push_back(f.sample); + } + std::sort(out.begin(), out.end()); + return out; +} + +// The typical spacing between consecutive hits, ignoring the first (the count-in beat). +int64_t typical_gap(const std::vector& xs) { + std::vector gaps; + for (size_t i = 1; i < xs.size(); ++i) gaps.push_back(xs[i] - xs[i - 1]); + std::sort(gaps.begin(), gaps.end()); + return gaps.empty() ? 0 : gaps[gaps.size() / 2]; // median: robust to the odd wrapped gap +} + +bool near(int64_t a, int64_t b, int64_t tol) { return (a > b ? a - b : b - a) <= tol; } + +} // namespace + +TEST_CASE("T-104 A device follows a MIDI leader's tempo and comes in on its cycle") { + World w; + hal_fake::set_midi_port_open(true); + w.tap(Pad::kick, 4); // the section's own tempo is 100 bpm + REQUIRE(w.state(0).bpm == 100); + + Leader lead(w, hal::ClockPort::midi, 120); + lead.start(); + const int64_t start_frame = w.frames; + lead.run_to(w.frames + 2 * beat_frames(120)); // a couple of beats to lock + + CHECK(app::clock_following()); + CHECK(near(app::clock_measured_bpm(), 120, 1)); + + w.press(hal::Button::play); // play while following: the count-in waits for the leader's cycle + lead.run_to(w.frames + 5 * 4 * beat_frames(120)); + w.collect(); + + const std::vector kicks = hits(w, Pad::kick); + REQUIRE(kicks.size() >= 8); + // The beat is the leader's 120 (24000 frames), not the section's 100 (28800). + CHECK(near(typical_gap(kicks), beat_frames(120), 200)); + // The kick's own bpm field never changed: the wire owns tempo, not the pattern (D-112). + CHECK(w.state(0).bpm == 100); + // Phase lock: every kick, heard one output buffer after its sample, sits on the + // leader's beat grid laid from its Start (D-112). The first kick is the count-in's + // beat, on the leader's cycle downbeat. + const int64_t beat = beat_frames(120); + const int64_t cycle = 4 * beat; + auto phase_mod = [&](int64_t k, int64_t m) { return ((k + hal::kAudioBlockFrames - start_frame) % m + m) % m; }; + for (int64_t k : kicks) { + const int64_t phase = phase_mod(k, beat); + CHECK((phase < 600 || phase > beat - 600)); + } + const int64_t first = phase_mod(kicks.front(), cycle); + CHECK((first < 600 || first > cycle - 600)); +} + +TEST_CASE("T-107 When the leader goes quiet the loop keeps its tempo and the sections adopt it") { + World w; + hal_fake::set_midi_port_open(true); + w.tap(Pad::kick, 4); + Leader lead(w, hal::ClockPort::midi, 120); + lead.start(); + lead.run_to(w.frames + 2 * beat_frames(120)); + w.press(hal::Button::play); + lead.run_to(w.frames + 3 * 4 * beat_frames(120)); + REQUIRE(app::clock_following()); + + // The leader stops. For a second and a half a gap changes nothing. + w.run_for(kSecond); + CHECK(app::clock_following()); // still following, tempo held + w.run_for(kSecond); // now past 1.5 s of silence + CHECK_FALSE(app::clock_following()); + + // The sections took the measured tempo as if a knob had set it, and the loop plays on. + CHECK(w.state(0).bpm == 120); + CHECK(w.status() == "ext off, 120 bpm"); + w.collect(); + const size_t before = hits(w, Pad::kick).size(); + w.run_for(4 * beat_frames(120)); + CHECK(hits(w, Pad::kick).size() > before); // no glitch, no stop (T-20) + + SUBCASE("a MIDI Stop ends the follow at once, before the quiet timeout") { + World w2; + hal_fake::set_midi_port_open(true); + w2.tap(Pad::kick, 4); + Leader lead2(w2, hal::ClockPort::midi, 120); + lead2.start(); + lead2.run_to(w2.frames + 2 * beat_frames(120)); + w2.press(hal::Button::play); + lead2.run_to(w2.frames + 2 * 4 * beat_frames(120)); + REQUIRE(app::clock_following()); + lead2.stop_byte(); + w2.run_for(kSecond / 4); // well under the 1.5 s quiet timeout + CHECK_FALSE(app::clock_following()); + CHECK(w2.state(0).bpm == 120); // the last measured tempo is adopted + } +} + +TEST_CASE("T-105 A device follows the sync jack when no MIDI is present") { + World w; + hal_fake::set_midi_port_open(true); + w.tap(Pad::kick, 4); + Leader lead(w, hal::ClockPort::sync, 100); + lead.start(); // sync sends no Start; begins its grid on the first pulse + lead.run_to(w.frames + 3 * beat_frames(100)); // sync is coarse: a few beats to lock + + CHECK(app::clock_following()); + CHECK(near(app::clock_measured_bpm(), 100, 3)); // 2 PPQN is coarse, so a little slack + + w.press(hal::Button::play); + lead.run_to(w.frames + 5 * 4 * beat_frames(100)); + w.collect(); + const std::vector kicks = hits(w, Pad::kick); + REQUIRE(kicks.size() >= 8); + CHECK(near(typical_gap(kicks), beat_frames(100), 400)); +} + +TEST_CASE("T-106 MIDI wins over sync whenever MIDI is alive") { + World w; + hal_fake::set_midi_port_open(true); + w.tap(Pad::kick, 4); + Leader midi(w, hal::ClockPort::midi, 120); + Leader sync(w, hal::ClockPort::sync, 90); + midi.start(); + sync.start(); + // Clock both for two beats; they interleave on the two rings. + const int64_t until = w.frames + 2 * beat_frames(120); + while (w.frames < until) { + if (w.frames >= midi.next) { hal_fake::push_clock_in(hal::ClockPort::midi, hal::ClockPulse::tick, us_of(midi.next)); midi.next += midi.per; } + if (w.frames >= sync.next) { hal_fake::push_clock_in(hal::ClockPort::sync, hal::ClockPulse::tick, us_of(sync.next)); sync.next += sync.per; } + w.run_for(kBlock); + } + CHECK(app::clock_following()); + CHECK(near(app::clock_measured_bpm(), 120, 2)); // MIDI's tempo, not sync's 90 +} + +TEST_CASE("T-108 A garbage-fast wire cannot drive the beat below the range or hang the loop") { + World w; + hal_fake::set_midi_port_open(true); + w.tap(Pad::kick, 4); + Leader lead(w, hal::ClockPort::midi, 400); // far above §6.3's 180 ceiling + lead.start(); + lead.run_to(w.frames + 2 * beat_frames(180)); + w.press(hal::Button::play); + lead.run_to(w.frames + 5 * 4 * beat_frames(180)); // the run completing at all proves push_window did not spin + w.collect(); + const std::vector kicks = hits(w, Pad::kick); + REQUIRE(kicks.size() >= 8); + CHECK(typical_gap(kicks) >= app::kMinBeatFrames - 50); // clamped at 16000, never faster +} + +TEST_CASE("T-109 A pulse stamped before the newest anchor maps behind it, not far ahead") { + World w; + hal_fake::set_midi_port_open(true); + w.tap(Pad::kick, 4); + Leader lead(w, hal::ClockPort::midi, 120); + lead.start(); + lead.run_to(w.frames + 2 * beat_frames(120)); + w.press(hal::Button::play); + lead.run_to(w.frames + 2 * 4 * beat_frames(120)); + REQUIRE(app::clock_following()); + + // A tick whose stamp is a millisecond old — as a real ISR stamp read a pass later is. + hal_fake::push_clock_in(hal::ClockPort::midi, hal::ClockPulse::tick, us_of(w.frames) - 1000); + lead.run_to(w.frames + 4 * beat_frames(120)); + w.collect(); + // No wrap-to-1e13 blow-up: the loop is still following at a sane tempo. + CHECK(app::clock_following()); + CHECK(near(app::clock_measured_bpm(), 120, 2)); +} From 5a453fae94edbc49c4263ddf2305b441c8749dc6 Mon Sep 17 00:00:00 2001 From: Deva Date: Fri, 4 Sep 2026 00:08:47 +0530 Subject: [PATCH 07/12] Hold show and pass a part across, and take one that arrives as an ordinary edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app/jam.cpp is the counterpart to app/card.cpp (D-123). Holding show, with the share view up, and pressing a pad sends that pad's track; dice sends the whole loop. The controller only records the gesture in model.jam_request — under it a pad neither sounds, mutes nor adds a hit and dice neither fills nor clears, and the share view stays up so several parts go in a row — and app::tick carries it out, exactly as a song pick is recorded and the card carries it out. One message goes out at a time (`still sending` otherwise); a build with no port says `no jam link`; what the wire refuses is offered again, so a code arrives once and in order. An arriving message lands as one undoable edit on the section being edited (Section::push_edit): a track copies one pad's steps, alternation and speed, a loop all eight, and a whole loop also takes the sender's id as its lineage so the share view can say what it is based on (D-105). Level, tone, send, chance, mute, bpm, filter, fx, swing and key all stay the receiver's — the jam sends patterns, never knobs (D-035), so no knob moves under the player's hand and the arrival reverses exactly as an undo does. It is heard from the next beat (§6.7). T-113 drives the gestures (send, no port, still-sending) by reading the bytes back off the fake wire; T-114/T-115 push a formatted message on and check the patterns land, the knobs do not, and one undo restores; T-116 the refuse-and- retry path. The two-device latency of T-19 stays a bench measurement. Co-Authored-By: Claude Opus 4.8 --- DECISIONS.md | 1 + firmware/src/app/app.cpp | 8 ++ firmware/src/app/controller.cpp | 16 +++ firmware/src/app/controller.h | 3 + firmware/src/app/jam.cpp | 70 +++++++++++++ firmware/src/app/jam.h | 40 ++++++++ firmware/src/app/model.h | 10 ++ spec/scenarios.md | 7 +- tests/jam_test.cpp | 169 ++++++++++++++++++++++++++++++++ 9 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 firmware/src/app/jam.cpp create mode 100644 firmware/src/app/jam.h create mode 100644 tests/jam_test.cpp diff --git a/DECISIONS.md b/DECISIONS.md index 2bda31e..513d391 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -126,3 +126,4 @@ What was decided, why, and when to look again. One row per decision. IDs are seq | D-120 | Cycle lock (counting MIDI pulses modulo 96 from the leader's Start) is MIDI-only. The sync jack carries no Start or transport (D-114), so following sync beat-locks to the pulse grid and acquires on the next sync beat, with no cross-cycle bar alignment; MIDI followed before any Start has been seen also degrades to beat-lock. | 2026-09-03. D-112's cycle reference is the MIDI Start byte and 96 pulses, which the sync wire simply does not carry, so promising bar alignment on sync would be a guess; beat-locking to what the wire actually carries is honest and gives a short acquire wait. T-19's sub-3 ms figure is a Rota-to-Rota MIDI link, so sync's coarser alignment does not violate it. | If a sync source is found that also carries a bar marker Rota could use. | | D-121 | A port whose own 64-deep ring (hal::kClockInCapacity) saturates in one drain pass is marked unusable for that pass and reacquired; arbitration skips an unusable port. This is separate from the followed-and-driven question (D-113). | 2026-09-03. The per-port rings exist so a shorted or chattering sync jack cannot crowd out the MIDI clock it is meant to lose to (D-114); the follower acts on that by excluding the dead port while it keeps following and driving the live one, rather than letting garbage poison the estimate. The guard is a ring-saturation test, not an edge-rate one, so sub-saturation chatter can still nudge the estimate — the estimator's own smoothing absorbs it, and a physical edge-rate ceiling is a bring-up refinement. | If bench chatter below saturation proves to need an edge-rate limit. | | D-122 | The tempo adopted when a follow ends is applied by the controller through the existing tap-tempo path (`edited_sections` then `sections[t].state().bpm`), triggered by `Clock::take_lost_bpm()`; `follow()` and the controller's application run inside one main-loop lock so no timer `begin_beat` sees an inconsistent bpm. The wire never writes `engine::State::bpm` itself. | 2026-09-03. D-112 writes the last tempo 'into the sections a knob would reach', which is exactly what tap tempo (T-95) does, so reuse it rather than add a second bpm-writing path; keeping the engine write in the controller and out of the Clock keeps the wire from ever touching bpm, and the single-lock ordering makes the quiet-loss handover seamless. | — | +| D-123 | The jam gestures and what an arrival does (§11, D-111): holding show (the share view up) and pressing a pad sends that pad's track, dice the whole loop; under the gesture the pad neither sounds, mutes nor adds a hit and dice neither fills nor clears, and the share view stays up so several can be sent in a row. The controller records the gesture in `model.jam_request` and `app/jam.cpp` carries it out, as the song view records a pick and the card carries it out; one message goes out at a time (`still sending` otherwise) and a build with no port says `no jam link`. A received message lands as one undoable edit on the section being edited (`Section::push_edit`): a track copies one pad's steps, alternation and speed, a loop all eight, and a whole loop also takes the sender's id as its lineage; level, tone, send, chance, mute, bpm, filter, fx, chance, swing and key stay the receiver's. | 2026-09-03. The gesture is the song-pick shape (D-030) one layer over: the controller only notes that the player asked, and app/ does the I/O, so nothing that sends touches the wire from inside the input grammar. Patterns-only is D-035 read across the cable — a jam partner moving a knob is worse than an undo moving one, because the player is holding it — and it makes a track and a loop one idea at two scales, reversing exactly as an undo does. The lineage on a whole loop is what lets the receiver's share view say `based on` the sender (D-105); a track is a fragment, not a loop, so it carries none. One message at a time keeps a held-show roll across the pads from queuing eight transfers of 79 ms each. Rejected: auditioning the pad as it sends (it would play a sound the player did not mean); bringing the sender's mix or tempo (D-035, and a follower's bpm is not what it plays); queuing sends (a second gesture is likelier a correction than a batch). | If testers want to send several parts at once, or want the sender's mix to travel (then a third message type, under a new envelope version). | diff --git a/firmware/src/app/app.cpp b/firmware/src/app/app.cpp index 4d23ac3..ab9b854 100644 --- a/firmware/src/app/app.cpp +++ b/firmware/src/app/app.cpp @@ -7,6 +7,7 @@ #include "app/card.h" #include "app/clock.h" #include "app/controller.h" +#include "app/jam.h" #include "app/params.h" #include "app/scheduler.h" #include "engine/kits/lofi.h" @@ -44,6 +45,7 @@ constexpr uint32_t kFramePeriodUs = 16667; // §7.3: 60 fps constexpr int64_t kFlashFrames = sound::kSampleRate / 4; // §9.1: 250 ms constexpr int64_t kLedFlashFrames = sound::kSampleRate / 10; // a pad lights fully for 100 ms after its hit constexpr int kInputBatch = 32; +constexpr int kMidiReadBatch = 256; // a chunk of the wire a pass; a message spans passes if longer constexpr int kLineCapacity = 320; constexpr int kFooterCapacity = 32; const char* const kTapMarker = "tap"; // the top row while tap tempo waits (§8.2, D-102) @@ -60,6 +62,7 @@ HAL_BULK_MEMORY Model the_model(the_kit); Clock the_clock; Scheduler scheduler(the_kit, the_clock); Controller controller(the_kit); +Jam the_jam; AudioPath audio; FiredLog the_fired_log; uint64_t last_frame_us = 0; @@ -344,6 +347,7 @@ void init() { new (&the_clock) Clock(); new (&scheduler) Scheduler(the_kit, the_clock); new (&controller) Controller(the_kit); + new (&the_jam) Jam(); audio.reset(); the_fired_log = FiredLog{}; last_frame_us = 0; @@ -372,12 +376,16 @@ void tick() { const int count = hal::read_input(events, kInputBatch); hal::ClockIn pulses[kClockInDrain]; // drained outside the lock, as input is const int pulse_count = hal::read_clock_in(pulses, kClockInDrain); + uint8_t midi_in[kMidiReadBatch]; + const int midi_count = hal::midi_read(midi_in, kMidiReadBatch); hal::lock(); for (int i = 0; i < count; ++i) controller.handle(events[i], the_model, scheduler, audio); the_clock.follow(pulses, pulse_count, now_us, audio); // latch the anchor, fold the wire in before the controller reads it controller.tick(now_us, the_model, scheduler, audio); + the_jam.step(the_model, the_kit, now_us, midi_in, midi_count); // send the gesture, apply an arrival the_clock.set_ports(the_model.settings.midi_clock_out, the_model.settings.sync_out); hal::unlock(); + the_jam.pump_out(); // meter the outgoing message onto the wire, outside the lock (D-114) Fired fired; while (audio.fired.pop(fired)) the_fired_log.append(fired); diff --git a/firmware/src/app/controller.cpp b/firmware/src/app/controller.cpp index 956ef35..0fbf149 100644 --- a/firmware/src/app/controller.cpp +++ b/firmware/src/app/controller.cpp @@ -142,9 +142,21 @@ void Controller::tick(uint64_t now_us, Model& model, Scheduler& scheduler, Audio if (scheduler.waiting_for_clock()) say(model, now_us, kStatusUs, "waiting for clock"); // the count-in (D-112) } +// Hold show opens the share view and keeps it up (D-093); a pad or dice pressed while +// show is still held is the jam send gesture (§11), so it does not sound, mute, add a +// hit, fill or clear. +bool Controller::sending_gesture(const Model& model) const { + return model.view == View::share && buttons_[static_cast(hal::Button::show)].down; +} + // Pads (§8.1, D-085): the sound at once, the mute while held, the edit on release. void Controller::pad_down(int pad, uint64_t at_us, Model& model, Scheduler& scheduler, AudioPath& audio) { + if (sending_gesture(model)) { // hold show + pad: send that pad's track (§11), no sound and no mute + pads_[pad] = Press{true, at_us, true, false}; // used, so the release does nothing + model.jam_request = JamRequest{true, true, pad}; + return; + } pads_[pad] = Press{true, at_us, false, false}; // A pad is not an instrument in the song view or in settings: there it picks a song // (§9.6) or does nothing at all (D-096), so it neither sounds nor mutes its track — @@ -305,6 +317,10 @@ void Controller::button_press(hal::Button button, uint64_t at_us, Model& model, say(model, at_us, kStatusUs, "undo"); return; case hal::Button::dice: { + if (sending_gesture(model)) { // hold show + dice: send the whole loop (§11) + model.jam_request = JamRequest{true, false, 0}; + return; + } if (model.view == View::song) { say(model, at_us, kStatusUs, "hold dice to clear"); return; diff --git a/firmware/src/app/controller.h b/firmware/src/app/controller.h index d176ecf..d7648d6 100644 --- a/firmware/src/app/controller.h +++ b/firmware/src/app/controller.h @@ -82,6 +82,9 @@ class Controller { void set_mute(Model& model, int pad, bool mute); void publish_params(const Model& model, AudioPath& audio); bool any_pad_held() const; + // Hold show (share view up) + a pad or dice is the jam send gesture (§11), not the + // pad's or dice's own meaning. + bool sending_gesture(const Model& model) const; bool following_ = false; // a wire owns the tempo this pass; the speed knob then shows `ext` (§11) // The tutorial waits for one gesture per step (§8.5); the rest is ignored. diff --git a/firmware/src/app/jam.cpp b/firmware/src/app/jam.cpp new file mode 100644 index 0000000..d35001f --- /dev/null +++ b/firmware/src/app/jam.cpp @@ -0,0 +1,70 @@ +#include "app/jam.h" + +#include + +#include "hal/hal.h" +#include "io/share.h" + +namespace app { + +namespace { + +// The pattern of a track — its steps and how they play — without the mix. A jam sends +// what an undo would move (D-035): steps, alternation and speed travel; level, tone, +// send, chance and the transient mute stay the receiver's (§11, T-114). +void copy_pattern(engine::Track& dst, const engine::Track& src) { + dst.alt = src.alt; + dst.speed = src.speed; + dst.step_count = src.step_count; + for (int i = 0; i < engine::kMaxStepsPerTrack; ++i) dst.steps[i] = src.steps[i]; +} + +} // namespace + +void Jam::step(Model& model, const engine::Kit& kit, uint64_t now_us, const uint8_t* bytes, int count) { + if (model.jam_request.pending) begin_send(model, kit, now_us); + for (int i = 0; i < count; ++i) { + io::Received msg; + if (in_.feed(bytes[i], kit, msg)) apply(model, msg, now_us); + } +} + +void Jam::begin_send(Model& model, const engine::Kit& kit, uint64_t now_us) { + const JamRequest req = model.jam_request; + model.jam_request.pending = false; + if (!hal::midi_port_open()) { // no wire in this build, and no cable a jack could feel + say(model.status, now_us, kStatusUs, "no jam link"); + return; + } + if (sending()) { // one message at a time, so a held-show roll across the pads cannot queue eight + say(model.status, now_us, kStatusUs, "still sending"); + return; + } + const engine::State& state = model.sections[model.current].state(); + out_len_ = req.track ? io::format_track(state, kit, req.pad, out_) : io::format_loop(state, kit, out_); + out_at_ = 0; + say(model.status, now_us, kStatusUs, req.track ? "sent a track" : "sent the loop"); +} + +void Jam::apply(Model& model, const io::Received& msg, uint64_t at_us) { + engine::State& live = model.sections[model.current].push_edit(); // one undoable level (§6.7) + const engine::State& in = msg.decoded.state; + if (msg.track) { + copy_pattern(live.tracks[msg.pad], in.tracks[msg.pad]); + say(model.status, at_us, kStatusUs, "got a track"); + } else { + for (int t = 0; t < engine::kTrackCount; ++t) copy_pattern(live.tracks[t], in.tracks[t]); + std::memcpy(live.lineage, in.lineage, sizeof live.lineage); // a whole loop keeps the sender's id (T-115, D-105) + say(model.status, at_us, kStatusUs, "got a loop"); + } +} + +void Jam::pump_out() { + while (sending()) { + const int took = hal::midi_send(out_ + out_at_, out_len_ - out_at_); + if (took <= 0) return; // the wire is full or absent: the rest waits for the next pass + out_at_ += took; + } +} + +} // namespace app diff --git a/firmware/src/app/jam.h b/firmware/src/app/jam.h new file mode 100644 index 0000000..caab60d --- /dev/null +++ b/firmware/src/app/jam.h @@ -0,0 +1,40 @@ +#pragma once + +#include + +#include "app/model.h" +#include "engine/kit.h" +#include "io/midi.h" + +// The jam link's app side (PRD §11, D-111), the counterpart to app/card.cpp. The +// show-held gesture records what to send in model.jam_request; this carries it out, +// exactly as the song view records a pick and the card carries it out. One message +// goes out at a time. A message that arrives lands as a single undoable edit on the +// section being edited (§6.7, D-003), moving no knob (D-035). +namespace app { + +class Jam { + public: + // From app::tick under hal::lock(): starts the send the gesture asked for, if any, + // and applies every message the incoming bytes complete. `bytes` are what + // hal::midi_read handed the main loop this pass; `count` is bounded by the caller. + void step(Model& model, const engine::Kit& kit, uint64_t now_us, const uint8_t* bytes, int count); + + // From app::tick outside the lock: puts the outgoing message on the wire as fast as + // hal::midi_send will take it, which on the device is one byte a UART slot so a clock + // pulse never waits long behind a pattern (D-114). What the wire refuses is offered + // again next pass, so the code arrives once, in order. + void pump_out(); + + private: + void begin_send(Model& model, const engine::Kit& kit, uint64_t now_us); + void apply(Model& model, const io::Received& msg, uint64_t at_us); + bool sending() const { return out_at_ < out_len_; } + + io::MidiPort in_; + uint8_t out_[io::kMessageCapacity]; + int out_len_ = 0; + int out_at_ = 0; +}; + +} // namespace app diff --git a/firmware/src/app/model.h b/firmware/src/app/model.h index 08ae5f8..1b931c5 100644 --- a/firmware/src/app/model.h +++ b/firmware/src/app/model.h @@ -39,6 +39,15 @@ struct Status { // is the only place that knows a slot did not load. void say(Status& status, uint64_t at_us, uint32_t duration_us, const char* text); +// The show-held gesture (§11, D-111): a pad sends its track, dice the whole loop. The +// controller records it here and app/jam.cpp carries it out, as a song pick is +// recorded and the card carries it out. +struct JamRequest { + bool pending = false; + bool track = false; // false = the whole loop + int pad = 0; // 0-7 when track +}; + struct Arrangement { uint8_t length; char letters[engine::kMaxArrangementLength]; // A–D, one cycle each (§6.8) @@ -73,6 +82,7 @@ struct Model { int pending_section; // kNoSection, or where `playing` moves at the next cycle boundary Arrangement arrangement; char song_lineage[engine::kLineageLength + 1]; // the id of the song this one was loaded from; empty otherwise + JamRequest jam_request; // the pending hold-show + pad/dice send (§11) bool song_mode; // stepping through the arrangement (§6.8) int song_position; // index of the letter playing bool song_start_pending; // song play from the top at the next cycle boundary diff --git a/spec/scenarios.md b/spec/scenarios.md index 25ef921..943a84f 100644 --- a/spec/scenarios.md +++ b/spec/scenarios.md @@ -1,6 +1,6 @@ # Acceptance scenarios -Every behaviour in the PRD has one row here, and every engine test names the ID it covers (PRD §12 rule 2). T-01–T-24 are PRD §13 unchanged. T-25 onward were added on 2026-09-02 for §6 behaviours that had no scenario, T-85 onward on 2026-09-03 for the §9 views and the lights, T-95 and T-96 the same day for the last two §8.2 gestures, T-97–T-99 for what the card keeps and T-100 and T-101 for the kit, and T-102 onward the same day for §7.6 and §11's clock, sync jack and jam link (T-104–T-109 following an external clock, T-111 and T-112 the jam link's SysEx); the PRD section each comes from is in brackets. +Every behaviour in the PRD has one row here, and every engine test names the ID it covers (PRD §12 rule 2). T-01–T-24 are PRD §13 unchanged. T-25 onward were added on 2026-09-02 for §6 behaviours that had no scenario, T-85 onward on 2026-09-03 for the §9 views and the lights, T-95 and T-96 the same day for the last two §8.2 gestures, T-97–T-99 for what the card keeps and T-100 and T-101 for the kit, and T-102 onward the same day for §7.6 and §11's clock, sync jack and jam link (T-104–T-109 following an external clock, T-111 and T-112 the jam link's SysEx, T-113–T-116 its gestures and arrivals); the PRD section each comes from is in brackets. Conventions: fractions are of one cycle; the default kit (lofi), C minor and 100 bpm are assumed unless stated; "tap ×n" means n taps on that pad starting from empty, following the kit's smart defaults (§6.6); per-track modifier strings are the share-code form from `spec/share-format.md` §3. IDs are never reused: retire a scenario by striking it through, not by deleting it. @@ -121,6 +121,11 @@ Conventions: fractions are of one cycle; the default kit (lofi), C minor and 100 | T-108 | A wire clocking far above the tempo range — MIDI spaced for ~400 bpm — is followed and played (§6.3, §11, T-84, D-117) | The followed beat length is clamped to the 16000-frame floor (180 bpm), never faster, so push_window's roll grid cannot reach a zero step and spin with interrupts off; the run completes and the loop plays at the ceiling rather than hanging. | | T-109 | While following, a tick arrives stamped a millisecond before the newest anchor (§11, D-117) | The stamp maps to a frame just behind the anchor's, not about 1e13 frames ahead: every frame-to-microsecond conversion subtracts as int64, so an older stamp gives a negative difference rather than an unsigned wrap. The loop keeps following at a sane tempo. | +| T-113 | Hold show until the share view opens, then press a pad, or dice, with show still down; then the same with no port; then a second gesture while one is still going out (§8.2, §9.3, §11, D-111) | A pad sends a track message for that pad and dice sends the whole loop; neither the pad sounds, mutes or adds a hit nor dice fills or clears, and the share view stays up so several can be sent in a row. The bytes on the wire decode back to the loop, a track on its pad. With no port the gesture is harmless and the row reads `no jam link`. A second gesture while a message is still going out is refused with `still sending`, since one goes at a time. | +| T-114 | A device receives a track message for the hat from a sender at 120 bpm in A minor, the hat a different pattern (§6.7, §11, D-003, D-035) | The hat's steps, alternation and speed become the sender's; its level, tone, send, chance and mute stay the receiver's, and so do bpm, filter, fx, chance, swing and key — the jam sends patterns, never knobs. It lands on the section being edited as one undoable edit, so one undo brings the receiver's hat back. | +| T-115 | The same device receives a whole-loop message (§10.2, §11, D-105) | All eight patterns arrive as one undoable load and every knob and the tempo stay where the receiver has them. The loop takes the sender's six-character id as its lineage, so the share view's footer can say what it is based on; a track message never does. | +| T-116 | Hold show and press dice while the wire refuses every byte, then let it free up (§11, D-114) | Nothing leaves while the wire refuses; what was refused is offered again once it frees, so the code arrives once, in order, whole, and decodes back to the loop. | + ## Watch in testing Design bets with a known fallback. Observe them in usability round 1 (PRD §14, phase 2); the fallback is written down so nobody re-derives it. diff --git a/tests/jam_test.cpp b/tests/jam_test.cpp new file mode 100644 index 0000000..12dbf23 --- /dev/null +++ b/tests/jam_test.cpp @@ -0,0 +1,169 @@ +// The jam link's gestures and arrivals (PRD §11, D-111, D-105): spec/scenarios.md +// T-113..T-116. app/ is one set of statics, so a send is checked by reading the bytes +// back off the fake wire and a receive by pushing a formatted message onto it; the +// two-device latency of T-19 is a bench measurement (T-118). +#include + +#include "app_support.h" +#include "engine/kits/lofi.h" +#include "io/midi.h" +#include "io/share.h" + +using namespace app_support; + +namespace { + +// Hold show until the share view opens, and leave show down for the send gesture. +void open_share_holding_show(World& w) { + w.button_down(hal::Button::show); + w.run_for(kSecond / 2); // past the hold threshold: the share view opens + REQUIRE(w.model().view == app::View::share); +} + +std::vector sent() { + const std::vector& v = hal_fake::midi_sent(); + return std::vector(v.begin(), v.end()); +} + +// The one message the bytes on the wire decode to, through a fresh receiver. +bool decode_sent(const std::vector& bytes, io::Received& out) { + io::MidiPort port; + bool got = false; + for (uint8_t b : bytes) + if (port.feed(b, app::kit(), out)) got = true; + return got; +} + +const char* kSenderLoop = "RT2:lofi:120:10:2:0:15:am:e10000-e1.0.0-e10108-e1-e1-e1-e1-e1"; + +} // namespace + +TEST_CASE("T-113 Hold show and press a pad or dice sends, and does not play the pad") { + World w; + hal_fake::set_midi_port_open(true); + w.tap(Pad::kick, 4); + w.tap(Pad::snare, 2); + const int snare_steps = engine::track_of(w.state(0), Pad::snare).step_count; + + SUBCASE("a pad sends its track and neither sounds nor adds a hit") { + open_share_holding_show(w); + w.tap(Pad::snare); // hold show is still down: this is a send, not a tap + w.run_for(kSecond / 10); + w.button_up(hal::Button::show); + + // The snare pad did not gain a step and did not mute; only a message went out. + CHECK(engine::track_of(w.state(0), Pad::snare).step_count == snare_steps); + io::Received got; + REQUIRE(decode_sent(sent(), got)); + CHECK(got.track); + CHECK(got.pad == engine::index_of(Pad::snare)); + CHECK(w.status() == "sent a track"); + } + + SUBCASE("dice sends the whole loop and neither fills nor clears") { + open_share_holding_show(w); + w.press(hal::Button::dice); // show still down: send the loop + w.run_for(kSecond / 10); + w.button_up(hal::Button::show); + io::Received got; + REQUIRE(decode_sent(sent(), got)); + CHECK_FALSE(got.track); + CHECK(w.status() == "sent the loop"); + } + + SUBCASE("with no port the gesture is harmless") { + hal_fake::set_midi_port_open(false); + open_share_holding_show(w); + w.tap(Pad::snare); + w.run_for(kSecond / 10); + CHECK(sent().empty()); + CHECK(w.status() == "no jam link"); + } + + SUBCASE("a second gesture while one is still going out is refused") { + hal_fake::choke_midi(true); // the first message cannot drain + open_share_holding_show(w); + w.tap(Pad::snare); + w.run_for(kSecond / 20); + w.tap(Pad::kick); // a second send while the first is stuck + w.run_for(kSecond / 20); + CHECK(w.status() == "still sending"); + } +} + +TEST_CASE("T-114 A received track lands as one undoable edit, patterns only") { + World w; + hal_fake::set_midi_port_open(true); + w.tap(Pad::hat, 1); // the receiver's own hat, one step + const engine::Track before = engine::track_of(w.state(0), Pad::hat); + const uint8_t receiver_bpm = w.state(0).bpm; // 100 + + // A sender whose hat is a different pattern (two steps, a split), at 120 bpm in A minor. + const engine::State sender = engine::decode(kSenderLoop, app::kit()).state; + uint8_t msg[io::kMessageCapacity]; + const int n = io::format_track(sender, app::kit(), engine::index_of(Pad::hat), msg); + hal_fake::push_midi(msg, n); + w.run_for(kSecond / 10); + + // The hat's pattern became the sender's; the receiver's tempo, key and the hat's own + // level stayed put (patterns travel, knobs and globals do not). + const engine::Track after = engine::track_of(w.state(0), Pad::hat); + CHECK(after.step_count == engine::track_of(sender, Pad::hat).step_count); + CHECK(after.step_count != before.step_count); + CHECK(after.level == before.level); + CHECK(w.state(0).bpm == receiver_bpm); // not the sender's 120 + CHECK(w.state(0).key.root == engine::make_state(app::kit()).key.root); // still C, not the sender's A + CHECK(w.status() == "got a track"); + + // It was a single undoable edit: one undo brings the receiver's hat back. + w.press(hal::Button::undo); + CHECK(engine::track_of(w.state(0), Pad::hat).step_count == before.step_count); +} + +TEST_CASE("T-115 A received whole loop brings every pattern and the sender's id, no knobs") { + World w; + hal_fake::set_midi_port_open(true); + w.tap(Pad::kick, 2); + const uint8_t receiver_bpm = w.state(0).bpm; + + const engine::State sender = engine::decode(kSenderLoop, app::kit()).state; + uint8_t msg[io::kMessageCapacity]; + const int n = io::format_loop(sender, app::kit(), msg); + hal_fake::push_midi(msg, n); + w.run_for(kSecond / 10); + + // Every track's pattern is the sender's; the tempo and key stay the receiver's. + for (int t = 0; t < engine::kTrackCount; ++t) { + CHECK(w.state(0).tracks[t].step_count == sender.tracks[t].step_count); + } + CHECK(w.state(0).bpm == receiver_bpm); + // The loop carries the sender's own id as its lineage, so the share view can say what + // it is based on (T-59, D-105); a track message never does. + const engine::SectionCode code = io::shared_code(sender, app::kit()); + const std::string text = code.text; + const std::string id = text.substr(text.find('~') + 1); + CHECK(std::string(w.state(0).lineage) == id); + CHECK(w.status() == "got a loop"); +} + +TEST_CASE("T-116 A wire that refuses bytes takes the whole message once, in order") { + World w; + hal_fake::set_midi_port_open(true); + w.tap(Pad::kick, 4); + hal_fake::choke_midi(true); // the wire takes nothing at first + + open_share_holding_show(w); + w.press(hal::Button::dice); // queue the whole loop + w.run_for(kSecond / 10); + CHECK(sent().empty()); // nothing left while the wire refused + + hal_fake::choke_midi(false); // the wire frees up + w.run_for(kSecond / 10); + w.button_up(hal::Button::show); + + // What was refused is offered again, so the code arrives once, in order, whole. + io::Received got; + REQUIRE(decode_sent(sent(), got)); + CHECK_FALSE(got.track); + CHECK(got.decoded.ok); +} From faffea48920c4c5fe749442abfd3121c3244e191 Mon Sep 17 00:00:00 2001 From: Deva Date: Fri, 4 Sep 2026 00:13:53 +0530 Subject: [PATCH 08/12] Drive the device's own jacks, still unverified until somebody puts a scope on them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hal/teensy/link_teensy.cpp replaces the no-port stubs: Serial1 at 31250 baud on pins 0/1, the four System Real Time bytes lifted where they arrive and handed to read_clock_in with a timestamp while the rest of the wire stays on midi_read (D-114); a rising-edge interrupt on pin 32 stamps the sync jack in its own interrupt; one pending out pulse per port that poll() emits at its deadline; and a 5 ms active-high pulse on pin 34. midi_send takes one byte and only when the UART has room, so it never spins — a spin with interrupts off is a hang. A byte is stamped when poll() reads it, and bytes buffered between two polls are spread back one byte time each, since MIDI delivers them 320 us apart; a per-byte receive interrupt is the bring-up upgrade if that jitter matters. The follower now guards a zero-or-negative interval so a pair the device stamps alike cannot halve the tempo EMA. pins.h names the sync pins, WIRING.md's rows go from "reserved" to what is driven (two MIDI jacks per D-111, the C-01/C-04 sync front end), and T-118 is the bring-up bench row. NONE of this has run on real silicon: the baud, the pins, the edge polarity, the pulse width and the stamp accuracy are all from datasheets and library docs. The host build and its 133 tests are unchanged, since no host code moved; the firmware links (EXTRAM 1536128). Co-Authored-By: Claude Opus 4.8 --- firmware/src/app/clock.cpp | 3 +- firmware/src/hal/hal.h | 3 +- firmware/src/hal/teensy/hal_teensy.cpp | 11 +- firmware/src/hal/teensy/link_teensy.cpp | 184 ++++++++++++++++++++++ firmware/src/hal/teensy/pins.h | 11 +- firmware/src/hal/teensy/teensy_internal.h | 2 + hardware/WIRING.md | 6 +- spec/scenarios.md | 4 +- 8 files changed, 207 insertions(+), 17 deletions(-) create mode 100644 firmware/src/hal/teensy/link_teensy.cpp diff --git a/firmware/src/app/clock.cpp b/firmware/src/app/clock.cpp index b062235..0c1c30c 100644 --- a/firmware/src/app/clock.cpp +++ b/firmware/src/app/clock.cpp @@ -129,7 +129,8 @@ void Clock::follow(const hal::ClockIn* pulses, int count, uint64_t now_us, Audio f.locked_ticks = 1; continue; } - const int64_t raw = t - f.last_us; // > 0: stamps are monotone per port + const int64_t raw = t - f.last_us; // per-port stamps are monotone, but the device may stamp two bytes alike + if (raw <= 0) continue; // same-instant duplicates carry no interval; folding a 0 would halve the EMA int ticks; if (f.per_tick_us == 0) { ticks = 1; diff --git a/firmware/src/hal/hal.h b/firmware/src/hal/hal.h index 7ad0e31..beac4db 100644 --- a/firmware/src/hal/hal.h +++ b/firmware/src/hal/hal.h @@ -44,7 +44,8 @@ struct InputEvent { // on is a settings row (§9.4), not something the HAL decides. enum class ClockPort : uint8_t { midi, sync }; constexpr int kClockPortCount = 2; -constexpr uint32_t kMidiByteUs = 320; // 31250 baud, ten bits a byte: 3125 bytes a second and no more +constexpr uint32_t kMidiBaud = 31250; // §7.6 MIDI +constexpr uint32_t kMidiByteUs = 320; // ten bits a byte at kMidiBaud: 3125 bytes a second and no more // One pulse of somebody's clock. `tick` is a MIDI clock byte or one edge on the // sync jack; the other three are MIDI's transport messages, which the sync wire has diff --git a/firmware/src/hal/teensy/hal_teensy.cpp b/firmware/src/hal/teensy/hal_teensy.cpp index 9b846f3..0b307b0 100644 --- a/firmware/src/hal/teensy/hal_teensy.cpp +++ b/firmware/src/hal/teensy/hal_teensy.cpp @@ -44,6 +44,7 @@ void init() { teensy::power_init(); teensy::input_init(); teensy::display_init(); + teensy::link_init(); } uint64_t now_us() { @@ -58,6 +59,7 @@ bool poll() { clock_base_.micros = now; interrupts(); teensy::input_read(); + teensy::link_poll(); return true; } @@ -74,15 +76,6 @@ void start_timer(uint32_t period_us, TimerCallback callback) { void lock() { noInterrupts(); } void unlock() { interrupts(); } -// The MIDI wire and the sync jack, still undriven: Serial1 and the pins WIRING.md -// reserves are wired up in link_teensy.cpp, which arrives with the commit that -// puts a scope on them. Until then the device says it has no port, which is the -// truth about this firmware and not about the hardware. -int read_clock_in(ClockIn*, int) { return 0; } -bool send_clock_out(ClockPort, ClockPulse, uint64_t) { return false; } -int midi_read(uint8_t*, int) { return 0; } -int midi_send(const uint8_t*, int) { return 0; } -bool midi_port_open() { return false; } void log(const char* line) { if (Serial) Serial.println(line); diff --git a/firmware/src/hal/teensy/link_teensy.cpp b/firmware/src/hal/teensy/link_teensy.cpp new file mode 100644 index 0000000..2b7a52f --- /dev/null +++ b/firmware/src/hal/teensy/link_teensy.cpp @@ -0,0 +1,184 @@ +// The MIDI wire and the sync jack on the Teensy (PRD §7.6, §11, D-111, D-114). NONE of +// this has run on real hardware (CLAUDE.md, Landmines): the baud, the pins, the edge +// polarity, the pulse width and the timestamp accuracy are all from datasheets and +// library docs, and the bring-up runbook (T-118) is what decides them. +// +// MIDI is one UART, Serial1: bytes in on pin 0, bytes out on pin 1. The four System +// Real Time bytes are lifted out where they arrive and handed to read_clock_in with a +// timestamp, because one of them may sit between any two bytes of any other message; the +// rest of the wire stays on midi_read for io/ to parse (D-114). The sync jack is a +// rising-edge interrupt in on pin 32 and a 5 ms active-high pulse out on pin 34 (C-01). +// +// Timestamps: a byte is stamped when poll() reads it, and several bytes buffered between +// two polls are spread back by one byte time each, since MIDI delivers them 320 µs apart +// — a stand-in for the per-byte receive interrupt a bring-up bench would add if the jitter +// matters. The sync edge is stamped in its own interrupt, which is exact. +#include + +#include + +#include "hal/hal.h" +#include "hal/teensy/pins.h" +#include "hal/teensy/teensy_internal.h" + +namespace { + +constexpr uint32_t kSyncPulseWidthUs = 5000; // C-01, unverified + +// A byte a status code names, or 0 if it names none this firmware follows. +bool real_time_pulse(uint8_t byte, hal::ClockPulse& pulse) { + switch (byte) { + case 0xF8: pulse = hal::ClockPulse::tick; return true; + case 0xFA: pulse = hal::ClockPulse::start; return true; + case 0xFB: pulse = hal::ClockPulse::resume; return true; + case 0xFC: pulse = hal::ClockPulse::stop; return true; + default: return false; + } +} + +uint8_t status_of(hal::ClockPulse pulse) { + switch (pulse) { + case hal::ClockPulse::tick: return 0xF8; + case hal::ClockPulse::start: return 0xFA; + case hal::ClockPulse::resume: return 0xFB; + case hal::ClockPulse::stop: return 0xFC; + } + return 0xF8; +} + +// One ring for the clock pulses of both ports, filled by poll() (MIDI) and the sync +// interrupt and drained by read_clock_in, all on one core. Access is bracketed by +// noInterrupts() so the sync interrupt cannot tear a push; the ring keeps one slot empty. +constexpr int kRing = 2 * hal::kClockInCapacity + 1; +hal::ClockIn clock_ring_[kRing]; +volatile int clock_head_ = 0; +volatile int clock_tail_ = 0; + +void push_clock(hal::ClockPort port, hal::ClockPulse pulse, uint64_t time_us) { + const int next = (clock_tail_ + 1) % kRing; + if (next == clock_head_) return; // full: drop the newest, as the per-port rings do (D-121) + clock_ring_[clock_tail_] = hal::ClockIn{port, pulse, time_us}; + clock_tail_ = next; +} + +// The rest of the wire: whole bytes for io/, one ring deep enough for a jam message and +// a little of the wire behind it (hal::kMidiInputCapacity). Producer and consumer are +// both the main loop, so no interrupt guard is needed. +uint8_t midi_ring_[hal::kMidiInputCapacity + 1]; +int midi_head_ = 0; +int midi_tail_ = 0; + +void push_midi(uint8_t byte) { + const int next = (midi_tail_ + 1) % (hal::kMidiInputCapacity + 1); + if (next == midi_head_) return; + midi_ring_[midi_tail_] = byte; + midi_tail_ = next; +} + +// One pulse each port may have armed for a future deadline; poll() emits it when due. +struct Pending { + bool armed; + hal::ClockPulse pulse; + uint64_t at_us; +}; +Pending midi_out_{false, hal::ClockPulse::tick, 0}; +Pending sync_out_{false, hal::ClockPulse::tick, 0}; +bool sync_high_ = false; +uint64_t sync_low_at_ = 0; + +void sync_isr() { push_clock(hal::ClockPort::sync, hal::ClockPulse::tick, hal::now_us()); } + +} // namespace + +namespace hal::teensy { + +void link_init() { + Serial1.begin(hal::kMidiBaud); + pinMode(hal::pins::kSyncIn, INPUT_PULLDOWN); // an empty jack idles low (C-04) + pinMode(hal::pins::kSyncOut, OUTPUT); + digitalWriteFast(hal::pins::kSyncOut, LOW); + attachInterrupt(digitalPinToInterrupt(hal::pins::kSyncIn), sync_isr, RISING); +} + +// From hal::poll(), every main-loop pass. +void link_poll() { + const uint64_t now = hal::now_us(); + + // Drain MIDI RX. The bytes waiting arrived one byte time apart, so spread their stamps + // back from now rather than stamping them all alike, which would feed the follower a + // zero interval. + int waiting = Serial1.available(); + for (int i = 0; waiting > 0 && i < waiting; ++i) { + const uint8_t byte = static_cast(Serial1.read()); + const uint64_t stamp = now - static_cast(waiting - 1 - i) * hal::kMidiByteUs; + hal::ClockPulse pulse; + if (real_time_pulse(byte, pulse)) { + push_clock(hal::ClockPort::midi, pulse, stamp); + } else { + push_midi(byte); + } + } + + // Emit a due out pulse. A deadline already past fires now, which is the past-deadline + // rule hal.h states; the signed difference is what makes "past" mean past. + if (midi_out_.armed && static_cast(now - midi_out_.at_us) >= 0) { + if (Serial1.availableForWrite() > 0) { + Serial1.write(status_of(midi_out_.pulse)); + midi_out_.armed = false; + } + } + if (sync_out_.armed && static_cast(now - sync_out_.at_us) >= 0) { + digitalWriteFast(hal::pins::kSyncOut, HIGH); + sync_high_ = true; + sync_low_at_ = now + kSyncPulseWidthUs; + sync_out_.armed = false; + } + if (sync_high_ && static_cast(now - sync_low_at_) >= 0) { + digitalWriteFast(hal::pins::kSyncOut, LOW); + sync_high_ = false; + } +} + +} // namespace hal::teensy + +namespace hal { + +int read_clock_in(ClockIn* out, int capacity) { + int count = 0; + noInterrupts(); + while (count < capacity && clock_head_ != clock_tail_) { + out[count++] = clock_ring_[clock_head_]; + clock_head_ = (clock_head_ + 1) % kRing; + } + interrupts(); + return count; +} + +bool send_clock_out(ClockPort port, ClockPulse pulse, uint64_t at_us) { + Pending& pending = port == ClockPort::midi ? midi_out_ : sync_out_; + if (pending.armed) return false; // still holding one: the caller offers it again next tick + pending = Pending{true, pulse, at_us}; + return true; +} + +int midi_read(uint8_t* out, int capacity) { + int count = 0; + while (count < capacity && midi_head_ != midi_tail_) { + out[count++] = midi_ring_[midi_head_]; + midi_head_ = (midi_head_ + 1) % (kMidiInputCapacity + 1); + } + return count; +} + +// One byte at a time, gated on the UART having room so it never blocks — a spin here +// would hang with interrupts off, since the caller runs the pump outside the lock but the +// clock's own send is on the timer (D-114). +int midi_send(const uint8_t* bytes, int count) { + if (count <= 0 || Serial1.availableForWrite() <= 0) return 0; + Serial1.write(bytes[0]); + return 1; +} + +bool midi_port_open() { return true; } + +} // namespace hal diff --git a/firmware/src/hal/teensy/pins.h b/firmware/src/hal/teensy/pins.h index abca70b..1c10484 100644 --- a/firmware/src/hal/teensy/pins.h +++ b/firmware/src/hal/teensy/pins.h @@ -48,8 +48,13 @@ constexpr EncoderPins kEncoders[5] = { constexpr uint8_t kBatterySense = 40; // A16 constexpr uint8_t kHeadphoneSense = 41; // A17 -// Reserved for io/ (not driven here): MIDI on Serial1 (0 RX, 1 TX); sync in 32, -// sync out 34. Routed to the audio shield's unused sockets and left alone: 6, 10, -// 11, 12, 13, 15. +// The jam link and sync (§7.6, §11, D-111, D-114, C-01, C-04). MIDI is one UART — +// Serial1, RX on 0 and TX on 1 — behind the H11L1M in and the resistor pair out +// (D-055), one physical jack each. The sync jack is a rising edge in on 32 (through +// the front end C-04 rules) and a 5 ms active-high pulse out on 34. Every number here +// is from a datasheet or a library doc and has never been on a board (T-118). +constexpr uint8_t kSyncIn = 32; +constexpr uint8_t kSyncOut = 34; +// Left alone, routed to the audio shield's unused sockets: 6, 10, 11, 12, 13, 15. } // namespace hal::pins diff --git a/firmware/src/hal/teensy/teensy_internal.h b/firmware/src/hal/teensy/teensy_internal.h index e5a6f14..be86dab 100644 --- a/firmware/src/hal/teensy/teensy_internal.h +++ b/firmware/src/hal/teensy/teensy_internal.h @@ -9,5 +9,7 @@ void input_init(); void input_read(); void storage_init(); void power_init(); +void link_init(); // the MIDI wire and the sync jack (§7.6, §11); unverified until bring-up +void link_poll(); // from hal::poll(): drains MIDI RX and emits due out pulses } // namespace hal::teensy diff --git a/hardware/WIRING.md b/hardware/WIRING.md index 68f3f07..4ab5e16 100644 --- a/hardware/WIRING.md +++ b/hardware/WIRING.md @@ -29,8 +29,10 @@ How the EVT breadboard unit (hardware/BOM.md, Table 1) connects to the Teensy 4. | Volume encoder A / B / push | 29 / 30 / 31 | PEC11R (D-054) | | no | | Battery sense | 40 (A16) | BAT+ through 100 kΩ, 100 kΩ to ground | 2:1 divider; a straight line 3.3–4.15 V → 0–100 % until the production gauge (D-072) | no | | Headphone detect | 41 (A17) | Headphone jack tip switch, with a pull-up | Read as an analog level (D-065); the shield's own jack has no switch, so this is the panel jack | no | -| MIDI in / out | 0 (RX1) / 1 (TX1) | H11L1M circuit / 33 Ω + 10 Ω | Reserved for io/; not driven | no | -| Sync in / out | 32 / 34 | Sync jack through a divider / a series resistor | Reserved for io/; not driven | no | +| MIDI in | 0 (RX1) | H11L1M circuit | Serial1 at 31250 baud; io/ lifts the real-time bytes (D-114) | no | +| MIDI out | 1 (TX1) | 33 Ω + 10 Ω from 3.3 V | Serial1 at 31250 baud; two jacks, so a jam is two cables (D-111) | no | +| Sync in | 32 | Series resistor + BAT54S clamp, internal pulldown (C-04) | Rising-edge interrupt; 2 PPQN, never driven while followed (C-01) | no | +| Sync out | 34 | Series resistor to the jack tip | A 5 ms active-high pulse per 1/8 cycle (C-01) | no | | microSD | built-in slot (SDIO) | SanDisk Ultra, FAT32 | `SD.begin(BUILTIN_SDCARD)` | no | | PSRAM, QSPI flash | bottom pads | ESP-PSRAM64H on the small pads, W25Q128JVSIQ on the large | Not used by the firmware yet | no | | Speaker amp shutdown | none | PAM8302 SD from the headphone jack's switch | Hardware mute (D-050); no GPIO | no | diff --git a/spec/scenarios.md b/spec/scenarios.md index 943a84f..ae75e98 100644 --- a/spec/scenarios.md +++ b/spec/scenarios.md @@ -1,6 +1,6 @@ # Acceptance scenarios -Every behaviour in the PRD has one row here, and every engine test names the ID it covers (PRD §12 rule 2). T-01–T-24 are PRD §13 unchanged. T-25 onward were added on 2026-09-02 for §6 behaviours that had no scenario, T-85 onward on 2026-09-03 for the §9 views and the lights, T-95 and T-96 the same day for the last two §8.2 gestures, T-97–T-99 for what the card keeps and T-100 and T-101 for the kit, and T-102 onward the same day for §7.6 and §11's clock, sync jack and jam link (T-104–T-109 following an external clock, T-111 and T-112 the jam link's SysEx, T-113–T-116 its gestures and arrivals); the PRD section each comes from is in brackets. +Every behaviour in the PRD has one row here, and every engine test names the ID it covers (PRD §12 rule 2). T-01–T-24 are PRD §13 unchanged. T-25 onward were added on 2026-09-02 for §6 behaviours that had no scenario, T-85 onward on 2026-09-03 for the §9 views and the lights, T-95 and T-96 the same day for the last two §8.2 gestures, T-97–T-99 for what the card keeps and T-100 and T-101 for the kit, and T-102 onward the same day for §7.6 and §11's clock, sync jack and jam link (T-104–T-109 following an external clock, T-111 and T-112 the jam link's SysEx, T-113–T-116 its gestures and arrivals, T-118 the EVT bring-up bench); the PRD section each comes from is in brackets. Conventions: fractions are of one cycle; the default kit (lofi), C minor and 100 bpm are assumed unless stated; "tap ×n" means n taps on that pad starting from empty, following the kit's smart defaults (§6.6); per-track modifier strings are the share-code form from `spec/share-format.md` §3. IDs are never reused: retire a scenario by striking it through, not by deleting it. @@ -126,6 +126,8 @@ Conventions: fractions are of one cycle; the default kit (lofi), C minor and 100 | T-115 | The same device receives a whole-loop message (§10.2, §11, D-105) | All eight patterns arrive as one undoable load and every knob and the tempo stay where the receiver has them. The loop takes the sender's six-character id as its lineage, so the share view's footer can say what it is based on; a track message never does. | | T-116 | Hold show and press dice while the wire refuses every byte, then let it free up (§11, D-114) | Nothing leaves while the wire refuses; what was refused is offered again once it frees, so the code arrives once, in order, whole, and decodes back to the loop. | +| T-118 | On the EVT unit: a Pocket Operator's sync out into Rota's sync jack and Rota's out into the PO on a plain stereo cable then a mono one; a second Rota on two MIDI cables; a scope on the sync pins (32, 34) and the MIDI pins (0, 1) throughout (§7.6, §11, T-19, T-20, D-111, D-114) | Each direction works with the cable a player already owns and neither cable damages anything: the PO's 2.6 V pulse crosses the input threshold through the front end a two-resistor divider would not have allowed (C-04), and Rota's 3.3 V pulse triggers the PO. Two Rotas on the MIDI cables hold their playheads within 3 ms (T-19) and both keep playing when a cable is pulled (T-20). Each rising edge sits within a bring-up-measured window of where the follower expects it; the worst main-loop gap with the display running is logged, since the timeouts and the arbitration are sized against it. Not provable on the host: the fake and the SDL link have no real UART, no baud, no edge and no cable. | + ## Watch in testing Design bets with a known fallback. Observe them in usability round 1 (PRD §14, phase 2); the fallback is written down so nobody re-derives it. From c7a35004dda2e7c857e8911d22c4d6c59b7fe220 Mon Sep 17 00:00:00 2001 From: Deva Date: Fri, 4 Sep 2026 00:17:03 +0530 Subject: [PATCH 09/12] Let two simulators share a cable, so the reference behaviour is a reference again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hal/sdl/link_sdl.cpp gives the simulator the jam link over a localhost UDP pair (D-124): start each of two ./build/simulator runs with ROTA_LINK=:, crossed, and each speaks the MIDI cable — the four System Real Time bytes and our SysEx, both ways, metered on send at 31250 baud so a transfer feels the ~79 ms a share code costs on the wire. With no ROTA_LINK there is no port and the simulator is exactly what it was. §12 rule 5 makes the simulator the reference behaviour, so it has to be able to rehearse a jam before the board exists; this is the least machinery that lets two of them do it. It models the MIDI cable only, not the Pocket Operator's sync jack — there is no PO on a desk — so a sync-out pulse is accepted and dropped and no sync pulse arrives. What loopback UDP cannot rehearse — a framing error, a baud mismatch, cable noise — is what io/'s recovery path handles, and that path is reached through the test fake instead (T-112). The 133 host tests are unchanged: they link the fake, not SDL. The simulator builds and prints whether a link is open and on which ports. Co-Authored-By: Claude Opus 4.8 --- DECISIONS.md | 1 + firmware/src/hal/sdl/hal_sdl.cpp | 11 +- firmware/src/hal/sdl/link_sdl.cpp | 214 ++++++++++++++++++++++++++++ firmware/src/hal/sdl/sdl_internal.h | 5 + host/main.cpp | 1 + 5 files changed, 223 insertions(+), 9 deletions(-) create mode 100644 firmware/src/hal/sdl/link_sdl.cpp diff --git a/DECISIONS.md b/DECISIONS.md index 513d391..c110a01 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -127,3 +127,4 @@ What was decided, why, and when to look again. One row per decision. IDs are seq | D-121 | A port whose own 64-deep ring (hal::kClockInCapacity) saturates in one drain pass is marked unusable for that pass and reacquired; arbitration skips an unusable port. This is separate from the followed-and-driven question (D-113). | 2026-09-03. The per-port rings exist so a shorted or chattering sync jack cannot crowd out the MIDI clock it is meant to lose to (D-114); the follower acts on that by excluding the dead port while it keeps following and driving the live one, rather than letting garbage poison the estimate. The guard is a ring-saturation test, not an edge-rate one, so sub-saturation chatter can still nudge the estimate — the estimator's own smoothing absorbs it, and a physical edge-rate ceiling is a bring-up refinement. | If bench chatter below saturation proves to need an edge-rate limit. | | D-122 | The tempo adopted when a follow ends is applied by the controller through the existing tap-tempo path (`edited_sections` then `sections[t].state().bpm`), triggered by `Clock::take_lost_bpm()`; `follow()` and the controller's application run inside one main-loop lock so no timer `begin_beat` sees an inconsistent bpm. The wire never writes `engine::State::bpm` itself. | 2026-09-03. D-112 writes the last tempo 'into the sections a knob would reach', which is exactly what tap tempo (T-95) does, so reuse it rather than add a second bpm-writing path; keeping the engine write in the controller and out of the Clock keeps the wire from ever touching bpm, and the single-lock ordering makes the quiet-loss handover seamless. | — | | D-123 | The jam gestures and what an arrival does (§11, D-111): holding show (the share view up) and pressing a pad sends that pad's track, dice the whole loop; under the gesture the pad neither sounds, mutes nor adds a hit and dice neither fills nor clears, and the share view stays up so several can be sent in a row. The controller records the gesture in `model.jam_request` and `app/jam.cpp` carries it out, as the song view records a pick and the card carries it out; one message goes out at a time (`still sending` otherwise) and a build with no port says `no jam link`. A received message lands as one undoable edit on the section being edited (`Section::push_edit`): a track copies one pad's steps, alternation and speed, a loop all eight, and a whole loop also takes the sender's id as its lineage; level, tone, send, chance, mute, bpm, filter, fx, chance, swing and key stay the receiver's. | 2026-09-03. The gesture is the song-pick shape (D-030) one layer over: the controller only notes that the player asked, and app/ does the I/O, so nothing that sends touches the wire from inside the input grammar. Patterns-only is D-035 read across the cable — a jam partner moving a knob is worse than an undo moving one, because the player is holding it — and it makes a track and a loop one idea at two scales, reversing exactly as an undo does. The lineage on a whole loop is what lets the receiver's share view say `based on` the sender (D-105); a track is a fragment, not a loop, so it carries none. One message at a time keeps a held-show roll across the pads from queuing eight transfers of 79 ms each. Rejected: auditioning the pad as it sends (it would play a sound the player did not mean); bringing the sender's mix or tempo (D-035, and a follower's bpm is not what it plays); queuing sends (a second gesture is likelier a correction than a batch). | If testers want to send several parts at once, or want the sender's mix to travel (then a third message type, under a new envelope version). | +| D-124 | The simulator carries the jam link over a localhost UDP pair (§11, §12 rule 5): `ROTA_LINK=:`, crossed between two `./build/simulator` runs, and each speaks the MIDI cable — the four real-time bytes and the SysEx, both ways, metered on send at 31250 baud. With no `ROTA_LINK` there is no port and the simulator behaves exactly as before. It models the MIDI cable only, not the Pocket Operator sync jack: `send_clock_out` on the sync port is accepted and dropped and no sync pulse arrives. | 2026-09-03. §12 rule 5 makes the simulator the reference behaviour, so it has to be able to rehearse a jam before the board exists, and only a link that moves bytes both ways at the wire's speed lets two simulators do that. UDP on loopback is the least machinery that carries a byte stream between two processes, and the baud meter makes a transfer feel the ~79 ms a share code costs on the real wire. What loopback cannot rehearse — a framing error, a baud mismatch, cable noise — is exactly what io/'s recovery path handles, so that path is reached through the test fake instead (T-112). Sync is left out because a desk machine has no Pocket Operator and edges are not a byte stream; the MIDI jam is what two Rotas actually do. Rejected: a shared file or FIFO (no addressing, one reader); TCP (a stream needs framing the wire does not have); modelling sync too (machinery for a device that is not there). | If two-machine testing over a real network is wanted, or the host build gains real OS MIDI ports. | diff --git a/firmware/src/hal/sdl/hal_sdl.cpp b/firmware/src/hal/sdl/hal_sdl.cpp index d97637a..5d33a36 100644 --- a/firmware/src/hal/sdl/hal_sdl.cpp +++ b/firmware/src/hal/sdl/hal_sdl.cpp @@ -207,7 +207,7 @@ void init() { for (Led& led : button_leds_) led = Led{0, 0, 0}; std::printf("hal/sdl: window %dx%d (screen %dx%d, scale %d)\n", kScreenWidth * kWindowScale, kLogicalHeight * kWindowScale, kScreenWidth, kScreenHeight, kWindowScale); - std::fflush(stdout); + std::fflush(stdout); sdl::link_init(); } uint64_t now_us() { @@ -224,6 +224,7 @@ bool poll() { on_event(event); while (SDL_PollEvent(&event) != 0) on_event(event); } + sdl::link_poll(); // drain the jam link and emit any due clock pulse (§11) return !quit_requested; } @@ -291,14 +292,6 @@ int battery_percent() { bool headphones_inserted() { return false; } -// No MIDI DIN on a desk machine. Two simulators share a cable over UDP behind -// ROTA_LINK, which link_sdl.cpp adds; with no link there is no port, and the -// simulator behaves exactly as it did before there was a wire at all. -int read_clock_in(ClockIn*, int) { return 0; } -bool send_clock_out(ClockPort, ClockPulse, uint64_t) { return false; } -int midi_read(uint8_t*, int) { return 0; } -int midi_send(const uint8_t*, int) { return 0; } -bool midi_port_open() { return false; } void log(const char* line) { std::puts(line); diff --git a/firmware/src/hal/sdl/link_sdl.cpp b/firmware/src/hal/sdl/link_sdl.cpp new file mode 100644 index 0000000..6edf27a --- /dev/null +++ b/firmware/src/hal/sdl/link_sdl.cpp @@ -0,0 +1,214 @@ +// The jam link for the simulator (PRD §12 rule 5, §11, D-114). A desk machine has no +// MIDI DIN, so two simulators share the MIDI cable over a UDP pair on localhost: start +// each with ROTA_LINK=:, crossed, and one can follow the other's +// clock and pass a loop across, so the reference platform can rehearse a jam before the +// board exists. With no ROTA_LINK there is no port and the simulator behaves exactly as +// it did before there was a wire. +// +// This models the MIDI cable only: the four System Real Time bytes and our SysEx, both +// ways, metered at 31250 baud so a transfer feels the ~79 ms a share code costs on the +// wire. It does not model the Pocket Operator's sync jack — there is no PO on a desk — so +// send_clock_out on the sync port is accepted and dropped, and no sync pulse ever arrives. +// What loopback UDP cannot rehearse is a framing error, a baud mismatch or cable noise, so +// io/'s recovery path is reached only through the test fake (tests/hal_fake.cpp). +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "hal/hal.h" +#include "hal/sdl/sdl_internal.h" + +namespace { + +bool open_ = false; +int sock_ = -1; +sockaddr_in peer_{}; +uint64_t next_send_us_ = 0; // the wire is free again at this microsecond (31250 baud) + +bool real_time_pulse(uint8_t byte, hal::ClockPulse& pulse) { + switch (byte) { + case 0xF8: pulse = hal::ClockPulse::tick; return true; + case 0xFA: pulse = hal::ClockPulse::start; return true; + case 0xFB: pulse = hal::ClockPulse::resume; return true; + case 0xFC: pulse = hal::ClockPulse::stop; return true; + default: return false; + } +} + +uint8_t status_of(hal::ClockPulse pulse) { + switch (pulse) { + case hal::ClockPulse::tick: return 0xF8; + case hal::ClockPulse::start: return 0xFA; + case hal::ClockPulse::resume: return 0xFB; + case hal::ClockPulse::stop: return 0xFC; + } + return 0xF8; +} + +// The clock pulses read this pass and the wire bytes for io/, each a ring one deep enough +// for a jam message and a little of the wire behind it; the newest is dropped when full. +constexpr int kClockRing = 2 * hal::kClockInCapacity + 1; +hal::ClockIn clock_ring_[kClockRing]; +int clock_head_ = 0; +int clock_tail_ = 0; + +void push_clock(hal::ClockPort port, hal::ClockPulse pulse, uint64_t time_us) { + const int next = (clock_tail_ + 1) % kClockRing; + if (next == clock_head_) return; + clock_ring_[clock_tail_] = hal::ClockIn{port, pulse, time_us}; + clock_tail_ = next; +} + +constexpr int kMidiRing = hal::kMidiInputCapacity + 1; +uint8_t midi_ring_[kMidiRing]; +int midi_head_ = 0; +int midi_tail_ = 0; + +void push_midi(uint8_t byte) { + const int next = (midi_tail_ + 1) % kMidiRing; + if (next == midi_head_) return; + midi_ring_[midi_tail_] = byte; + midi_tail_ = next; +} + +struct Pending { + bool armed; + hal::ClockPulse pulse; + uint64_t at_us; +}; +Pending midi_out_{false, hal::ClockPulse::tick, 0}; + +void send_byte(uint8_t byte) { + sendto(sock_, &byte, 1, 0, reinterpret_cast(&peer_), sizeof peer_); +} + +// The wire carries one byte every byte time; a caller offering faster is refused and +// tries again, so a clock pulse and a pattern share the 31250 baud a real cable has. +bool wire_free(uint64_t now) { return now >= next_send_us_; } +void wire_took(uint64_t now) { next_send_us_ = now + hal::kMidiByteUs; } + +int parse_ports(const char* spec, int& bind_port, int& peer_port) { + return std::sscanf(spec, "%d:%d", &bind_port, &peer_port) == 2 ? 0 : -1; +} + +} // namespace + +namespace hal::sdl { + +void link_init() { + const char* spec = std::getenv("ROTA_LINK"); + if (spec == nullptr) { + std::puts("hal/sdl: no jam link (set ROTA_LINK=: to link two simulators)"); + return; + } + int bind_port = 0; + int peer_port = 0; + if (parse_ports(spec, bind_port, peer_port) != 0) { + std::printf("hal/sdl: ROTA_LINK=%s is not :; no jam link\n", spec); + return; + } + sock_ = socket(AF_INET, SOCK_DGRAM, 0); + if (sock_ < 0) { + std::puts("hal/sdl: could not open the jam link socket"); + return; + } + sockaddr_in me{}; + me.sin_family = AF_INET; + me.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + me.sin_port = htons(static_cast(bind_port)); + if (bind(sock_, reinterpret_cast(&me), sizeof me) != 0) { + std::printf("hal/sdl: could not bind the jam link to port %d\n", bind_port); + close(sock_); + sock_ = -1; + return; + } + fcntl(sock_, F_SETFL, O_NONBLOCK); // read never blocks the main loop + peer_.sin_family = AF_INET; + peer_.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + peer_.sin_port = htons(static_cast(peer_port)); + open_ = true; + std::printf("hal/sdl: jam link on 127.0.0.1:%d -> :%d\n", bind_port, peer_port); + std::fflush(stdout); +} + +// From hal::poll(), every main-loop pass. +void link_poll() { + if (!open_) return; + const uint64_t now = hal::now_us(); + + // Read everything waiting into one buffer, then spread the stamps back one byte time + // each — the bytes arrived 320 us apart, and stamping them alike would feed the + // follower a zero interval. + uint8_t buffer[hal::kMidiInputCapacity]; + int count = 0; + while (count < static_cast(sizeof buffer)) { + uint8_t byte = 0; + const ssize_t got = recv(sock_, &byte, 1, 0); + if (got <= 0) break; + buffer[count++] = byte; + } + for (int i = 0; i < count; ++i) { + const uint64_t stamp = now - static_cast(count - 1 - i) * hal::kMidiByteUs; + hal::ClockPulse pulse; + if (real_time_pulse(buffer[i], pulse)) { + push_clock(hal::ClockPort::midi, pulse, stamp); + } else { + push_midi(buffer[i]); + } + } + + if (midi_out_.armed && static_cast(now - midi_out_.at_us) >= 0 && wire_free(now)) { + send_byte(status_of(midi_out_.pulse)); + wire_took(now); + midi_out_.armed = false; + } +} + +} // namespace hal::sdl + +namespace hal { + +int read_clock_in(ClockIn* out, int capacity) { + int count = 0; + while (count < capacity && clock_head_ != clock_tail_) { + out[count++] = clock_ring_[clock_head_]; + clock_head_ = (clock_head_ + 1) % kClockRing; + } + return count; +} + +bool send_clock_out(ClockPort port, ClockPulse pulse, uint64_t at_us) { + if (port == ClockPort::sync) return true; // no PO on a desk: accept and drop the sync pulse + if (!open_) return true; // no link: the clock thinks it sent, and nothing goes out + if (midi_out_.armed) return false; + midi_out_ = Pending{true, pulse, at_us}; + return true; +} + +int midi_read(uint8_t* out, int capacity) { + int count = 0; + while (count < capacity && midi_head_ != midi_tail_) { + out[count++] = midi_ring_[midi_head_]; + midi_head_ = (midi_head_ + 1) % kMidiRing; + } + return count; +} + +int midi_send(const uint8_t* bytes, int count) { + if (!open_ || count <= 0) return 0; + const uint64_t now = now_us(); + if (!wire_free(now)) return 0; // the wire is busy; the caller offers again next pass + send_byte(bytes[0]); + wire_took(now); + return 1; +} + +bool midi_port_open() { return open_; } + +} // namespace hal diff --git a/firmware/src/hal/sdl/sdl_internal.h b/firmware/src/hal/sdl/sdl_internal.h index 90bf742..841dfd1 100644 --- a/firmware/src/hal/sdl/sdl_internal.h +++ b/firmware/src/hal/sdl/sdl_internal.h @@ -8,4 +8,9 @@ namespace hal::sdl { // Writes the framebuffer as a PNG under ROTA_SCREENS_DIR, numbered per run (D-100). void save_screenshot(const uint16_t* framebuffer); +// The jam link over UDP (§11, §12 rule 5): ROTA_LINK=: links two +// simulators; with none there is no port. link_poll runs from hal::poll(). +void link_init(); +void link_poll(); + } // namespace hal::sdl diff --git a/host/main.cpp b/host/main.cpp index 453ea88..624ddf1 100644 --- a/host/main.cpp +++ b/host/main.cpp @@ -12,6 +12,7 @@ int main() { app::init(); std::puts("simulator: 1-8 pads; s w k z d e space = split swap skip undo dice show play; a b c shift+d sections;"); std::puts("simulator: up/down or the wheel turn the selected knob, left/right pick it, - = volume; Escape quits"); + std::puts("simulator: link two with ROTA_LINK=: (crossed) to jam over a UDP cable"); std::fflush(stdout); while (hal::poll()) app::tick(); return 0; From a8e4705b81f1363b44e8d7f539340a248e4182b6 Mon Sep 17 00:00:00 2001 From: Deva Date: Fri, 4 Sep 2026 02:25:25 +0530 Subject: [PATCH 10/12] Answer the review: fold the wire first, and close two concurrency races MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit's round on PR #12, the findings that held up: - app::tick folds the wire (Clock::follow) before the input loop, so a play release and the pulses that establish following in the same pass see this pass's clock, not the last one's — the count-in (D-112) then triggers. - io::MidiPort::feed rejects a control byte in the payload (a NUL would end the decoder's C string and pass a valid prefix through as if the whole frame were good). The header's own version and pad bytes are control values, so the guard is only past the header. T-112 gains the embedded-NUL case. - link_teensy and link_sdl make the out-pulse's `armed` flag std::atomic with release/acquire: send_clock_out publishes from the timer (a 2 ms interrupt on the device, a separate thread on the host) and link_poll consumes on the main loop, so a deadline no longer tears and a pulse is not missed. On the Teensy the shared clock ring's main-loop push is bracketed by noInterrupts(), which the sync interrupt also respects. - link_sdl's ROTA_LINK parse requires exactly two ports in 1..65535 with no trailing text, so an out-of-range value can no longer wrap through htons. - T-114 and T-115 assert the whole contract now: the full pattern copied, the mix (level, tone, send, chance, mute) preserved, no other track touched, and one undo restoring the prior loop. D-123 names the two chance controls apart. Declined, with reasons: gating sync out on a separate flag from midi_port_open (no build has MIDI absent but the sync jack live, and arming the sync recorder in the timer during unrelated playback would risk T-79's zero-allocation guarantee); validating format_track's pad (its caller is the gesture, always 0-7, and the wire boundary is already checked in feed — CLAUDE.md trusts internal callers). One finding was already fixed (the Teensy stubs, in faffea4). 133 tests / 11,349 assertions pass; firmware links; the simulator builds. Co-Authored-By: Claude Opus 4.8 --- DECISIONS.md | 2 +- firmware/src/app/app.cpp | 4 ++- firmware/src/hal/sdl/link_sdl.cpp | 29 +++++++++++----- firmware/src/hal/teensy/link_teensy.cpp | 37 ++++++++++++-------- firmware/src/io/midi.cpp | 8 +++++ tests/jam_test.cpp | 46 ++++++++++++++++++++----- tests/midi_test.cpp | 7 ++++ 7 files changed, 99 insertions(+), 34 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index c110a01..076a52b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -126,5 +126,5 @@ What was decided, why, and when to look again. One row per decision. IDs are seq | D-120 | Cycle lock (counting MIDI pulses modulo 96 from the leader's Start) is MIDI-only. The sync jack carries no Start or transport (D-114), so following sync beat-locks to the pulse grid and acquires on the next sync beat, with no cross-cycle bar alignment; MIDI followed before any Start has been seen also degrades to beat-lock. | 2026-09-03. D-112's cycle reference is the MIDI Start byte and 96 pulses, which the sync wire simply does not carry, so promising bar alignment on sync would be a guess; beat-locking to what the wire actually carries is honest and gives a short acquire wait. T-19's sub-3 ms figure is a Rota-to-Rota MIDI link, so sync's coarser alignment does not violate it. | If a sync source is found that also carries a bar marker Rota could use. | | D-121 | A port whose own 64-deep ring (hal::kClockInCapacity) saturates in one drain pass is marked unusable for that pass and reacquired; arbitration skips an unusable port. This is separate from the followed-and-driven question (D-113). | 2026-09-03. The per-port rings exist so a shorted or chattering sync jack cannot crowd out the MIDI clock it is meant to lose to (D-114); the follower acts on that by excluding the dead port while it keeps following and driving the live one, rather than letting garbage poison the estimate. The guard is a ring-saturation test, not an edge-rate one, so sub-saturation chatter can still nudge the estimate — the estimator's own smoothing absorbs it, and a physical edge-rate ceiling is a bring-up refinement. | If bench chatter below saturation proves to need an edge-rate limit. | | D-122 | The tempo adopted when a follow ends is applied by the controller through the existing tap-tempo path (`edited_sections` then `sections[t].state().bpm`), triggered by `Clock::take_lost_bpm()`; `follow()` and the controller's application run inside one main-loop lock so no timer `begin_beat` sees an inconsistent bpm. The wire never writes `engine::State::bpm` itself. | 2026-09-03. D-112 writes the last tempo 'into the sections a knob would reach', which is exactly what tap tempo (T-95) does, so reuse it rather than add a second bpm-writing path; keeping the engine write in the controller and out of the Clock keeps the wire from ever touching bpm, and the single-lock ordering makes the quiet-loss handover seamless. | — | -| D-123 | The jam gestures and what an arrival does (§11, D-111): holding show (the share view up) and pressing a pad sends that pad's track, dice the whole loop; under the gesture the pad neither sounds, mutes nor adds a hit and dice neither fills nor clears, and the share view stays up so several can be sent in a row. The controller records the gesture in `model.jam_request` and `app/jam.cpp` carries it out, as the song view records a pick and the card carries it out; one message goes out at a time (`still sending` otherwise) and a build with no port says `no jam link`. A received message lands as one undoable edit on the section being edited (`Section::push_edit`): a track copies one pad's steps, alternation and speed, a loop all eight, and a whole loop also takes the sender's id as its lineage; level, tone, send, chance, mute, bpm, filter, fx, chance, swing and key stay the receiver's. | 2026-09-03. The gesture is the song-pick shape (D-030) one layer over: the controller only notes that the player asked, and app/ does the I/O, so nothing that sends touches the wire from inside the input grammar. Patterns-only is D-035 read across the cable — a jam partner moving a knob is worse than an undo moving one, because the player is holding it — and it makes a track and a loop one idea at two scales, reversing exactly as an undo does. The lineage on a whole loop is what lets the receiver's share view say `based on` the sender (D-105); a track is a fragment, not a loop, so it carries none. One message at a time keeps a held-show roll across the pads from queuing eight transfers of 79 ms each. Rejected: auditioning the pad as it sends (it would play a sound the player did not mean); bringing the sender's mix or tempo (D-035, and a follower's bpm is not what it plays); queuing sends (a second gesture is likelier a correction than a batch). | If testers want to send several parts at once, or want the sender's mix to travel (then a third message type, under a new envelope version). | +| D-123 | The jam gestures and what an arrival does (§11, D-111): holding show (the share view up) and pressing a pad sends that pad's track, dice the whole loop; under the gesture the pad neither sounds, mutes nor adds a hit and dice neither fills nor clears, and the share view stays up so several can be sent in a row. The controller records the gesture in `model.jam_request` and `app/jam.cpp` carries it out, as the song view records a pick and the card carries it out; one message goes out at a time (`still sending` otherwise) and a build with no port says `no jam link`. A received message lands as one undoable edit on the section being edited (`Section::push_edit`): a track copies one pad's steps, alternation and speed, a loop all eight, and a whole loop also takes the sender's id as its lineage; the track's level, tone, send and chance and its mute stay the receiver's, and so do the section's bpm, filter, fx, chance, swing and key. | 2026-09-03. The gesture is the song-pick shape (D-030) one layer over: the controller only notes that the player asked, and app/ does the I/O, so nothing that sends touches the wire from inside the input grammar. Patterns-only is D-035 read across the cable — a jam partner moving a knob is worse than an undo moving one, because the player is holding it — and it makes a track and a loop one idea at two scales, reversing exactly as an undo does. The lineage on a whole loop is what lets the receiver's share view say `based on` the sender (D-105); a track is a fragment, not a loop, so it carries none. One message at a time keeps a held-show roll across the pads from queuing eight transfers of 79 ms each. Rejected: auditioning the pad as it sends (it would play a sound the player did not mean); bringing the sender's mix or tempo (D-035, and a follower's bpm is not what it plays); queuing sends (a second gesture is likelier a correction than a batch). | If testers want to send several parts at once, or want the sender's mix to travel (then a third message type, under a new envelope version). | | D-124 | The simulator carries the jam link over a localhost UDP pair (§11, §12 rule 5): `ROTA_LINK=:`, crossed between two `./build/simulator` runs, and each speaks the MIDI cable — the four real-time bytes and the SysEx, both ways, metered on send at 31250 baud. With no `ROTA_LINK` there is no port and the simulator behaves exactly as before. It models the MIDI cable only, not the Pocket Operator sync jack: `send_clock_out` on the sync port is accepted and dropped and no sync pulse arrives. | 2026-09-03. §12 rule 5 makes the simulator the reference behaviour, so it has to be able to rehearse a jam before the board exists, and only a link that moves bytes both ways at the wire's speed lets two simulators do that. UDP on loopback is the least machinery that carries a byte stream between two processes, and the baud meter makes a transfer feel the ~79 ms a share code costs on the real wire. What loopback cannot rehearse — a framing error, a baud mismatch, cable noise — is exactly what io/'s recovery path handles, so that path is reached through the test fake instead (T-112). Sync is left out because a desk machine has no Pocket Operator and edges are not a byte stream; the MIDI jam is what two Rotas actually do. Rejected: a shared file or FIFO (no addressing, one reader); TCP (a stream needs framing the wire does not have); modelling sync too (machinery for a device that is not there). | If two-machine testing over a real network is wanted, or the host build gains real OS MIDI ports. | diff --git a/firmware/src/app/app.cpp b/firmware/src/app/app.cpp index ab9b854..14dd93b 100644 --- a/firmware/src/app/app.cpp +++ b/firmware/src/app/app.cpp @@ -379,8 +379,10 @@ void tick() { uint8_t midi_in[kMidiReadBatch]; const int midi_count = hal::midi_read(midi_in, kMidiReadBatch); hal::lock(); + // Fold the wire in first: a play release and the pulses that establish following can + // land in one pass, and start() must see this pass's clock, not the last one's (D-112). + the_clock.follow(pulses, pulse_count, now_us, audio); // also latches the audio anchor for (int i = 0; i < count; ++i) controller.handle(events[i], the_model, scheduler, audio); - the_clock.follow(pulses, pulse_count, now_us, audio); // latch the anchor, fold the wire in before the controller reads it controller.tick(now_us, the_model, scheduler, audio); the_jam.step(the_model, the_kit, now_us, midi_in, midi_count); // send the gesture, apply an arrival the_clock.set_ports(the_model.settings.midi_clock_out, the_model.settings.sync_out); diff --git a/firmware/src/hal/sdl/link_sdl.cpp b/firmware/src/hal/sdl/link_sdl.cpp index 6edf27a..d4e3384 100644 --- a/firmware/src/hal/sdl/link_sdl.cpp +++ b/firmware/src/hal/sdl/link_sdl.cpp @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -77,12 +78,14 @@ void push_midi(uint8_t byte) { midi_tail_ = next; } +// send_clock_out runs on the SDL timer thread and link_poll on the main thread, so the +// armed flag is atomic and its release/acquire publishes the deadline before the flag. struct Pending { - bool armed; - hal::ClockPulse pulse; - uint64_t at_us; + std::atomic armed{false}; + hal::ClockPulse pulse{hal::ClockPulse::tick}; + uint64_t at_us{0}; }; -Pending midi_out_{false, hal::ClockPulse::tick, 0}; +Pending midi_out_; void send_byte(uint8_t byte) { sendto(sock_, &byte, 1, 0, reinterpret_cast(&peer_), sizeof peer_); @@ -93,8 +96,13 @@ void send_byte(uint8_t byte) { bool wire_free(uint64_t now) { return now >= next_send_us_; } void wire_took(uint64_t now) { next_send_us_ = now + hal::kMidiByteUs; } +// Requires exactly : with nothing trailing and both in 1..65535, so a stray +// character or an out-of-range port (which htons would silently wrap) is rejected. +bool valid_port(int port) { return port >= 1 && port <= 65535; } int parse_ports(const char* spec, int& bind_port, int& peer_port) { - return std::sscanf(spec, "%d:%d", &bind_port, &peer_port) == 2 ? 0 : -1; + char extra = 0; + const int fields = std::sscanf(spec, "%d:%d%c", &bind_port, &peer_port, &extra); + return fields == 2 && valid_port(bind_port) && valid_port(peer_port) ? 0 : -1; } } // namespace @@ -163,10 +171,11 @@ void link_poll() { } } - if (midi_out_.armed && static_cast(now - midi_out_.at_us) >= 0 && wire_free(now)) { + if (midi_out_.armed.load(std::memory_order_acquire) && static_cast(now - midi_out_.at_us) >= 0 && + wire_free(now)) { send_byte(status_of(midi_out_.pulse)); wire_took(now); - midi_out_.armed = false; + midi_out_.armed.store(false, std::memory_order_release); } } @@ -186,8 +195,10 @@ int read_clock_in(ClockIn* out, int capacity) { bool send_clock_out(ClockPort port, ClockPulse pulse, uint64_t at_us) { if (port == ClockPort::sync) return true; // no PO on a desk: accept and drop the sync pulse if (!open_) return true; // no link: the clock thinks it sent, and nothing goes out - if (midi_out_.armed) return false; - midi_out_ = Pending{true, pulse, at_us}; + if (midi_out_.armed.load(std::memory_order_acquire)) return false; + midi_out_.pulse = pulse; + midi_out_.at_us = at_us; + midi_out_.armed.store(true, std::memory_order_release); return true; } diff --git a/firmware/src/hal/teensy/link_teensy.cpp b/firmware/src/hal/teensy/link_teensy.cpp index 2b7a52f..81f2fdc 100644 --- a/firmware/src/hal/teensy/link_teensy.cpp +++ b/firmware/src/hal/teensy/link_teensy.cpp @@ -23,7 +23,9 @@ namespace { -constexpr uint32_t kSyncPulseWidthUs = 5000; // C-01, unverified +// C-01: 2 PPQN, tip, active-high, 5 ms wide, from Teenage Engineering's PO-16 guide as +// the BOM cites it (hardware/BOM.md, "sync in/out"); UNVERIFIED until a scope confirms it (T-118). +constexpr uint32_t kSyncPulseWidthUs = 5000; // A byte a status code names, or 0 if it names none this firmware follows. bool real_time_pulse(uint8_t byte, hal::ClockPulse& pulse) { @@ -75,14 +77,17 @@ void push_midi(uint8_t byte) { midi_tail_ = next; } -// One pulse each port may have armed for a future deadline; poll() emits it when due. +// One pulse each port may have armed for a future deadline. `armed` is atomic because +// send_clock_out publishes from the 2 ms timer and link_poll consumes on the main loop; +// the pulse and deadline are written before armed is set and read after it is seen, so +// the release/acquire pair keeps a deadline from tearing and a pulse from being missed. struct Pending { - bool armed; - hal::ClockPulse pulse; - uint64_t at_us; + std::atomic armed{false}; + hal::ClockPulse pulse{hal::ClockPulse::tick}; + uint64_t at_us{0}; }; -Pending midi_out_{false, hal::ClockPulse::tick, 0}; -Pending sync_out_{false, hal::ClockPulse::tick, 0}; +Pending midi_out_; +Pending sync_out_; bool sync_high_ = false; uint64_t sync_low_at_ = 0; @@ -113,25 +118,27 @@ void link_poll() { const uint64_t stamp = now - static_cast(waiting - 1 - i) * hal::kMidiByteUs; hal::ClockPulse pulse; if (real_time_pulse(byte, pulse)) { + noInterrupts(); // the sync interrupt writes the same ring; keep it out for this push push_clock(hal::ClockPort::midi, pulse, stamp); + interrupts(); } else { - push_midi(byte); + push_midi(byte); // the midi ring has one producer, this loop, so it needs no guard } } // Emit a due out pulse. A deadline already past fires now, which is the past-deadline // rule hal.h states; the signed difference is what makes "past" mean past. - if (midi_out_.armed && static_cast(now - midi_out_.at_us) >= 0) { + if (midi_out_.armed.load(std::memory_order_acquire) && static_cast(now - midi_out_.at_us) >= 0) { if (Serial1.availableForWrite() > 0) { Serial1.write(status_of(midi_out_.pulse)); - midi_out_.armed = false; + midi_out_.armed.store(false, std::memory_order_release); } } - if (sync_out_.armed && static_cast(now - sync_out_.at_us) >= 0) { + if (sync_out_.armed.load(std::memory_order_acquire) && static_cast(now - sync_out_.at_us) >= 0) { digitalWriteFast(hal::pins::kSyncOut, HIGH); sync_high_ = true; sync_low_at_ = now + kSyncPulseWidthUs; - sync_out_.armed = false; + sync_out_.armed.store(false, std::memory_order_release); } if (sync_high_ && static_cast(now - sync_low_at_) >= 0) { digitalWriteFast(hal::pins::kSyncOut, LOW); @@ -156,8 +163,10 @@ int read_clock_in(ClockIn* out, int capacity) { bool send_clock_out(ClockPort port, ClockPulse pulse, uint64_t at_us) { Pending& pending = port == ClockPort::midi ? midi_out_ : sync_out_; - if (pending.armed) return false; // still holding one: the caller offers it again next tick - pending = Pending{true, pulse, at_us}; + if (pending.armed.load(std::memory_order_acquire)) return false; // still holding one; offered again next tick + pending.pulse = pulse; + pending.at_us = at_us; + pending.armed.store(true, std::memory_order_release); // publish after the fields are written return true; } diff --git a/firmware/src/io/midi.cpp b/firmware/src/io/midi.cpp index 630db2c..a317f58 100644 --- a/firmware/src/io/midi.cpp +++ b/firmware/src/io/midi.cpp @@ -48,6 +48,14 @@ bool MidiPort::feed(uint8_t byte, const engine::Kit& kit, Received& out) { if (byte != kSysExEnd) { if (byte >= 0x80) return false; // a status byte in the middle is not payload; skip it, stay in the message + // The header bytes (version 01, a pad 00-07) are control values; only the payload, + // which is share-code text, may never hold one. A NUL there would truncate the + // decoder's C string and pass a valid prefix through, so a control byte in the + // payload means the frame is corrupt: abandon it and re-sync on the next F0 (§10). + if (length_ >= kHeaderBytes && byte < 0x20) { + in_message_ = false; + return false; + } if (length_ < static_cast(sizeof buffer_) - 1) { buffer_[length_++] = byte; // room kept for the NUL the decoder reads } else { diff --git a/tests/jam_test.cpp b/tests/jam_test.cpp index 12dbf23..e24604d 100644 --- a/tests/jam_test.cpp +++ b/tests/jam_test.cpp @@ -36,6 +36,21 @@ bool decode_sent(const std::vector& bytes, io::Received& out) { const char* kSenderLoop = "RT2:lofi:120:10:2:0:15:am:e10000-e1.0.0-e10108-e1-e1-e1-e1-e1"; +// The pattern a jam copies: steps, alternation and speed (§11, T-114). Compared as the +// share-code spelling of the steps plus alt and speed, so a mismatch reads plainly. +bool same_pattern(const engine::Track& a, const engine::Track& b) { + if (a.step_count != b.step_count || a.alt != b.alt || a.speed != b.speed) return false; + for (int i = 0; i < a.step_count; ++i) { + if (a.steps[i].hits != b.steps[i].hits || a.steps[i].note != b.steps[i].note) return false; + } + return true; +} + +// The mix a jam never touches: level, tone, send, chance and the mute (§11, T-114). +bool same_mix(const engine::Track& a, const engine::Track& b) { + return a.level == b.level && a.tone == b.tone && a.send == b.send && a.chance == b.chance && a.mute == b.mute; +} + } // namespace TEST_CASE("T-113 Hold show and press a pad or dice sends, and does not play the pad") { @@ -96,6 +111,7 @@ TEST_CASE("T-114 A received track lands as one undoable edit, patterns only") { hal_fake::set_midi_port_open(true); w.tap(Pad::hat, 1); // the receiver's own hat, one step const engine::Track before = engine::track_of(w.state(0), Pad::hat); + const engine::Track before_kick = engine::track_of(w.state(0), Pad::kick); const uint8_t receiver_bpm = w.state(0).bpm; // 100 // A sender whose hat is a different pattern (two steps, a split), at 120 bpm in A minor. @@ -105,19 +121,22 @@ TEST_CASE("T-114 A received track lands as one undoable edit, patterns only") { hal_fake::push_midi(msg, n); w.run_for(kSecond / 10); - // The hat's pattern became the sender's; the receiver's tempo, key and the hat's own - // level stayed put (patterns travel, knobs and globals do not). + // The hat's whole pattern became the sender's — steps, alternation and speed — while + // its mix and every global stayed the receiver's (patterns travel, knobs do not). const engine::Track after = engine::track_of(w.state(0), Pad::hat); - CHECK(after.step_count == engine::track_of(sender, Pad::hat).step_count); - CHECK(after.step_count != before.step_count); - CHECK(after.level == before.level); + CHECK(same_pattern(after, engine::track_of(sender, Pad::hat))); + CHECK_FALSE(same_pattern(after, before)); // it really changed + CHECK(same_mix(after, before)); // level, tone, send, chance and mute untouched CHECK(w.state(0).bpm == receiver_bpm); // not the sender's 120 CHECK(w.state(0).key.root == engine::make_state(app::kit()).key.root); // still C, not the sender's A + // No other track moved: a track message touches only its pad. + CHECK(same_pattern(engine::track_of(w.state(0), Pad::kick), before_kick)); CHECK(w.status() == "got a track"); - // It was a single undoable edit: one undo brings the receiver's hat back. + // It was a single undoable edit: one undo brings the receiver's hat back whole. w.press(hal::Button::undo); - CHECK(engine::track_of(w.state(0), Pad::hat).step_count == before.step_count); + CHECK(same_pattern(engine::track_of(w.state(0), Pad::hat), before)); + CHECK(same_mix(engine::track_of(w.state(0), Pad::hat), before)); } TEST_CASE("T-115 A received whole loop brings every pattern and the sender's id, no knobs") { @@ -126,15 +145,20 @@ TEST_CASE("T-115 A received whole loop brings every pattern and the sender's id, w.tap(Pad::kick, 2); const uint8_t receiver_bpm = w.state(0).bpm; + engine::Track before[engine::kTrackCount]; + for (int t = 0; t < engine::kTrackCount; ++t) before[t] = w.state(0).tracks[t]; + const engine::State sender = engine::decode(kSenderLoop, app::kit()).state; uint8_t msg[io::kMessageCapacity]; const int n = io::format_loop(sender, app::kit(), msg); hal_fake::push_midi(msg, n); w.run_for(kSecond / 10); - // Every track's pattern is the sender's; the tempo and key stay the receiver's. + // Every track's whole pattern is the sender's, its mix the receiver's; the tempo and + // key stay the receiver's. for (int t = 0; t < engine::kTrackCount; ++t) { - CHECK(w.state(0).tracks[t].step_count == sender.tracks[t].step_count); + CHECK(same_pattern(w.state(0).tracks[t], sender.tracks[t])); + CHECK(same_mix(w.state(0).tracks[t], before[t])); } CHECK(w.state(0).bpm == receiver_bpm); // The loop carries the sender's own id as its lineage, so the share view can say what @@ -144,6 +168,10 @@ TEST_CASE("T-115 A received whole loop brings every pattern and the sender's id, const std::string id = text.substr(text.find('~') + 1); CHECK(std::string(w.state(0).lineage) == id); CHECK(w.status() == "got a loop"); + + // One undo brings the prior whole loop back: it arrived as a single edit. + w.press(hal::Button::undo); + for (int t = 0; t < engine::kTrackCount; ++t) CHECK(same_pattern(w.state(0).tracks[t], before[t])); } TEST_CASE("T-116 A wire that refuses bytes takes the whole message once, in order") { diff --git a/tests/midi_test.cpp b/tests/midi_test.cpp index 5e08a23..fc55a34 100644 --- a/tests/midi_test.cpp +++ b/tests/midi_test.cpp @@ -172,6 +172,13 @@ TEST_CASE("T-112 A malformed message changes nothing and the parser re-syncs on truncated.pop_back(); // no F7 refused(truncated); // the next F0 in loop_bytes discards the truncated one } + SUBCASE("a control byte in the payload is refused, not truncated to its valid prefix") { + std::vector b = loop_bytes(state); // a complete valid message + b.insert(b.end() - 1, 'X'); // trailing garbage, before F7 + b.insert(b.end() - 2, 0x00); // a NUL after the complete code: it would end the decoder's C string + refused(b); // without the guard, decode would accept the prefix and ignore the X + } + SUBCASE("a status byte scattered through a good message does not break it") { std::vector b = loop_bytes(state); b.insert(b.begin() + io::kHeaderBytes + 3, 0xF8); // a clock byte the HAL would normally have lifted From 3368327deaeb8d8d28bcd0c2858e59c87ee31641 Mon Sep 17 00:00:00 2001 From: Deva Date: Fri, 4 Sep 2026 02:40:06 +0530 Subject: [PATCH 11/12] Answer the review's second round: T-115 checks the whole loop undo restores CodeRabbit noted T-115's post-undo assertion proved only the patterns came back, so a broken undo that scrambled the mix, tempo, key or lineage would have passed. It now compares the full pre-receive state after undo: every track's pattern and mix, the bpm, the key and the lineage. Declined, with a reason posted on the PR: prefixing the new T-112 subcase with its scenario id would break the local convention, since its sibling subcases are prose under the id-named TEST_CASE. The re-raised sync-gating and format_track findings stand on the reasons already given. 133 tests / 11,360 assertions pass. Co-Authored-By: Claude Opus 4.8 --- tests/jam_test.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/jam_test.cpp b/tests/jam_test.cpp index e24604d..3c2be99 100644 --- a/tests/jam_test.cpp +++ b/tests/jam_test.cpp @@ -147,6 +147,8 @@ TEST_CASE("T-115 A received whole loop brings every pattern and the sender's id, engine::Track before[engine::kTrackCount]; for (int t = 0; t < engine::kTrackCount; ++t) before[t] = w.state(0).tracks[t]; + const uint8_t before_root = w.state(0).key.root; + const std::string before_lineage = w.state(0).lineage; const engine::State sender = engine::decode(kSenderLoop, app::kit()).state; uint8_t msg[io::kMessageCapacity]; @@ -169,9 +171,16 @@ TEST_CASE("T-115 A received whole loop brings every pattern and the sender's id, CHECK(std::string(w.state(0).lineage) == id); CHECK(w.status() == "got a loop"); - // One undo brings the prior whole loop back: it arrived as a single edit. + // One undo brings the prior whole loop back — every track's pattern and mix, the + // tempo, the key and the lineage — because it arrived as a single edit. w.press(hal::Button::undo); - for (int t = 0; t < engine::kTrackCount; ++t) CHECK(same_pattern(w.state(0).tracks[t], before[t])); + for (int t = 0; t < engine::kTrackCount; ++t) { + CHECK(same_pattern(w.state(0).tracks[t], before[t])); + CHECK(same_mix(w.state(0).tracks[t], before[t])); + } + CHECK(w.state(0).bpm == receiver_bpm); + CHECK(w.state(0).key.root == before_root); + CHECK(std::string(w.state(0).lineage) == before_lineage); } TEST_CASE("T-116 A wire that refuses bytes takes the whole message once, in order") { From 70f4a0640b7e43551be9fbccfe45edb7438944f3 Mon Sep 17 00:00:00 2001 From: Deva Date: Fri, 4 Sep 2026 02:47:16 +0530 Subject: [PATCH 12/12] Pace the Teensy MIDI send, so a clock byte never waits behind a pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit found that midi_send wrote a payload byte whenever the UART had any room, so the caller's pump filled the tens-of-bytes TX buffer in one pass and a clock pulse armed by send_clock_out would queue milliseconds deep behind it — breaking D-114's promise that a pulse leaves within one byte time. It now holds the wire for a byte time between payload bytes (as link_sdl already meters its UDP stream), so at most one payload byte is in flight and a clock byte waits at most 320 us. availableForWrite still gates it, so the write never blocks. Firmware links; 133 tests / 11,360 assertions pass. Co-Authored-By: Claude Opus 4.8 --- firmware/src/hal/teensy/link_teensy.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/firmware/src/hal/teensy/link_teensy.cpp b/firmware/src/hal/teensy/link_teensy.cpp index 81f2fdc..ad6fa5a 100644 --- a/firmware/src/hal/teensy/link_teensy.cpp +++ b/firmware/src/hal/teensy/link_teensy.cpp @@ -90,6 +90,7 @@ Pending midi_out_; Pending sync_out_; bool sync_high_ = false; uint64_t sync_low_at_ = 0; +uint64_t next_midi_send_us_ = 0; // a payload byte may leave only once the last one has (D-114) void sync_isr() { push_clock(hal::ClockPort::sync, hal::ClockPulse::tick, hal::now_us()); } @@ -179,12 +180,17 @@ int midi_read(uint8_t* out, int capacity) { return count; } -// One byte at a time, gated on the UART having room so it never blocks — a spin here -// would hang with interrupts off, since the caller runs the pump outside the lock but the -// clock's own send is on the timer (D-114). +// One byte at a time, paced so a clock byte never waits behind more than one payload +// byte (D-114). Without the pace, the caller's pump would fill the UART's tens-of-bytes +// TX buffer in one pass and a clock pulse would queue milliseconds deep behind it; with +// it, at most one payload byte is in flight, so a pulse armed by send_clock_out leaves +// within one byte time. availableForWrite gates it too, so the write never blocks — a +// spin would hang with interrupts off. int midi_send(const uint8_t* bytes, int count) { - if (count <= 0 || Serial1.availableForWrite() <= 0) return 0; + const uint64_t now = hal::now_us(); + if (count <= 0 || now < next_midi_send_us_ || Serial1.availableForWrite() <= 0) return 0; Serial1.write(bytes[0]); + next_midi_send_us_ = now + hal::kMidiByteUs; // hold the wire for the byte to leave return 1; }