Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion esp32/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ Notes:
| `link_listener.*` + `link_protocol.*` | Link adapter; `link_protocol.c` is our own ~100-line gossip parser (the vendored `lib/link/` SDK is **not** used at runtime) |
| `link_measurement.{h,c}` + `link_measurement_io.cpp` | Link measurement (ping/pong) client; `link_measurement.c` is pure TLV build/parse + median/offset math (host-tested), `link_measurement_io.cpp` is the thin WiFiUDP unicast glue — pinger-only, no PingResponder |
| `midi_clock.*` · `midi_bpm.*` · `midi_bpm_calc.*` | USB-MIDI adapter; `midi_bpm_calc` is the pure, host-tested BPM math (symlinked from `X32MidiClock/`). `midi_clock` also owns the shared USBMIDI endpoint + `midi_clock_send_f8()` for clock OUT |
| `midi_clock_out.{h,c}` + `midi_clock_out_io.cpp` | LNK-027 Link→USB-MIDI clock OUT; `midi_clock_out.c` is the pure, host-tested 24-PPQN tick scheduler (quantizes `tempo_source_beats_now()` to `0xF8` ticks), `midi_clock_out_io.cpp` is the 1ms FreeRTOS task + TinyUSB writes |
| `clock_ticker.{h,c}` | LNK-027/028 shared pure tick engine: quantizes `tempo_source_beats_now()` to N PPQN pulses (phase-locked, re-primes on re-origin) + a `BarReset` tracker that fires once per bar boundary (analog reset pulse). Host-tested |
| `midi_clock_out.{h,c}` + `midi_clock_out_io.cpp` | LNK-027 Link→USB-MIDI clock OUT; `midi_clock_out.{h,c}` is a thin 24-PPQN adapter over `clock_ticker`, `midi_clock_out_io.cpp` is the 1ms FreeRTOS task + TinyUSB writes |
| `bpm_tracker.*` | change/threshold detection |
| `bar.{h,c}` | ARC-003: a bar is `quantum` beats — `bar_beats()` / `bar_ms()`; one place owns beats-per-bar so non-4/4 `quantum` scales refresh/resend (was a hardcoded `4 *`) |
| `osc_out.*` / `osc_sender.*` | OSC packet build / UDP send to the mixer |
Expand Down
60 changes: 60 additions & 0 deletions esp32/X32Link/clock_ticker.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Shared pure clock-tick scheduler — see clock_ticker.h.
#include "clock_ticker.h"
#include <math.h>

void clock_ticker_reset(ClockTicker* s) {
s->last_tick = 0;
s->primed = false;
}

int clock_ticker_ticks_due(ClockTicker* s, double beats_now, int ppqn, int max_burst) {
if (ppqn <= 0) return 0;
if (beats_now < 0.0) beats_now = 0.0;
int64_t tick = (int64_t)floor(beats_now * (double)ppqn);

if (!s->primed) { // first observation: align the grid, no backlog
s->primed = true;
s->last_tick = tick;
return 0;
}

int64_t due = tick - s->last_tick;
if (due <= 0) return 0; // backward or same 1/ppqn-beat slot

if (max_burst > 0 && due > (int64_t)max_burst) {
// Big forward jump (tempo re-origin / long stall): realign to the new
// position instead of flooding the output with a catch-up burst.
s->last_tick = tick;
return 0;
}

s->last_tick = tick;
return (int)due;
}

void bar_reset_reset(BarReset* s) {
s->last_bar = 0;
s->primed = false;
}

