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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 51 additions & 23 deletions docs/MIDI_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <payload> F7`.
- `0x11` **Patch load** (web→device): `F0 7D 11 <payload> 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 <mode> <used0> <used1> <used2>
<active> 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)

Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions src/io/midi_in.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -99,6 +107,10 @@ inline bool PumpMidi(daisy::MidiUsbHandler& midi, MidiContext ctx) {
auto m = msg.AsNoteOff();
mode->NoteOff(static_cast<float>(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;
Expand Down Expand Up @@ -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:
Expand Down
120 changes: 118 additions & 2 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>(v01 * 127.0f + 0.5f);
return static_cast<uint8_t>(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<uint8_t>(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<uint8_t>(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<float>(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: <mode> <used0> <used1> <used2> <active+1 | 0=none>
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<uint8_t>(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<uint8_t>(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();
Expand Down Expand Up @@ -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
Expand Down
Loading