diff --git a/CHANGELOG.md b/CHANGELOG.md index a6b05eb..ede97bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this pr uses [Semantic Versioning](https://semver.org/) (`vMAJOR.MINOR.PATCH`). ## [Unreleased] +- **Patch dump/load + preset management over USB MIDI** (`docs/MIDI_PROTOCOL.md` §3-4). + New SysEx commands let Propagator drive the on-device presets it previously couldn't see: + `0x10`/`0x50` dump the full live patch (device → editor, so a hardware tweak or a recall + syncs the UI), `0x11` loads a patch, and `0x20`/`0x21`/`0x22` save / recall / list the three + QSPI slots of the active mode, with `0x52` reporting slot occupancy + the active slot. A + Program Change is an alias for recall. The slots are the same ones the Footswitch-2 gesture + uses; the commands reuse the existing capture/apply glue. Patch values are 7-bit to stay + under libDaisy's 128-byte inbound SysEx buffer. - **Fix: intermittent crackle in Synth mode under load.** The crackle was a per-block CPU spike, not sustained load (so the watchdog, which only sheds after ~150 ms, never caught it): a moving filter envelope changed the cutoff every sample, forcing a per-sample diff --git a/docs/MIDI_PROTOCOL.md b/docs/MIDI_PROTOCOL.md index 4cffac4..ce1e317 100644 --- a/docs/MIDI_PROTOCOL.md +++ b/docs/MIDI_PROTOCOL.md @@ -114,30 +114,57 @@ Tempo feeds the synced delay (CC 15 division) and the clock-synced LFO rates reports the cached value — **no extra audio-CPU cost**. Propagator polls it (~20 Hz, only while the attractor canvas is visible) to draw the live chaos in the MOD pod. -## 3. Full patch dump / load — SysEx 🔜 (Phase 2 → enables presets) - -A "patch" = every live value: MODE-layer knobs ×6, FX-layer knobs ×6, mode select, -FX select, and (later) mod/sensor routing. Encoded 14-bit per value (two 7-bit -bytes) so there's no resolution loss. - -- `0x10` **Dump request** (web→device) → device replies `0x50` with the full patch. -- `0x50` **Patch dump** (device→web). -- `0x11` **Patch load** (web→device): set all live values at once. - -This is what keeps the web UI in sync with the device, and is the unit a preset -stores. - -## 4. Presets — SysEx + QSPI 🔜 (Phase 3, needs hardware) +## 3. Full patch dump / load — SysEx ✅ (in firmware) + +A **patch** is exactly what a preset stores: the active mode's extended params, the +MODE-layer knobs ×6, the FX-layer knobs ×6, FX select, delay sync, and the master +stage (volume / filter type / cutoff / resonance). Values are **7-bit** (`0..127`) — +this matches the CC editing granularity and keeps a full synth patch (~72 bytes) +under libDaisy's 128-byte inbound SysEx buffer. (14-bit via NRPN remains a possible +future refinement; in practice every value originates from a 7-bit CC or a knob.) + +- `0x10` **Dump request** (web→device), no payload → device replies `0x50`. +- `0x50` **Patch dump** (device→web): `F0 7D 50 F7`. +- `0x11` **Patch load** (web→device): `F0 7D 11 F7` — sets all live values + at once and switches the device to the patch's mode. + +**Patch payload layout** (same bytes for `0x50` and `0x11`): + +| Offset | Bytes | Field | +|--------|-------|-------| +| 0 | 1 | patch format version (`1`) | +| 1 | 1 | **mode** (0 synth / 1 granular / 2 generative) — which mode the params below belong to | +| 2 | 1 | FX select (0 off / 1 delay / 2 reverb) | +| 3 | 1 | delay sync division (0 off / 1..4) | +| 4 | 1 | master filter type (0 off / 1 LP / 2 BP / 3 HP) | +| 5–7 | 3 | master volume · cutoff · resonance (each `0..127`) | +| 8–13 | 6 | MODE-layer knobs 1–6 | +| 14–19 | 6 | FX-layer knobs 1–6 | +| 20 | 1 | **paramCount** = number of mode params that follow (synth 49 / granular 4 / generative 6) | +| 21… | paramCount | the active mode's extended params (`SP_*` / `GR_*` / `GP_*`, each `0..127`) | + +This keeps the web UI in sync with the device, and is the unit a preset stores. + +## 4. Presets — SysEx + QSPI ✅ (in firmware) Presets are patches (§3). Two stores: - **Browser librarian**: presets saved as JSON in `localStorage` / exported files. -- **On-device**: saved to **QSPI flash** (`daisy::PersistentStorage` / `QSPIHandle`) - so they survive power-off and recall without the computer. - -- `0x20` **Save to slot** `N` (device writes current patch to QSPI slot N). -- `0x21` **Recall slot** `N` (device loads slot N; also emits a `0x50` dump so the - UI follows). Program Change `N` is an alias for recall. -- `0x22` **List slots** → reply `0x52` with slot names/occupancy. +- **On-device**: 3 slots **per mode** in **QSPI flash** (`daisy::PersistentStorage`), + indexed by the Toggle-2 position, so they survive power-off and recall without the + computer. The same slots are reachable by the hardware gesture (hold FOOTSWITCH 2, + pick the slot with Toggle 2, tap FOOTSWITCH 1 to save). + +All slot commands act on the device's **currently active mode**. + +- `0x20` **Save to slot** `N` (web→device): device captures the current live sound + into QSPI slot `N`, then replies `0x52` with the updated occupancy. +- `0x21` **Recall slot** `N` (web→device): device loads slot `N` (no-op if empty), + then emits a `0x50` dump (so the UI follows) **and** a `0x52` (active-slot update). + **Program Change** `N` is an alias for recall. +- `0x22` **List slots** (web→device), no payload → reply `0x52`. +- `0x52` **Slot list reply** (device→web): `F0 7D 52 + F7` — `mode` is the active mode, `usedK` is 1 if slot K holds a preset, + and `active` is the last-loaded/saved slot as `slot+1` (`0` = none). ## 5. Sample upload — SysEx + QSPI 🔜 (Phase 4, biggest; needs hardware) @@ -166,8 +193,9 @@ Flow: 1. ✅ **Live control (CC) + MIDI clock** — firmware and the full Propagator editor are done (synth panel, mod matrix, sequencer, tempo/clock). -2. 🔜 **Handshake + patch dump/load (SysEx)** — 2-way sync; central `Patch` store. -3. 🔜 **Presets** — QSPI save/recall on device + browser librarian. +2. ✅ **Handshake + patch dump/load (SysEx)** — 2-way sync via `0x10`/`0x50`/`0x11`. +3. ✅ **Presets** — QSPI save/recall/list on device (`0x20`/`0x21`/`0x22`/`0x52`) + + browser librarian. 4. 🔜 **Sample upload** — chunked SysEx → QSPI + a sample-player source. The web tool, **Propagator**, lives in a separate repo diff --git a/src/io/midi_in.h b/src/io/midi_in.h index 7bbbece..acca69e 100644 --- a/src/io/midi_in.h +++ b/src/io/midi_in.h @@ -45,6 +45,14 @@ struct MidiContext { int& delaySync; // [in/out] delay tempo-sync division int& varSel; // [in/out] forced TOGGLE 2 variant (-1 = follow) bool& bypass; // [in/out] effect bypass + // Patch / preset SysEx hooks (the state lives in main.cpp; a null hook = the + // command is unsupported). See docs/MIDI_PROTOCOL.md §3-4. `loadPatch` gets the + // payload bytes after `F0 7D 11`, with `len` excluding the trailing F7. + void (*sendPatch)(void); // 0x10 -> reply 0x50 (full patch dump) + void (*loadPatch)(const uint8_t*, int); // 0x11 (set all live values at once) + void (*presetSave)(int); // 0x20 (save live state -> QSPI slot N) + void (*presetRecall)(int); // 0x21 (recall slot N; also emits 0x50) + void (*sendSlots)(void); // 0x22 -> reply 0x52 (slot occupancy) }; // Drain pending MIDI events: notes -> active mode; CC -> live knob values + state. @@ -99,6 +107,10 @@ inline bool PumpMidi(daisy::MidiUsbHandler& midi, MidiContext ctx) { auto m = msg.AsNoteOff(); mode->NoteOff(static_cast(m.note)); } break; + case daisy::ProgramChange: { // Program Change N = recall preset slot N (alias for + // 0x21) + if (ctx.presetRecall) ctx.presetRecall(msg.AsProgramChange().program); + } break; case daisy::ControlChange: { auto cc = msg.AsControlChange(); int n = cc.control_number; @@ -192,6 +204,17 @@ inline bool PumpMidi(daisy::MidiUsbHandler& midi, MidiContext ctx) { uint8_t reply[6] = { 0xF0, 0x7D, 0x43, enc(modEngine.ChaosX()), enc(modEngine.ChaosY()), 0xF7}; midi.SendMessage(reply, 6); + } else if (cmd == 0x10) { // full patch dump request -> device replies 0x50 + if (ctx.sendPatch) ctx.sendPatch(); + } else if (cmd == 0x11) { // patch load: set every live value at once + if (ctx.loadPatch && sx.length > i + 2) + ctx.loadPatch(&sx.data[i + 2], sx.length - (i + 2)); + } else if (cmd == 0x20) { // save the current patch to QSPI slot N + if (ctx.presetSave && sx.length > i + 2) ctx.presetSave(sx.data[i + 2]); + } else if (cmd == 0x21) { // recall QSPI slot N (device also emits a 0x50 dump) + if (ctx.presetRecall && sx.length > i + 2) ctx.presetRecall(sx.data[i + 2]); + } else if (cmd == 0x22) { // list slots -> device replies 0x52 (occupancy) + if (ctx.sendSlots) ctx.sendSlots(); } } break; default: diff --git a/src/main.cpp b/src/main.cpp index 7db9a2f..b46fbbc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -158,6 +158,120 @@ static void PresetSave(int mode, int slot) { g_preset_save_pending = true; } +// ---- Patch over SysEx: dump / load + preset slots (docs/MIDI_PROTOCOL.md §3-4) ---- +// A "patch" is exactly what a preset stores (PresetData): the active mode's params, +// the MODE + FX knob layers, FX select, delay sync, and the master stage. Values are +// 7-bit (0..127) -- matches the CC editing granularity and keeps a full synth patch +// (~72 bytes) well under libDaisy's 128-byte inbound SysEx buffer. The hooks below +// reuse the QSPI capture/apply glue above and are wired into MidiContext; PumpMidi +// calls them from the main loop, so they reply on the global `midi`. +static int ModeParamCount(int mode) { + switch (mode) { + case MODE_GRANULAR: + return GR_COUNT; + case MODE_GENERATIVE: + return GP_COUNT; + default: + return SP_COUNT; + } +} + +static inline uint8_t To7(float v01) { + int x = static_cast(v01 * 127.0f + 0.5f); + return static_cast(x < 0 ? 0 : (x > 127 ? 127 : x)); +} + +// Encode a captured patch into the 7-bit payload; returns the byte count written. +static int EncodePatch(const PresetData& p, int mode, uint8_t* out) { + int j = 0; + out[j++] = 1; // patch format version + out[j++] = static_cast(mode); + out[j++] = p.fxMode; + out[j++] = p.delaySync; + out[j++] = p.masterFiltType; + out[j++] = To7(p.masterVol); + out[j++] = To7(p.masterCut); + out[j++] = To7(p.masterRes); + for (int i = 0; i < ShiftKnobs::kKnobs; ++i) out[j++] = To7(p.modeKnobs[i]); + for (int i = 0; i < ShiftKnobs::kKnobs; ++i) out[j++] = To7(p.fxKnobs[i]); + const int n = ModeParamCount(mode); + out[j++] = static_cast(n); + for (int i = 0; i < n; ++i) out[j++] = To7(p.modeParams[i]); + return j; +} + +// Decode the 7-bit payload back into a PresetData (+ its mode). false if malformed. +static bool DecodePatch(const uint8_t* in, int len, PresetData& p, int& mode) { + if (len < 21) return false; + auto f = [](uint8_t x) { return static_cast(x) / 127.0f; }; + mode = in[1]; + if (mode < 0 || mode >= MODE_COUNT) return false; + p.fxMode = in[2]; + p.delaySync = in[3]; + p.masterFiltType = in[4]; + p.masterVol = f(in[5]); + p.masterCut = f(in[6]); + p.masterRes = f(in[7]); + for (int i = 0; i < ShiftKnobs::kKnobs; ++i) p.modeKnobs[i] = f(in[8 + i]); + for (int i = 0; i < ShiftKnobs::kKnobs; ++i) p.fxKnobs[i] = f(in[14 + i]); + int n = in[20]; + const int maxn = ModeParamCount(mode); + if (n > maxn) n = maxn; + if (len < 21 + n) return false; + for (int i = 0; i < n; ++i) p.modeParams[i] = f(in[21 + i]); + p.used = 1; + return true; +} + +static void SxSendPatchDump() { // 0x50: the active mode's full live patch + PresetData p; + PresetCapture(p, g_active); + uint8_t buf[80]; // F0 7D 50 + (<=70 payload) + F7 + int j = 0; + buf[j++] = 0xF0; + buf[j++] = 0x7D; + buf[j++] = 0x50; + j += EncodePatch(p, g_active, buf + j); + buf[j++] = 0xF7; + midi.SendMessage(buf, j); +} + +static void SxSendSlots() { // 0x52: + const PresetBank& bank = g_presetStore.GetSettings(); + uint8_t buf[16]; // F0 7D 52 + mode + kPresetSlots + active + F7 + int j = 0; + buf[j++] = 0xF0; + buf[j++] = 0x7D; + buf[j++] = 0x52; + buf[j++] = static_cast(g_active); + for (int s = 0; s < kPresetSlots; ++s) buf[j++] = bank.slot[g_active][s].used ? 1 : 0; + const int act = g_active_preset[g_active]; + buf[j++] = static_cast(act < 0 ? 0 : act + 1); + buf[j++] = 0xF7; + midi.SendMessage(buf, j); +} + +static void SxLoadPatch(const uint8_t* payload, int len) { + PresetData p; + int mode; + if (!DecodePatch(payload, len, p, mode)) return; + PresetApply(p, mode); + g_modeSel = mode; // switch the device to the patch's mode (forced; a toggle move overrides) +} + +static void SxPresetSave(int slot) { + if (slot < 0 || slot >= kPresetSlots) return; + PresetSave(g_active, slot); // captures now; the main loop flushes the bank to QSPI + SxSendSlots(); // confirm the new occupancy back to the editor +} + +static void SxPresetRecall(int slot) { + if (slot < 0 || slot >= kPresetSlots) return; + PresetLoad(g_active, slot); // no-op on an empty slot + SxSendPatchDump(); // tell the editor the new live state ... + SxSendSlots(); // ... and which slot is now active +} + void AudioCallback(AudioHandle::InputBuffer in, AudioHandle::OutputBuffer out, size_t size) { g_cpu.OnBlockStart(); hw.ProcessAllControls(); @@ -400,8 +514,10 @@ int main() { hw.StartAudio(AudioCallback); while (true) { - MidiContext mctx{g_modes[g_active], g_shift, g_clock, g_cpu, g_mod, g_master, - g_modeSel, g_fxSel, g_delaySync, g_varSel, g_bypass}; + MidiContext mctx{ + g_modes[g_active], g_shift, g_clock, g_cpu, g_mod, g_master, + g_modeSel, g_fxSel, g_delaySync, g_varSel, g_bypass, SxSendPatchDump, + SxLoadPatch, SxPresetSave, SxPresetRecall, SxSendSlots}; if (PumpMidi(midi, mctx)) g_last_midi_ms = System::GetNow(); EchoControls(midi, hw); // mirror physical control changes back to the editor g_clock.Update(System::GetNow()); // drop back to internal tempo if clock stops