bool bar_reset_due(BarReset* s, double beats_now, double quantum) {
if (quantum <= 0.0) return false;
if (beats_now < 0.0) beats_now = 0.0;
int64_t bar = (int64_t)floor(beats_now / quantum);

if (!s->primed) {
s->primed = true;
s->last_bar = bar;
return false;
}

int64_t delta = bar - s->last_bar;
if (delta == 1) { // clean crossing into the next bar
s->last_bar = bar;
return true;
}
if (delta != 0) { // backward or multi-bar jump (re-origin): realign,
s->last_bar = bar; // don't fire a false downbeat
}
return false;
}
53 changes: 53 additions & 0 deletions esp32/X32Link/clock_ticker.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#pragma once
// Shared, pure clock-tick scheduler for tempo fan-out (LNK-027 USB-MIDI clock
// out at 24 PPQN; LNK-028 analog Eurorack sync at a configurable PPQN). It
// quantizes a *monotonic beat position* (from the Link phase pipeline) to N
// pulses per beat and reports how many pulses are due since the last call —
// phase-locked (first pulse on phase-zero), re-priming on a large jump
// (re-origin / stall) instead of flooding. A companion BarReset tracker fires
// once per bar boundary, for the analog "reset" trigger. No Arduino/hardware
// dependency — host-tested in test/test_clock_ticker.c. Glue owns the timer/RMT
// and the actual edge emission.
#include <stdint.h>
#include <stdbool.h>

#ifdef __cplusplus
extern "C" {
#endif

typedef struct {
int64_t last_tick; // last emitted 1/ppqn-beat grid index
bool primed; // false until the first beats_now aligns the grid
} ClockTicker;

// Clear state: the next ticks_due() call re-primes the grid (emits nothing).
void clock_ticker_reset(ClockTicker* s);

// Advance to beats_now and return how many pulses to emit now, at `ppqn` pulses
// per beat:
// - first call after a reset primes the grid and returns 0 (no backlog dump);
// - a backward or same-slot move returns 0;
// - a forward move of N pulses returns N, UNLESS N exceeds max_burst (a tempo
// re-origin or a long stall), in which case it re-primes and returns 0 — the
// caller keeps phase alignment rather than flooding the output.
// beats_now < 0 is treated as 0 (a "not valid yet" sentinel from the glue).
// ppqn <= 0 is a no-op that returns 0 without touching state.
int clock_ticker_ticks_due(ClockTicker* s, double beats_now, int ppqn, int max_burst);

typedef struct {
int64_t last_bar; // last observed bar index = floor(beats / quantum)
bool primed;
} BarReset;

void bar_reset_reset(BarReset* s);

// True exactly once when beats_now crosses forward into the next bar (bar =
// floor(beats / quantum)). First call after a reset primes and returns false. A
// same-bar read returns false. A backward move or a forward jump of more than one
// bar (a re-origin) re-primes and returns false — better to miss a reset than to
// fire a false downbeat. quantum <= 0 returns false.
bool bar_reset_due(BarReset* s, double beats_now, double quantum);

#ifdef __cplusplus
}
#endif
29 changes: 4 additions & 25 deletions esp32/X32Link/midi_clock_out.c
Original file line number Diff line number Diff line change
@@ -1,32 +1,11 @@
// Pure 24-PPQN MIDI-clock-out scheduler — see midi_clock_out.h.
// 24-PPQN MIDI clock OUT — a thin adapter over the shared clock_ticker engine.
// See midi_clock_out.h.
#include "midi_clock_out.h"
#include <math.h>

void midi_clock_out_reset(MidiClockOut* s) {
s->last_tick = 0;
s->primed = false;
clock_ticker_reset(s);
}

