diff --git a/KitchenSync/main/ks_tick.c b/KitchenSync/main/ks_tick.c index 9e39c18..c4f6765 100644 --- a/KitchenSync/main/ks_tick.c +++ b/KitchenSync/main/ks_tick.c @@ -139,11 +139,14 @@ KsTickPlan ks_tick_step(KsTickState* st, const KsTickInputs* in) { st->tr[o].playing = false; break; case TL_RESTART: - // Unreachable today: the P4 posts no TL_INTENT_REALIGN (only the - // ESP-025 bench rig does). Supporting it here needs a plan-level - // change -- a restart is TWO bytes (0xFC then 0xFA) in one tick, - // and plan.transport[] holds exactly one action per output. Left - // as an explicit no-op rather than silently degrading to Stop. + // ESP-026: realign — stop THEN start, on the bar line. The plan + // carries it as one RESTART action; ks_tick_out_bytes expands it to + // 0xFC then 0xFA (both before the downbeat 0xF8 — the ESP-038 + // transport-before-clock invariant). Still playing afterward, so a + // later session edge does not re-emit (mirror TL_START's tr sync). + plan.transport[o] = TRANSPORT_RESTART; + st->tr[o].primed = true; + st->tr[o].playing = true; break; case TL_NONE: default: @@ -201,6 +204,10 @@ int ks_tick_out_bytes(const KsTickPlan* p, int out, uint8_t* buf, int cap) { * this lives in one tested place instead of in glue loop order. */ if (p->transport[out] == TRANSPORT_START && n < cap) buf[n++] = 0xFA; else if (p->transport[out] == TRANSPORT_STOP && n < cap) buf[n++] = 0xFC; + else if (p->transport[out] == TRANSPORT_RESTART) { // realign: stop THEN start, one tick + if (n < cap) buf[n++] = 0xFC; + if (n < cap) buf[n++] = 0xFA; + } for (int i = 0; i < p->pulses[out] && n < cap; i++) buf[n++] = 0xF8; return n; } diff --git a/X32Link/transport.h b/X32Link/transport.h index a9eb9c5..b1fac90 100644 --- a/X32Link/transport.h +++ b/X32Link/transport.h @@ -11,6 +11,7 @@ typedef enum { TRANSPORT_NONE = 0, TRANSPORT_START, // Link went stopped -> playing (emit 0xFA) TRANSPORT_STOP, // Link went playing -> stopped (emit 0xFC) + TRANSPORT_RESTART, // realign on the bar line: stop THEN start (emit 0xFC then 0xFA) } TransportAction; typedef struct { diff --git a/tasks/ESP-026.md b/tasks/ESP-026.md index f4ff262..e1ebab0 100644 --- a/tasks/ESP-026.md +++ b/tasks/ESP-026.md @@ -163,7 +163,8 @@ Board differences go behind **Kconfig**, not `#define` comment-swaps — which a repointed; all 56 host suites still pass - [ ] Analyzer check on the S3 and the bench ESP32: **transport precedes clock** on the downbeat tick (the ESP-023 regression cannot come back) -- [ ] `TL_RESTART` (realign) works on the P4, or is explicitly deferred with a reason +- [x] `TL_RESTART` (realign) works on the P4 — `TRANSPORT_RESTART` carries stop-then-start + through the plan; `ks_tick_out_bytes` emits `0xFC` then `0xFA` before the clock (host-tested) - [ ] The AGENTS.md firmware map and the `X32Link.ino` banner updated to match the new layout ## Cross-references (2026-07-15) diff --git a/test/test_ks_tick.c b/test/test_ks_tick.c index 247f4d3..5234ce1 100644 --- a/test/test_ks_tick.c +++ b/test/test_ks_tick.c @@ -39,6 +39,23 @@ void test_start_byte_precedes_first_clock(void) { TEST_ASSERT_LESS_THAN_INT(f8, fa); // START strictly before first CLOCK } +// ESP-038 for realign: the restart pair (0xFC then 0xFA) must both precede the downbeat +// 0xF8 — same tick-late invariant as START, now with two transport bytes. +void test_restart_pair_precedes_first_clock(void) { + KsTickPlan p; memset(&p, 0, sizeof p); + p.transport[0] = TRANSPORT_RESTART; + p.pulses[0] = 3; + uint8_t buf[8]; + int n = ks_tick_out_bytes(&p, 0, buf, sizeof buf); + int fc = idx_of(buf, n, 0xFC); + int fa = idx_of(buf, n, 0xFA); + int f8 = idx_of(buf, n, 0xF8); + TEST_ASSERT_EQUAL_INT(5, n); // FC + FA + 3 clocks + TEST_ASSERT_LESS_THAN_INT(f8, fc); // STOP before first CLOCK + TEST_ASSERT_LESS_THAN_INT(f8, fa); // START before first CLOCK + TEST_ASSERT_LESS_THAN_INT(fa, fc); // and STOP strictly before START +} + void test_none_emits_only_clocks(void) { KsTickPlan p; memset(&p, 0, sizeof p); p.transport[0] = TRANSPORT_NONE; @@ -59,6 +76,18 @@ void test_stop_emits_fc(void) { TEST_ASSERT_EQUAL_HEX8(0xFC, buf[0]); } +// ESP-026: a realign (TL_RESTART) is a stop-then-start on the bar line — 0xFC THEN +// 0xFA, in that order, as one tick's transport edge. Was a no-op on the P4 writer. +void test_restart_emits_fc_then_fa(void) { + KsTickPlan p; memset(&p, 0, sizeof p); + p.transport[0] = TRANSPORT_RESTART; + uint8_t buf[8]; + int n = ks_tick_out_bytes(&p, 0, buf, sizeof buf); + TEST_ASSERT_EQUAL_INT(2, n); + TEST_ASSERT_EQUAL_HEX8(0xFC, buf[0]); + TEST_ASSERT_EQUAL_HEX8(0xFA, buf[1]); +} + void test_empty_plan_emits_nothing(void) { KsTickPlan p; memset(&p, 0, sizeof p); // NONE + 0 pulses uint8_t buf[8]; @@ -304,6 +333,18 @@ void test_stop_is_immediate_per_output(void) { TEST_ASSERT_EQUAL_INT(TRANSPORT_STOP, ks_tick_step(&st, &b).transport[0]); } +// ESP-026: through the full tick, a realign while running + on the bar line drives +// TRANSPORT_RESTART on that output. Was a silent no-op — transport_launch produced +// TL_RESTART but ks_tick dropped it on the floor, so the writer emitted nothing. +void test_realign_drives_restart_through_the_tick(void) { + KsTickInputs a = mk(0, 0, true, true); + a.launch[0] = TL_INTENT_PLAY; + ks_tick_step(&st, &a); // output 0 running, on the grid + KsTickInputs b = mk((int64_t)(4 * MPB), 0, true, true); // beats = 4.0 — a bar line + b.launch[0] = TL_INTENT_REALIGN; + TEST_ASSERT_EQUAL_INT(TRANSPORT_RESTART, ks_tick_step(&st, &b).transport[0]); +} + // A disabled output never emits transport, whatever it is asked for. void test_disabled_output_emits_no_transport(void) { cfg.clock[0].enable = 0; @@ -490,6 +531,7 @@ int main(void) { RUN_TEST(test_manual_owns_transport_when_session_silent); RUN_TEST(test_launch_tracks_without_usb); RUN_TEST(test_transport_is_per_output); + RUN_TEST(test_realign_drives_restart_through_the_tick); RUN_TEST(test_play_mid_bar_fires_on_the_downbeat); RUN_TEST(test_stop_is_immediate_per_output); RUN_TEST(test_disabled_output_emits_no_transport); @@ -514,6 +556,8 @@ int main(void) { RUN_TEST(test_start_byte_precedes_first_clock); RUN_TEST(test_none_emits_only_clocks); RUN_TEST(test_stop_emits_fc); + RUN_TEST(test_restart_emits_fc_then_fa); + RUN_TEST(test_restart_pair_precedes_first_clock); RUN_TEST(test_empty_plan_emits_nothing); RUN_TEST(test_cap_never_overflows); return UNITY_END();