int midi_clock_out_ticks_due(MidiClockOut* s, double beats_now, int max_burst) {
if (beats_now < 0.0) beats_now = 0.0;
int64_t tick = (int64_t)floor(beats_now * (double)MIDI_CLOCK_OUT_PPQN);

if (!s->primed) { // first observation: align the grid, no backlog
s->primed = true;
s->last_tick = tick;
return 0;
}

int64_t due = tick - s->last_tick;
if (due <= 0) return 0; // backward or same 1/24-beat slot

if (max_burst > 0 && due > (int64_t)max_burst) {
// Big forward jump (tempo re-origin / long stall): realign to the new
// position instead of flooding the bus with a catch-up burst.
s->last_tick = tick;
return 0;
}

s->last_tick = tick;
return (int)due;
return clock_ticker_ticks_due(s, beats_now, MIDI_CLOCK_OUT_PPQN, max_burst);
}
37 changes: 12 additions & 25 deletions esp32/X32Link/midi_clock_out.h
Original file line number Diff line number Diff line change
@@ -1,41 +1,28 @@
#pragma once
// Pure 24-PPQN clock-tick scheduler for MIDI clock OUT (LNK-027), and the
// shared tick engine a future analog sync out (LNK-028) can reuse. No
// Arduino/USB dependency — host-tested in test/test_midi_clock_out.c. The thin
// glue (midi_clock_out_io.cpp) owns the timer task and the TinyUSB writes.
//
// The scheduler quantizes a *monotonic beat position* (as produced by the Link
// phase pipeline, LNK-017/019) to 24 ticks per beat and reports how many 0xF8
// ticks are now due since the last call. Because the grid is derived from the
// session timeline, ticks are phase-locked: the first tick lands on phase-zero
// and the clock stays bar-aligned, not just tempo-matched.
#include <stdint.h>
#include <stdbool.h>
// 24-PPQN MIDI clock OUT scheduler (LNK-027) — a thin adapter over the shared,
// pure clock_ticker engine (LNK-028 uses the same engine at a configurable
// PPQN). No Arduino/USB dependency — host-tested in test/test_midi_clock_out.c.
// The thin glue (midi_clock_out_io.cpp) owns the timer task and the TinyUSB
// writes. Ticks are phase-locked: the first tick lands on phase-zero and the
// clock stays bar-aligned, not just tempo-matched.
#include "clock_ticker.h"

#ifdef __cplusplus
extern "C" {
#endif

#define MIDI_CLOCK_OUT_PPQN 24

typedef struct {
int64_t last_tick; // last emitted 1/24-beat grid index
bool primed; // false until the first beats_now aligns the grid
} MidiClockOut;
// MIDI clock out is a ClockTicker fixed at 24 PPQN.
typedef ClockTicker MidiClockOut;

// Clear state: the next ticks_due() call re-primes the grid (emits nothing).
void midi_clock_out_reset(MidiClockOut* s);

// Advance the scheduler to the current monotonic beat position and return how
// many 0xF8 ticks to emit now.
// - first call after a reset primes the grid to beats_now and returns 0 (no
// backlog dump);
// - a backward or same-slot move returns 0;
// - a forward move of N 1/24-beat slots returns N, UNLESS N exceeds
// max_burst (a tempo re-origin or a long stall), in which case it re-primes
// and returns 0 — the caller keeps phase alignment rather than flooding the
// bus with a catch-up burst.
// beats_now < 0 is treated as 0 (a "not valid yet" sentinel from the glue).
// many 0xF8 ticks to emit now (24 PPQN). Same semantics as
// clock_ticker_ticks_due() with ppqn=24 — prime-on-first, no backward/backlog,
// re-prime (return 0) on a forward jump larger than max_burst.
int midi_clock_out_ticks_due(MidiClockOut* s, double beats_now, int max_burst);

#ifdef __cplusplus
Expand Down
63 changes: 59 additions & 4 deletions esp32/tasks/LNK-028.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,52 @@ drive a modular clock input directly — no computer in the patch.
decision with LNK-027)?
5. **DIN-sync (Sync24)** — worth a 5-pin DIN variant (24 PPQN + run/stop on one
connector) for Roland-style gear, or keep it to bare 3.5 mm clock/reset?
*(Owner input: deferred follow-on — see "Target gear" below. Reach feature; no
Roland gear owned; BSP takes 24 PPQN on 3.5 mm anyway.)*
6. **Which board** — the Waveshare touch board has free GPIOs but no jacks; this
implies a small add-on/protoboard or a board revision. Scope the hardware
deliverable, not just firmware.

## Target gear & format priorities (owner input, 2026-07-04)

Concrete gear the owner runs, which anchors the format priorities:
- **Moog DFAM** — sync is the **ADV/CLOCK** input, *step-advance*: one rising edge
= advance one sequencer step. **No reset input**, no tempo abstraction. Drive
with the clock pulse at the PPQN matching the desired step rate (2 PPQN = 8th
steps, 4 = 16th). Because there's no reset, a dropped/added pulse slips the
pattern permanently → **pulse accuracy is critical** (reinforces RMT, not
loop-polled edges). Triggers Eurorack-ish; **~5 V target** (3.3 V marginal).
- **Arturia BeatStep Pro** — **CLOCK IN** (3.5 mm) with a *selectable* rate
(1 step / 1 / 2 / 24 / 48 PPQN); our `{1,2,4,8,24}` covers 1/2/24 directly. Acts
as a **clock hub**: redistributes to MIDI-DIN / USB / 2× CV-gate / 8 drum gates,
so one clock line from our box → BSP → clocks the DFAM + the rest. **A single
clock jack may be enough for the whole rig** when a BSP fronts it.
- **Vintage Roland / DIN-sync (Sync24)** — owner owns none; **attractive for
product reach**, not owner-critical → keep as a lower-priority follow-on, not
MVP. Note the BSP already accepts **24 PPQN on its 3.5 mm clock in** (DIN-sync
*rate* without the 5-pin connector), so 3.5 mm 24 PPQN covers BSP-fronted
DIN-rate gear; the physical 5-pin DIN is only needed for direct-to-Roland.

**Resulting priorities:**
1. **MVP:** one rock-solid **configurable-PPQN clock pulse on a 3.5 mm jack @ ~5 V**
(covers DFAM, BSP, Eurorack, Volca). Jitter/accuracy is the top spec.
2. **Secondary:** a **reset** jack (bar downbeat) for sequencers that have one
(not the DFAM; useful for Eurorack/others).
3. **Follow-on:** 5-pin **DIN-sync** for direct vintage-Roland support — reach
feature, defer.

This argues open Q6 (jack count) toward **1–2 jacks (clock, + optional reset)**,
and open Q5 (DIN) toward a **deferred follow-on**.

## Acceptance Criteria

- [ ] Shared clock-tick scheduler (pure, host-tested; reused by LNK-027) emits
ticks at a configurable PPQN, first tick aligned to `tempo_source_phase()`
phase-zero, gated on `tempo_source_phase_valid()`.
- [x] Shared clock-tick scheduler (pure, host-tested; reused by LNK-027) emits
ticks at a configurable PPQN, first tick aligned to phase-zero. **DONE:**
`clock_ticker.{h,c}` (`clock_ticker_ticks_due(state, beats, ppqn, max_burst)`
+ a `BarReset` tracker for the reset pulse); LNK-027's `midi_clock_out` is
now a thin 24-PPQN adapter over it (its 8 tests still green + 11 new
`test_clock_ticker` tests). The phase-valid gate + re-origin realign live in
the glue (reused from LNK-027). *(One engine now, per the note below.)*
- [ ] Analog clock + reset pulses appear on GPIO, level-shifted to ~5 V on 3.5 mm
jacks, with the protection/level-shift circuit documented (schematic or BOM).
- [ ] New NVS config (`analog_sync_*`) exposed in the web config UI and the touch
Expand All @@ -97,7 +134,9 @@ drive a modular clock input directly — no computer in the patch.
clock locked to the Link bar with no visible jitter/flam, and reset firing
on the downbeat, across a transport stop/start and a tempo change.
- [ ] No regression to Link → OSC, USB-MIDI-in, or (if landed) LNK-027 MIDI-out.
- [ ] `esp32/AGENTS.md` module table updated for the new module.
*(Scheduler slice: full host suite green, LNK-027 MIDI-out behavior unchanged.)*
- [x] `esp32/AGENTS.md` module table updated (`clock_ticker`; `midi_clock_out`
re-noted as its adapter).

## Notes

Expand All @@ -109,3 +148,19 @@ drive a modular clock input directly — no computer in the patch.
- Filed from a feature ask (2026-07-03); hardware design (level-shift + jacks) is
the gating unknown, hence `priority: low` / `needs-triage` until the board
question (add-on vs revision) is decided.

## Progress

**2026-07-04 — shared pure scheduler landed (AC #1).** Extracted `clock_ticker.c`
— the configurable-PPQN tick engine both LNK-027 and LNK-028 want — with a
`BarReset` tracker that fires once per bar boundary for the analog reset pulse.
TDD'd (11 host tests: PPQN 1/4/24 cadence, jump-reprime, backward/negative guards,
bar-reset crossing/backward/multi-bar-jump/bad-quantum). Refactored LNK-027's
`midi_clock_out` into a thin 24-PPQN adapter over it — behavior-preserving, its 8
tests unchanged and green. Firmware compiles (86% flash); AGENTS.md updated.

**Still gated (needs the hardware/board decision — open Q6):** the GPIO/RMT edge
emission, 3.3→5V level-shift + protection + 3.5mm jacks, `analog_sync_*` config +
web/touch UI, and on-scope hardware verification. Those wait on whether this is an
add-on board vs a revision, and the level-shifter approach (open Q1). The pure
timing core is decision-independent and is now in place for whenever that lands.
9 changes: 7 additions & 2 deletions esp32/test/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ TOUCHUI = ../X32Link/touch_ui.c
SNAP = ../X32Link/tempo_snapshot.c
BAR = ../X32Link/bar.c
MIDIOUT = ../X32Link/midi_clock_out.c
CLOCKTICK = ../X32Link/clock_ticker.c

.PHONY: all clean

all: test_bpm_tracker test_link_protocol test_link_measurement test_link_phase test_led_phase test_wifi_down_blink test_app_config test_osc_out test_midi_bpm_calc test_bpm_publisher test_osc_in test_fader_db test_web_status_json test_touch_ui test_tempo_snapshot test_bar test_midi_clock_out
all: test_bpm_tracker test_link_protocol test_link_measurement test_link_phase test_led_phase test_wifi_down_blink test_app_config test_osc_out test_midi_bpm_calc test_bpm_publisher test_osc_in test_fader_db test_web_status_json test_touch_ui test_tempo_snapshot test_bar test_midi_clock_out test_clock_ticker
./test_bpm_tracker
./test_link_protocol
./test_link_measurement
Expand All @@ -44,6 +45,7 @@ all: test_bpm_tracker test_link_protocol test_link_measurement test_link_phase t
./test_tempo_snapshot
./test_bar
./test_midi_clock_out
./test_clock_ticker
./check_docs.sh

test_bpm_tracker: test_bpm_tracker.c $(TRACKER) $(UNITY)
Expand Down Expand Up @@ -97,7 +99,10 @@ test_tempo_snapshot: test_tempo_snapshot.c $(SNAP) $(UNITY)
test_bar: test_bar.c $(BAR) $(UNITY)
$(CC) $(CFLAGS) $^ -o $@ -lm

test_midi_clock_out: test_midi_clock_out.c $(MIDIOUT) $(UNITY)
test_midi_clock_out: test_midi_clock_out.c $(MIDIOUT) $(CLOCKTICK) $(UNITY)
$(CC) $(CFLAGS) $^ -o $@ -lm

test_clock_ticker: test_clock_ticker.c $(CLOCKTICK) $(UNITY)
$(CC) $(CFLAGS) $^ -o $@ -lm

clean:
Expand Down
Loading
Loading