From 661391d34183e8563954255c16ae53fb6f8d9c56 Mon Sep 17 00:00:00 2001 From: Vince Speelman Date: Thu, 5 Nov 2026 20:15:09 -0500 Subject: [PATCH 1/5] MIDI: the pure logic: packet decoder, Control Change map, messages to send, settings byte, clock to delay time firmware/px_midi.h has no hardware and is tested on a PC (tests/midisim.c). px_dly_value_for_ms picks the delay TIME step nearest a time. --- engine/px_dsp.c | 12 ++ engine/px_dsp.h | 1 + firmware/px_midi.h | 221 ++++++++++++++++++++++++++++++++++++ tests/midisim.c | 272 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 506 insertions(+) create mode 100644 firmware/px_midi.h create mode 100644 tests/midisim.c diff --git a/engine/px_dsp.c b/engine/px_dsp.c index c484937..d056005 100644 --- a/engine/px_dsp.c +++ b/engine/px_dsp.c @@ -406,6 +406,18 @@ static float syn_rel_ms(float x) { return 50.0f + x * x * 2950.0f; } /* rele static int syn_bari_st(int v) { return -1 - (int)((float)v * 0.01f * 11.99f); } static int syn_oct(int v) { return v < 34 ? -1 : (v < 67 ? 0 : 1); } /* presets saved before v34 (store format 1 / 2): the old amp / drive / cab numbers -> the nearest of the v34 set */ +/* the TIME knob value (0..100) whose delay is nearest to `ms` (MIDI clock sync) */ +int px_dly_value_for_ms(int type, float ms) +{ + int best = 0; + float bd = 1e30f; + for (int v = 0; v <= 100; v++) { + float d = dly_ms(type, v) - ms; + if (d < 0.0f) d = -d; + if (d < bd) { bd = d; best = v; } + } + return best; +} int px_dly_time_v35(int type, int v) { if (type == PX_DLY_REVERSE || v < 0) return v < 0 ? 0 : v; diff --git a/engine/px_dsp.h b/engine/px_dsp.h index ea5c4c1..e72148e 100644 --- a/engine/px_dsp.h +++ b/engine/px_dsp.h @@ -77,6 +77,7 @@ const char *px_param_name(int blk, int type, int k); int px_type_v34(int blk, int old); /* presets saved before v34: old type -> new */ #define PX_PATCH_LEVEL 2 /* the preset LEVEL: p[PX_B_GATE][2] ("PRESET LVL" on the GT page), 50 = 0 dB (v36) */ int px_dly_time_v35(int type, int v); /* presets saved before v35: delay TIME (20..1000 ms) -> today's knob */ +int px_dly_value_for_ms(int type, float ms); /* the TIME value nearest to a delay of ms (MIDI clock sync) */ int px_param_text(int blk, int type, int k, int v, char *buf8); int px_preset_count(void); void px_preset_factory(px_preset_t *p, int idx); diff --git a/firmware/px_midi.h b/firmware/px_midi.h new file mode 100644 index 0000000..8088dd6 --- /dev/null +++ b/firmware/px_midi.h @@ -0,0 +1,221 @@ +/* SPDX-License-Identifier: GPL-3.0-only + * OpenPocket — musical MIDI over USB: what a message means for the pedal, and what the pedal says back. + * The map is documented in docs/MIDI.md. No hardware here (tests/midisim.c runs this file on a PC); the rings and + * the pedal's state are in px_midi_io.h. + * + * px_midi_decode one 4-byte USB-MIDI event packet (from usb.c's input ring) -> a message, or "ignore" + * px_midi_cc_apply a Control Change -> the preset (and the MASTER volume) + * px_midi_tx_next the next message the pedal sends so the host follows the pedal (Program Change, then CCs) + * px_midi_clock_* MIDI clock -> a quarter-note time, for the delay TIME + * + * Needs px_dsp.h (px_preset_t, px_ntypes, px_dly_value_for_ms). SysEx never comes here: usb.c routes it to the + * update / editor code, and only channel voice messages and clock reach the ring this file reads. + */ +#pragma once + +/* ---- settings (one byte in the preset store's header, px_store.h): all-zero = the defaults ---- */ +typedef struct { + uint8_t chan; /* 0 = omni (receive any channel; send on 1), 1..16 = that channel */ + uint8_t send; /* 1 = the pedal sends Program Change / Control Change when its sound changes */ + uint8_t clock; /* 1 = MIDI clock sets the delay TIME to a quarter note */ +} px_midi_cfg_t; +#define PXM_CFG_NOSEND 0x20u +#define PXM_CFG_CLOCK 0x40u +static uint8_t px_midi_cfg_pack(const px_midi_cfg_t *c) +{ + return (uint8_t)((c->chan & 31u) | (c->send ? 0u : PXM_CFG_NOSEND) | (c->clock ? PXM_CFG_CLOCK : 0u)); +} +static void px_midi_cfg_unpack(px_midi_cfg_t *c, uint8_t b) +{ + c->chan = (uint8_t)(b & 31u); + if (c->chan > 16u) c->chan = 0; /* a byte this firmware never wrote: omni */ + c->send = (b & PXM_CFG_NOSEND) ? 0u : 1u; + c->clock = (b & PXM_CFG_CLOCK) ? 1u : 0u; +} +/* the pedal's MIDI state, shared by the screen (ui.h) and the glue (px_midi_io.h) */ +static px_midi_cfg_t mid_cfg; /* all-zero until the store is read: omni, send on, clock off */ +static uint8_t mid_usb_up; /* a USB host is connected */ +static uint8_t mid_last[3]; /* the last message received: type (PXM_*), data 1, data 2 */ +static uint32_t px_midi_tx_chan(const px_midi_cfg_t *c) { return c->chan ? (uint32_t)c->chan - 1u : 0u; } + +/* ---- the Control Change map (docs/MIDI.md) ---- */ +enum { PXM_NONE, PXM_ON, PXM_TYPE, PXM_PARAM, PXM_MASTER }; +typedef struct { uint8_t kind, blk, k; } px_cc_t; +#define PXM_CC_MASTER 7u +#define PXM_CC_ON0 20u /* 20..31: block b on/off */ +#define PXM_CC_TYPE0 52u /* 52..63: block b model */ +static uint32_t px_cc_param(uint32_t b, uint32_t k) /* 60 settings over three runs of free numbers (38, Data Entry LSB, skipped) */ +{ + uint32_t i = b * PX_NPAR + k; + return i < 5u ? 33u + i : i < 18u ? 34u + i : i < 46u ? 70u + (i - 18u) : 102u + (i - 46u); +} +static uint32_t px_cc_of(const px_cc_t *t) +{ + switch (t->kind) { + case PXM_ON: return PXM_CC_ON0 + t->blk; + case PXM_TYPE: return PXM_CC_TYPE0 + t->blk; + case PXM_PARAM: return px_cc_param(t->blk, t->k); + case PXM_MASTER: return PXM_CC_MASTER; + } + return 0; +} +static px_cc_t px_cc_tab[128]; /* the inverse of px_cc_of, built once */ +static uint8_t px_cc_tab_ok; +static void px_cc_build(void) +{ + px_cc_t t; + uint32_t b, k; + if (px_cc_tab_ok) return; + for (b = 0; b < PX_NBLK; b++) { + t.blk = (uint8_t)b; t.k = 0; + t.kind = PXM_ON; px_cc_tab[px_cc_of(&t)] = t; + t.kind = PXM_TYPE; px_cc_tab[px_cc_of(&t)] = t; + for (k = 0; k < PX_NPAR; k++) { t.kind = PXM_PARAM; t.k = (uint8_t)k; px_cc_tab[px_cc_of(&t)] = t; } + } + t.kind = PXM_MASTER; t.blk = t.k = 0; + px_cc_tab[px_cc_of(&t)] = t; + px_cc_tab_ok = 1; +} + +/* 0..100 <-> 0..127; a model number <-> the middle of its slice of 0..127 */ +static uint32_t pxm_to_cc(uint32_t v) { return (v * 127u + 50u) / 100u; } +static uint32_t pxm_from_cc(uint32_t c) { return (c * 100u + 63u) / 127u; } +static uint32_t pxm_type_to_cc(uint32_t t, uint32_t n) { return (t * 128u + 64u) / n; } +static uint32_t pxm_type_from_cc(uint32_t c, uint32_t n) { return c * n / 128u; } + +/* ---- a received message ---- */ +enum { PXM_IGNORE, PXM_PC, PXM_CC, PXM_CLOCK, PXM_START, PXM_STOP }; +typedef struct { uint8_t type, d1, d2; } px_midi_msg_t; + +/* One USB-MIDI event packet: byte 0 = cable number << 4 | code index, bytes 1..3 = the MIDI message. Only the + * channel voice messages the map uses and the realtime clock / transport bytes pass; everything else is ignored. + * A message on another channel than the setting is ignored (omni: any). */ +static px_midi_msg_t px_midi_decode(const px_midi_cfg_t *cfg, uint32_t pkt) +{ + px_midi_msg_t m = {PXM_IGNORE, 0, 0}; + uint32_t cin = pkt & 15u, st = (pkt >> 8) & 0xFFu, d1 = (pkt >> 16) & 0xFFu, d2 = (pkt >> 24) & 0xFFu; + if (cin == 0xFu) { /* a single byte: realtime */ + if (st == 0xF8u) m.type = PXM_CLOCK; + else if (st == 0xFAu || st == 0xFBu) m.type = PXM_START; + else if (st == 0xFCu) m.type = PXM_STOP; + return m; + } + if (cin != 0xBu && cin != 0xCu) return m; /* not a Control Change or a Program Change */ + if ((st >> 4) != cin || (d1 & 0x80u)) return m; /* the status does not match the code index, or bad data */ + if (cin == 0xBu && (d2 & 0x80u)) return m; + if (cfg->chan && (st & 15u) != (uint32_t)cfg->chan - 1u) return m; + m.type = cin == 0xCu ? PXM_PC : PXM_CC; + m.d1 = (uint8_t)d1; + m.d2 = (uint8_t)(cin == 0xBu ? d2 : 0); + return m; +} + +/* A Control Change on the preset. Returns 1 when the sound changed. `master` (0..100) is set for CC 7 (a change + * is reported through *master_set). Numbers outside the map change nothing (modulation wheel, sustain, "all notes + * off"...). */ +static int px_midi_cc_apply(px_preset_t *p, uint32_t cc, uint32_t val, uint32_t *master, int *master_set) +{ + px_cc_t t; + uint32_t v; + px_cc_build(); + if (cc > 127u || val > 127u) return 0; + t = px_cc_tab[cc]; + switch (t.kind) { + case PXM_ON: + v = val >= 64u; + if (p->on[t.blk] == v) return 0; + p->on[t.blk] = (uint8_t)v; + return 1; + case PXM_TYPE: + v = pxm_type_from_cc(val, (uint32_t)px_ntypes(t.blk)); + if (p->type[t.blk] == v) return 0; + p->type[t.blk] = (uint8_t)v; + return 1; + case PXM_PARAM: + v = pxm_from_cc(val); + if (p->p[t.blk][t.k] == v) return 0; + p->p[t.blk][t.k] = (uint8_t)v; + return 1; + case PXM_MASTER: + *master = pxm_from_cc(val); + *master_set = 1; + return 0; + } + return 0; +} + +/* ---- what the pedal sends: it follows the preset it plays ---- */ +typedef struct { + px_preset_t seen; /* the sound the host was last told about */ + uint8_t slot; /* the slot it was last told about (Program Change) */ + uint8_t valid; /* 0 until the first sync after boot: nothing is sent for the sound we start with */ +} px_midi_tx_t; + +/* Take the pedal's present state as already known to the host (after a message received from it, at boot). */ +static void px_midi_tx_sync(px_midi_tx_t *t, const px_preset_t *cur, uint32_t slot) +{ + t->seen = *cur; + t->slot = (uint8_t)slot; + t->valid = 1; +} + +/* The next message to send as a USB-MIDI event packet (cable 0), 0 = nothing to say. A slot change is a Program + * Change and tells the host the whole new sound at once (no CC burst); after that, each changed setting is one + * Control Change, in the order on / model / settings of the blocks. Call again while the ring has room. */ +static uint32_t px_midi_tx_next(px_midi_tx_t *t, const px_midi_cfg_t *cfg, const px_preset_t *cur, uint32_t slot) +{ + uint32_t ch = px_midi_tx_chan(cfg), b, k; + px_cc_t c; + if (!t->valid) { px_midi_tx_sync(t, cur, slot); return 0; } + if (!cfg->send) { px_midi_tx_sync(t, cur, slot); return 0; } + if (t->slot != slot) { + px_midi_tx_sync(t, cur, slot); + return 0xCu | ((0xC0u | ch) << 8) | (slot << 16); + } + px_cc_build(); + for (b = 0; b < PX_NBLK; b++) { + if (cur->on[b] != t->seen.on[b]) { + t->seen.on[b] = cur->on[b]; + c.kind = PXM_ON; c.blk = (uint8_t)b; c.k = 0; + return 0xBu | ((0xB0u | ch) << 8) | (px_cc_of(&c) << 16) | ((cur->on[b] ? 127u : 0u) << 24); + } + if (cur->type[b] != t->seen.type[b]) { + t->seen.type[b] = cur->type[b]; + c.kind = PXM_TYPE; c.blk = (uint8_t)b; c.k = 0; + return 0xBu | ((0xB0u | ch) << 8) | (px_cc_of(&c) << 16) | + (pxm_type_to_cc(cur->type[b], (uint32_t)px_ntypes((int)b)) << 24); + } + for (k = 0; k < PX_NPAR; k++) { + if (cur->p[b][k] != t->seen.p[b][k]) { + t->seen.p[b][k] = cur->p[b][k]; + c.kind = PXM_PARAM; c.blk = (uint8_t)b; c.k = (uint8_t)k; + return 0xBu | ((0xB0u | ch) << 8) | (px_cc_of(&c) << 16) | (pxm_to_cc(cur->p[b][k]) << 24); + } + } + } + return 0; +} + +/* ---- MIDI clock -> a quarter note ---- + * 24 clocks per quarter note. The time of 24 clocks is measured from the first of a run to the 25th (one run is + * one quarter note), so a timestamp's jitter of a millisecond or two is spread over the whole note. A run that is + * broken (a gap of more than 100 ms between two clocks, or a Stop) starts over. A time is reported once two runs in + * a row agree within 3 ms, so a drifting host does not make the delay time jump. */ +typedef struct { uint32_t first_ms, last_ms, n, prev_q; } px_midi_clock_t; +static void px_midi_clock_reset(px_midi_clock_t *c) { c->n = 0; c->prev_q = 0; } +/* feed one clock at time `ms`; returns the quarter note in ms (200..2000), or 0 */ +static uint32_t px_midi_clock_tick(px_midi_clock_t *c, uint32_t ms) +{ + uint32_t q; + if (c->n && ms - c->last_ms > 100u) c->n = 0; + c->last_ms = ms; + if (c->n == 0) { c->first_ms = ms; c->n = 1; return 0; } + if (++c->n < 25u) return 0; + q = ms - c->first_ms; + c->first_ms = ms; + c->n = 1; + if (q < 200u || q > 2000u) { c->prev_q = 0; return 0; } + if (c->prev_q && (q > c->prev_q ? q - c->prev_q : c->prev_q - q) <= 3u) { c->prev_q = q; return q; } + c->prev_q = q; + return 0; +} diff --git a/tests/midisim.c b/tests/midisim.c new file mode 100644 index 0000000..e2ba875 --- /dev/null +++ b/tests/midisim.c @@ -0,0 +1,272 @@ +/* SPDX-License-Identifier: GPL-3.0-only + * OpenPocket — the musical MIDI code (firmware/px_midi.h) on a PC: the packet decoder, the Control Change map, the + * messages the pedal sends, the settings byte, and MIDI clock. No hardware, no sound. + * cc -O1 -Wall -Wno-unused-function -Iengine -Ifirmware -o build/midisim tests/midisim.c engine/px_dsp.c firmware/px_math.c -lm && build/midisim + * Exits 1 on the first group with a failure. */ +#include +#include +#include +#include +#include +#include "px_dsp.h" +#include "px_midi.h" + +static int failed; +static void ok(int c, const char *what) { printf("%-76s %s\n", what, c ? "ok" : "FAIL"); if (!c) failed++; } +/* a USB-MIDI event packet: cable 0 */ +static uint32_t pk(uint32_t cin, uint32_t st, uint32_t d1, uint32_t d2) { return cin | st << 8 | d1 << 16 | d2 << 24; } +static uint32_t cc_pkt(uint32_t ch, uint32_t cc, uint32_t v) { return pk(0xB, 0xB0 | ch, cc, v); } +static uint32_t pc_pkt(uint32_t ch, uint32_t n) { return pk(0xC, 0xC0 | ch, n, 0); } + +int main(void) +{ + px_midi_cfg_t omni = {0, 1, 0}, ch3 = {3, 1, 0}; + px_midi_msg_t m; + + /* ---- decoder: good messages, the channel filter, and anything malformed or not ours ---- */ + m = px_midi_decode(&omni, pc_pkt(0, 12)); + ok(m.type == PXM_PC && m.d1 == 12, "Program Change 12 is understood"); + m = px_midi_decode(&omni, cc_pkt(5, 20, 127)); + ok(m.type == PXM_CC && m.d1 == 20 && m.d2 == 127, "Control Change 20 = 127 on channel 6 is understood (omni)"); + m = px_midi_decode(&ch3, cc_pkt(2, 20, 127)); + ok(m.type == PXM_CC, "channel 3 setting: a message on channel 3 passes"); + m = px_midi_decode(&ch3, cc_pkt(3, 20, 127)); + ok(m.type == PXM_IGNORE, "channel 3 setting: a message on channel 4 is ignored"); + m = px_midi_decode(&ch3, pk(0xF, 0xF8, 0, 0)); + ok(m.type == PXM_CLOCK, "the clock passes on any channel setting"); + ok(px_midi_decode(&omni, pk(0xF, 0xFA, 0, 0)).type == PXM_START && px_midi_decode(&omni, pk(0xF, 0xFB, 0, 0)).type == PXM_START && + px_midi_decode(&omni, pk(0xF, 0xFC, 0, 0)).type == PXM_STOP, "start / continue / stop"); + ok(px_midi_decode(&omni, pk(0xF, 0xFE, 0, 0)).type == PXM_IGNORE && px_midi_decode(&omni, pk(0xF, 0xFF, 0, 0)).type == PXM_IGNORE, + "active sensing and system reset are ignored"); + ok(px_midi_decode(&omni, pk(0x9, 0x90, 60, 100)).type == PXM_IGNORE, "a Note On is ignored"); + ok(px_midi_decode(&omni, pk(0xE, 0xE0, 0, 64)).type == PXM_IGNORE, "a pitch bend is ignored"); + ok(px_midi_decode(&omni, pk(0xB, 0xC0, 20, 1)).type == PXM_IGNORE, "bad: code index Control Change but a Program Change status"); + ok(px_midi_decode(&omni, cc_pkt(0, 20, 0x80)).type == PXM_IGNORE && px_midi_decode(&omni, cc_pkt(0, 0x94, 5)).type == PXM_IGNORE, + "bad: a data byte with the top bit set"); + ok(px_midi_decode(&omni, pk(0x4, 0xF0, 0x7D, 0x4F)).type == PXM_IGNORE && px_midi_decode(&omni, pk(0x7, 0x50, 0x01, 0xF7)).type == PXM_IGNORE, + "SysEx packets never decode to a musical message"); + ok(px_midi_decode(&omni, 0).type == PXM_IGNORE, "an empty packet is ignored"); + { /* a cable number other than 0 does not change the meaning */ + uint32_t i, good = 1; + for (i = 0; i < 16; i++) good &= px_midi_decode(&omni, cc_pkt(0, 20, 127) | i << 4).type == PXM_CC; + ok(good, "the cable number (high nibble of byte 0) is not used"); + } + + /* ---- the map: unique, in range, clear of the reserved numbers, and it round-trips ---- */ + { + px_cc_t t; + uint8_t used[128]; + uint32_t b, k, n = 0, dup = 0, bad = 0; + static const uint8_t RESERVED[] = {0, 6, 32, 38, 64, 65, 66, 67, 68, 69, 98, 99, 100, 101}; + memset(used, 0, sizeof used); + px_cc_build(); + for (b = 0; b < PX_NBLK; b++) { + uint32_t cs[2 + PX_NPAR], i; + t.blk = (uint8_t)b; t.k = 0; + t.kind = PXM_ON; cs[0] = px_cc_of(&t); + t.kind = PXM_TYPE; cs[1] = px_cc_of(&t); + for (k = 0; k < PX_NPAR; k++) { t.kind = PXM_PARAM; t.k = (uint8_t)k; cs[2 + k] = px_cc_of(&t); } + for (i = 0; i < 2 + PX_NPAR; i++) { + n++; + if (cs[i] > 119u) bad++; + if (used[cs[i]]++) dup++; + for (k = 0; k < sizeof RESERVED; k++) if (RESERVED[k] == cs[i]) bad++; + } + } + ok(n == 84 && !dup, "84 settings, each on its own Control Change number"); + ok(!bad, "none above 119 (120..127 are channel-mode messages) or on a reserved number (bank, data entry, pedals, RPN)"); + ok(!used[PXM_CC_MASTER], "7 (volume) is not used by a block"); + bad = 0; + for (b = 0; b < 128; b++) { + if (!used[b]) { if (px_cc_tab[b].kind != PXM_NONE && b != PXM_CC_MASTER) bad++; continue; } + } + for (b = 0; b < PX_NBLK; b++) { + t.blk = (uint8_t)b; t.k = 2; t.kind = PXM_PARAM; + if (px_cc_tab[px_cc_of(&t)].kind != PXM_PARAM || px_cc_tab[px_cc_of(&t)].blk != b || px_cc_tab[px_cc_of(&t)].k != 2) bad++; + } + ok(!bad, "the table by number is the exact inverse of the table by setting"); + } + { /* value scaling: every pedal value goes out and comes back unchanged */ + uint32_t v, t, n, bad = 0, badt = 0; + for (v = 0; v <= 100; v++) if (pxm_from_cc(pxm_to_cc(v)) != v || pxm_to_cc(v) > 127) bad++; + ok(!bad, "settings 0..100 -> 0..127 -> 0..100 round-trip exactly"); + for (n = 1; n <= 40; n++) for (t = 0; t < n; t++) if (pxm_type_from_cc(pxm_type_to_cc(t, n), n) != t) badt++; + ok(!badt, "model numbers round-trip for every block size up to 40"); + ok(pxm_from_cc(0) == 0 && pxm_from_cc(127) == 100 && pxm_from_cc(64) == 50, "0 -> 0, 127 -> 100, 64 -> 50"); + } + + /* ---- applying a Control Change ---- */ + { + px_preset_t p; + uint32_t master = 77; + int mset = 0, ch; + px_preset_factory(&p, 0); + p.on[PX_B_AMP] = 1; + ch = px_midi_cc_apply(&p, 20 + PX_B_AMP, 0, &master, &mset); + ok(ch == 1 && p.on[PX_B_AMP] == 0, "CC 25 = 0 switches the amp off"); + ch = px_midi_cc_apply(&p, 20 + PX_B_AMP, 63, &master, &mset); + ok(ch == 0 && p.on[PX_B_AMP] == 0, "63 is still off (the threshold is 64)"); + ch = px_midi_cc_apply(&p, 20 + PX_B_AMP, 64, &master, &mset); + ok(ch == 1 && p.on[PX_B_AMP] == 1, "64 switches it on"); + ch = px_midi_cc_apply(&p, 20 + PX_B_AMP, 127, &master, &mset); + ok(ch == 0, "the same state again reports no change"); + { + px_cc_t t = {PXM_PARAM, PX_B_AMP, 1}; + ch = px_midi_cc_apply(&p, px_cc_of(&t), 127, &master, &mset); + ok(ch == 1 && p.p[PX_B_AMP][1] == 100, "the amp's 2nd setting at 127 is 100"); + } + { + px_cc_t t = {PXM_TYPE, PX_B_AMP, 0}; + uint32_t n = (uint32_t)px_ntypes(PX_B_AMP); + ch = px_midi_cc_apply(&p, px_cc_of(&t), 127, &master, &mset); + ok(ch == 1 && p.type[PX_B_AMP] == n - 1u, "the amp model at 127 is its last one"); + ch = px_midi_cc_apply(&p, px_cc_of(&t), 0, &master, &mset); + ok(ch == 1 && p.type[PX_B_AMP] == 0, "and at 0 its first"); + } + ch = px_midi_cc_apply(&p, 7, 127, &master, &mset); + ok(ch == 0 && mset && master == 100, "CC 7 sets the MASTER volume (0..100), not the sound"); + { + px_preset_t before = p; + uint32_t other[] = {0, 1, 6, 11, 32, 64, 66, 98, 99, 120, 121, 123, 127}, i, same = 1; + for (i = 0; i < sizeof other / sizeof *other; i++) same &= px_midi_cc_apply(&p, other[i], 100, &master, &mset) == 0; + ok(same && !memcmp(&before, &p, sizeof p), "bank, mod wheel, sustain, RPN, 'all notes off': nothing changes"); + } + ok(px_midi_cc_apply(&p, 200, 5, &master, &mset) == 0 && px_midi_cc_apply(&p, 20, 200, &master, &mset) == 0, "out of range numbers change nothing"); + } + + /* ---- what the pedal sends ---- */ + { + px_preset_t cur; + px_midi_tx_t tx; + px_midi_cfg_t cfg = {0, 1, 0}, off = {0, 0, 0}, c5 = {5, 1, 0}; + uint32_t pkt, n; + px_preset_factory(&cur, 3); + memset(&tx, 0, sizeof tx); + ok(px_midi_tx_next(&tx, &cfg, &cur, 3) == 0 && tx.valid, "first look after boot: nothing is sent for the sound it starts on"); + ok(px_midi_tx_next(&tx, &cfg, &cur, 3) == 0, "nothing changed: nothing sent"); + px_preset_factory(&cur, 7); /* the pedal has loaded slot 7's sound when it asks */ + pkt = px_midi_tx_next(&tx, &cfg, &cur, 7); + ok(pkt == pc_pkt(0, 7), "a new slot sends Program Change (slot 8 = number 7) on channel 1 when omni"); + ok(px_midi_tx_next(&tx, &cfg, &cur, 7) == 0, "the new slot's sound is not sent as Control Changes after it"); + cur.p[PX_B_DELAY][0] = (uint8_t)(cur.p[PX_B_DELAY][0] ^ 1); + { + px_cc_t t = {PXM_PARAM, PX_B_DELAY, 0}; + pkt = px_midi_tx_next(&tx, &cfg, &cur, 7); + ok(pkt == cc_pkt(0, px_cc_of(&t), pxm_to_cc(cur.p[PX_B_DELAY][0])), "a turned setting sends its Control Change"); + } + ok(px_midi_tx_next(&tx, &cfg, &cur, 7) == 0, "...once"); + cur.on[PX_B_REVERB] ^= 1; + pkt = px_midi_tx_next(&tx, &cfg, &cur, 7); + ok(pkt == cc_pkt(0, 20 + PX_B_REVERB, cur.on[PX_B_REVERB] ? 127 : 0), "a block switched sends 127 or 0"); + cur.type[PX_B_AMP] = (uint8_t)((cur.type[PX_B_AMP] + 1) % px_ntypes(PX_B_AMP)); + pkt = px_midi_tx_next(&tx, &cfg, &cur, 7); + ok((pkt & 15u) == 0xB && ((pkt >> 16) & 0xFF) == 52u + PX_B_AMP, "a new model sends the model Control Change (52 + block)"); + n = 0; + for (uint32_t b = 0; b < PX_NBLK; b++) cur.p[b][4] = (uint8_t)(cur.p[b][4] ^ 1), n++; + { + uint32_t sent = 0; + while (px_midi_tx_next(&tx, &cfg, &cur, 7)) sent++; + ok(sent == n, "12 changed settings are 12 messages, then silence"); + } + px_midi_tx_sync(&tx, &cur, 7); + cur.p[PX_B_AMP][0] ^= 1; + px_midi_tx_sync(&tx, &cur, 7); + ok(px_midi_tx_next(&tx, &cfg, &cur, 7) == 0, "a change made by the host (synced) is not sent back"); + pkt = px_midi_tx_next(&tx, &c5, &cur, 9); + ok(pkt == pc_pkt(4, 9), "channel 5 setting: Program Change goes out on channel 5"); + cur.p[PX_B_AMP][0] ^= 1; + ok(px_midi_tx_next(&tx, &off, &cur, 9) == 0 && px_midi_tx_next(&tx, &cfg, &cur, 9) == 0, + "SEND off: nothing goes out, and the changes made meanwhile are not sent when it is switched on again"); + } + + /* ---- a received message and the pedal's own message agree ---- */ + { + px_preset_t a, b; + px_midi_tx_t tx; + px_midi_cfg_t cfg = {0, 1, 0}; + uint32_t master = 0, bad = 0, i, sent = 0, want = 0, pkt; + int mset = 0; + px_preset_factory(&a, 20); px_preset_factory(&b, 20); + for (i = 0; i < PX_NBLK; i++) { + b.on[i] ^= 1; b.p[i][1] = (uint8_t)((b.p[i][1] + 37) % 101); b.type[i] = (uint8_t)((b.type[i] + 1) % px_ntypes((int)i)); + want += 2u + (b.type[i] != a.type[i]); + } + memset(&tx, 0, sizeof tx); + px_midi_tx_sync(&tx, &a, 20); + while ((pkt = px_midi_tx_next(&tx, &cfg, &b, 20))) { + px_midi_msg_t m = px_midi_decode(&cfg, pkt); + sent++; + if (m.type != PXM_CC) { bad++; continue; } + px_midi_cc_apply(&a, m.d1, m.d2, &master, &mset); + } + ok(sent == want && want >= 24 && !bad && !memcmp(a.on, b.on, sizeof a.on) && !memcmp(a.type, b.type, sizeof a.type) && !memcmp(a.p, b.p, sizeof a.p), + "what the pedal sends, a second pedal applies: the same sound (on, models, settings)"); + } + + /* ---- the settings byte ---- */ + { + px_midi_cfg_t c, d; + uint32_t i, bad = 0; + for (i = 0; i < 256; i++) { + px_midi_cfg_unpack(&c, (uint8_t)i); + if (c.chan > 16) bad++; + d = c; + if (px_midi_cfg_pack(&d) != px_midi_cfg_pack(&c)) bad++; + } + ok(!bad, "any stored byte unpacks to a legal setting, and packing it again is stable"); + px_midi_cfg_unpack(&c, 0); + ok(c.chan == 0 && c.send == 1 && c.clock == 0, "0 (every earlier save) = omni, send on, clock sync off"); + px_midi_cfg_unpack(&c, 0xFF); + ok(c.chan == 0 && !c.send && c.clock, "a value this firmware never wrote is omni, not a wild channel"); + c.chan = 16; c.send = 0; c.clock = 1; + px_midi_cfg_unpack(&d, px_midi_cfg_pack(&c)); + ok(d.chan == 16 && !d.send && d.clock, "channel 16, send off, clock on survive a save"); + } + + /* ---- MIDI clock -> a quarter note ---- */ + { + px_midi_clock_t c; + uint32_t i, got = 0, last = 0; + px_midi_clock_reset(&c); + for (i = 0; i < 24 * 8; i++) { /* 120 BPM: 20.833 ms per clock, in whole milliseconds */ + uint32_t q = px_midi_clock_tick(&c, 1000u + (i * 20833u + 500u) / 1000u); + if (q) { got++; last = q; } + } + ok(got >= 5 && last >= 499 && last <= 501, "120 BPM: a quarter note of 500 ms, reported after two agreeing runs"); + px_midi_clock_reset(&c); + got = 0; + for (i = 0; i < 24 * 4; i++) if (px_midi_clock_tick(&c, 5000u + i * 10u)) got++; + ok(got > 0, "250 BPM (10 ms per clock, a 240 ms quarter note) is followed"); + px_midi_clock_reset(&c); + got = 0; + for (i = 0; i < 24 * 4; i++) if (px_midi_clock_tick(&c, 5000u + i * 1u)) got++; + ok(got == 0, "absurd tempo (24 ms per beat) is refused"); + px_midi_clock_reset(&c); + got = 0; + for (i = 0; i < 24 * 6; i++) if (px_midi_clock_tick(&c, 9000u + i * 40u + (i % 5u))) got++; + ok(got > 0, "1 ms of jitter on each clock does not stop the sync"); + px_midi_clock_reset(&c); + got = 0; + { /* 20 ms per clock for one beat, 30 ms for the next, and so on: 480 ms, 720 ms, 480 ms... */ + uint32_t ms = 20000u; + for (i = 0; i < 24 * 8; i++) { + if (px_midi_clock_tick(&c, ms)) got++; + ms += ((i / 24u) % 2u) ? 30u : 20u; + } + } + ok(got == 0, "a tempo that keeps changing is not followed"); + px_midi_clock_reset(&c); + { /* a gap of more than 100 ms between clocks starts over: no run is counted across it */ + for (i = 0; i < 20; i++) px_midi_clock_tick(&c, 30000u + i * 20u); + got = px_midi_clock_tick(&c, 31000u); + ok(got == 0 && c.n == 1, "a gap (the host stopped sending) starts a new run"); + } + ok(px_dly_value_for_ms(PX_DLY_DIGITAL, 500.0f) >= 38 && px_dly_value_for_ms(PX_DLY_DIGITAL, 500.0f) <= 40, + "the delay TIME nearest to 500 ms (20 + 12.25 v: v = 39)"); + ok(px_dly_value_for_ms(PX_DLY_DIGITAL, 2000.0f) == 100 && px_dly_value_for_ms(PX_DLY_DIGITAL, 1.0f) == 0, "the ends of the scale"); + ok(px_dly_value_for_ms(PX_DLY_REVERSE, 100.0f) == 0, "REVERSE has its own scale (250 ms and up)"); + } + + printf("\n%s\n", failed ? "MIDI tests FAILED" : "all MIDI tests passed"); + return failed ? 1 : 0; +} From b1d2c41491e4a5f84ac7c413cb9b108f8ed479ba Mon Sep 17 00:00:00 2001 From: Vince Speelman Date: Thu, 5 Nov 2026 20:15:10 -0500 Subject: [PATCH 2/5] MIDI over USB-C: drain usb.c's ring, Program / Control Change in, the pedal's changes out, MIDI page, settings in the store usb.c is unchanged. Nothing emptied its MIDI input ring, which also stalls the editor's and the installer's SysEx when it fills; px_midi_service now drains it every pass. FX held 0.8 s opens the MIDI page (channel, send, clock to delay); the settings are one byte in the preset store's header (0 = defaults, as every earlier save). --- firmware/openpocket.c | 28 +++++- firmware/px_midi_io.h | 72 +++++++++++++++ firmware/px_store.h | 7 +- firmware/ui.h | 65 ++++++++++++- tests/edsim.c | 1 + tests/midiio.c | 193 +++++++++++++++++++++++++++++++++++++++ tests/run_tests.sh | 11 ++- tests/test_installer.mjs | 3 +- tests/test_store.c | 45 ++++++++- tests/uisim.c | 17 ++++ 10 files changed, 431 insertions(+), 11 deletions(-) create mode 100644 firmware/px_midi_io.h create mode 100644 tests/midiio.c diff --git a/firmware/openpocket.c b/firmware/openpocket.c index 2a1956f..8adc36d 100644 --- a/firmware/openpocket.c +++ b/firmware/openpocket.c @@ -253,6 +253,7 @@ static void lcd_text(uint32_t x, uint32_t y, const char *str, uint16_t fg, uint1 } /* ---- preset: the v2 engine's struct (engine/px_dsp.h), edited directly by the UI ---- */ #include "px_dsp.h" +#include "px_midi.h" /* MIDI map and settings (docs/MIDI.md) */ static px_preset_t PRESET; static uint32_t cur_preset = 0; static uint32_t master_vol = 80; /* 0..100, follows the MASTER knob (ADC channel 2) */ @@ -302,8 +303,13 @@ static void fm1_fault(const fm1_crash_t *c) #define PH_PU (*(volatile uint32_t *)0x501D0u) #define PH_DIR (*(volatile uint32_t *)0x501C8u) #define ENC_MASK 0x3u /* PC0=A, PC1=B */ +#ifndef OP_VERSION_NUM /* a test channel build (firmware/build.sh, OP_CHANNEL) sets its own number and tag */ #define OP_VERSION_NUM "36" -#define OP_VERSION "v" OP_VERSION_NUM /* shown at boot: changes with every released build */ +#endif +#ifndef OP_TAG +#define OP_TAG "" +#endif +#define OP_VERSION "v" OP_VERSION_NUM OP_TAG /* shown at boot: changes with every released build */ #define PROBE 0 /* 1 = GPIO diagnostic: shows the pins that change at the bottom */ #define ADC_PROBE 0 /* 1 = ADC diagnostic: shows the channels that move at the bottom (C2:512) */ #define PA_PD (*(volatile uint32_t *)0x50014u) @@ -542,6 +548,7 @@ static const int8_t QDEC[16] = {0,-1,1,0, 1,0,0,-1, -1,0,0,1, 0,1,-1,0}; static uint32_t ed_load(void) { return a_bypass ? a_peak : a_load; } /* PING: the DSP load, as at the bottom right */ static uint32_t ed_flags(void) { return (a_bypass ? 1u : 0u) | (a_c1 ? 2u : 0u); } #include "px_editor.h" /* the web editor (docs/EDITOR-PROTOCOL.md) */ +#include "px_midi_io.h" /* musical MIDI over USB (docs/MIDI.md) */ #endif #endif @@ -564,8 +571,8 @@ static void app_main(void) dbn_t btn[NBTN] = {{0,0,0}}; dbn_t bhome = {0,0,0}; uint32_t adc_tick = 0, home_t = 0, t_knob = 0, t_tuner = 0; - int home_down = 0, home_long = 0, push_down = 0, push_long = 0; - uint32_t push_t = 0, clk_t = 0; + int home_down = 0, home_long = 0, push_down = 0, push_long = 0, fx_down = 0, fx_long = 0; + uint32_t push_t = 0, clk_t = 0, fx_t = 0; int clk_pend = 0; /* chain page: a PUSH waiting to see if a 2nd one makes it a double-click */ int32_t master_raw = -100; #if DSP_CPU1 @@ -660,6 +667,7 @@ static void app_main(void) fm1_wdt_feed(); #if !AUDIO_PROBE px_usb_service(now); /* web installer: handshake, update, UBOOT soft key */ + px_midi_service(); /* USB-MIDI: Program / Control Change in, the pedal's own changes out */ audio_poll(now); /* the sound follows the UI (preset, settings, volume) */ if (ui_mode != M_TUNER) { /* DSP load at the bottom right: "C1 42%" (2nd core), "C0" or BYP */ static uint32_t t_cpu; @@ -702,7 +710,7 @@ static void app_main(void) if (accum >= 4) { accum -= 4; dd = 1; } else if (accum <= -4) { accum += 4; dd = -1; } if (dd) { int fast = (now - t_knob) < 45u; t_knob = now; - if (ui_mode == M_SAVE) save_knob(dd); else if (ui_mode != M_TUNER) ui_knob(dd, fast); + if (ui_mode == M_SAVE) save_knob(dd); else if (ui_mode == M_MIDI) midi_knob(dd); else if (ui_mode != M_TUNER) ui_knob(dd, fast); any = 1; } } @@ -716,7 +724,12 @@ static void app_main(void) if (!push_now && push_down) { push_down = 0; if (!push_long) p_push = 1; } int p_up = dbn_press(&btn[B_FSUP], (pa & PA_FS_UP) != 0); int p_dn = dbn_press(&btn[B_FSDN], (pa & PA_FS_DN) != 0); - int p_fx = dbn_press(&btn[B_FX], (pa & PA_FX) != 0); + /* FX: a short press (on release) cycles the effects, a long one (0.8 s) opens the MIDI settings */ + dbn_press(&btn[B_FX], (pa & PA_FX) != 0); + int fx_now = btn[B_FX].stab != 0, p_fx = 0, p_midi = 0; + if (fx_now && !fx_down) { fx_down = 1; fx_long = 0; fx_t = now; } + if (fx_now && fx_down && !fx_long && now - fx_t >= 800u) { fx_long = 1; p_midi = 1; } + if (!fx_now && fx_down) { fx_down = 0; if (!fx_long) p_fx = 1; } int p_mod = dbn_press(&btn[B_MOD], (ph & PH_MOD) != 0); int p_amp = dbn_press(&btn[B_AMP], (ph & PH_AMP) != 0); /* HOME: short press = back to the chain, long press (0.7 s) = tuner */ @@ -744,6 +757,11 @@ static void app_main(void) if (ui_mode == M_SAVE) { if (p_push) save_confirm(); else if (p_home) save_close(); + } else if (ui_mode == M_MIDI) { + if (p_push) midi_push(); + else if (p_home) midi_close(); + } else if (p_midi && ui_mode != M_TUNER && ui_mode != M_MOVE) { + midi_open(); } else if (ui_mode == M_TUNER) { if (p_push || p_save || p_up || p_dn || p_fx || p_mod || p_amp || p_cab || p_dly || p_rev || p_home) tuner_close(); } else if (p_save) { diff --git a/firmware/px_midi_io.h b/firmware/px_midi_io.h new file mode 100644 index 0000000..0ab50d7 --- /dev/null +++ b/firmware/px_midi_io.h @@ -0,0 +1,72 @@ +/* SPDX-License-Identifier: GPL-3.0-only + * OpenPocket — musical MIDI over USB: the glue between usb.c's rings and the pedal (docs/MIDI.md). + * + * usb.c (felucca-bsp, unchanged) puts the channel voice messages and the clock / transport bytes of the host into + * midi_in_q (4-byte USB-MIDI event packets, with the millisecond they arrived) and sends what is put in midi_out_q. + * SysEx never takes this road: it goes to the update code (ota.c) and the editor (px_editor.h) only. + * The including file provides px_midi.h, usb.c's rings, PRESET, cur_preset, load_preset, master_vol, ui_mode / ui_item / + * ui_nitems (ui.h), st_midi and st_commit (px_store.h), and ms_now. + * + * The ring must be emptied: usb.c leaves the host's USB packet unread (NAK) while the ring is nearly full, and the + * editor's and installer's SysEx come in the same packets. Nothing drained it before this file. + */ +#pragma once + +static px_midi_tx_t mid_tx; +static px_midi_clock_t mid_clk; + +static void midi_persist(void) /* the page's HOME: save the settings (the sound is not touched) */ +{ + st_midi = px_midi_cfg_pack(&mid_cfg); + ft_box(0, 186, 240, "SAVING...", &FT_MD, UI_ACC, UI_BG, 1); + if (st_ok) (void)st_commit(); +} + +/* every main-loop pass */ +static void px_midi_service(void) +{ + /* the sound may be changed from the host only while the pedal is on a normal page */ + int free_ui = ui_mode != M_SAVE && ui_mode != M_TUNER && ui_mode != M_MOVE && ui_mode != M_MIDI; + int touched = 0; + mid_usb_up = (uint8_t)usb_midi_out_ready(); + while (mi_r != mi_w) { /* usb.c's timer interrupt is the only writer */ + uint32_t at = mi_r % MQ, pkt = midi_in_q[at], ms = midi_in_ms[at]; + px_midi_msg_t m; + RING_PUBLISH(); + mi_r++; + m = px_midi_decode(&mid_cfg, pkt); + if (m.type == PXM_IGNORE) continue; + if (m.type == PXM_CLOCK) { + uint32_t q = mid_cfg.clock && free_ui ? px_midi_clock_tick(&mid_clk, ms) : 0u; + if (q) { + int v = px_dly_value_for_ms(PRESET.type[PX_B_DELAY], (float)q); + if (PRESET.p[PX_B_DELAY][0] != v) { PRESET.p[PX_B_DELAY][0] = (uint8_t)v; touched = 1; } + } + continue; + } + if (m.type == PXM_START || m.type == PXM_STOP) { px_midi_clock_reset(&mid_clk); continue; } + mid_last[0] = m.type; mid_last[1] = m.d1; mid_last[2] = m.d2; + if (m.type == PXM_PC) { + if (free_ui && m.d1 < px_preset_count()) { + cur_preset = m.d1; + load_preset(cur_preset); + if (ui_mode == M_EDIT) ui_mode = M_BLOCK; + if ((int)ui_item >= ui_nitems()) ui_item = 0; + touched = 1; + } + } else { + uint32_t mv = master_vol; + int mset = 0; + if (free_ui && px_midi_cc_apply(&PRESET, m.d1, m.d2, &mv, &mset)) touched = 1; + else if (mset) master_vol = mv; + } + } + if (midi_in_overflow && mi_r == mi_w) midi_in_overflow = 0; /* usb.c stopped queueing on overflow: start over */ + if (touched) px_midi_tx_sync(&mid_tx, &PRESET, cur_preset); /* what the host sent is not sent back */ + if (!usb_midi_out_ready()) { px_midi_tx_sync(&mid_tx, &PRESET, cur_preset); return; } + while (midi_out_room()) { + uint32_t pkt = px_midi_tx_next(&mid_tx, &mid_cfg, &PRESET, cur_preset); + if (!pkt) break; + midi_out_try(pkt); + } +} diff --git a/firmware/px_store.h b/firmware/px_store.h index 9c6ed51..02512bf 100644 --- a/firmware/px_store.h +++ b/firmware/px_store.h @@ -33,11 +33,13 @@ typedef char st_size_check[(sizeof(px_preset_t) < ST_SLOT && ST_NSLOT == 52u) ? #define ST_IRQ_OFF() __asm__ volatile("cli" ::: "memory") #endif +/* pad[0]: the MIDI settings (px_midi.h, px_midi_cfg_pack); 0 = the defaults, which is what every earlier save wrote */ typedef struct { uint32_t magic, seq; uint16_t crc; uint8_t ver, nslot, last, pad[3]; } st_hdr_t; /* 16 B */ static uint8_t st_slot[ST_NSLOT][ST_SLOT] __attribute__((aligned(4))); static uint32_t st_seq, st_cur; /* sequence of the copy in use, its base (0: none yet) */ static uint8_t st_ok; /* the store can be written (the flash is the expected part) */ static uint8_t st_last; /* the slot to start on (the last one saved) */ +static uint8_t st_midi; /* the MIDI settings byte (header pad[0]) */ static uint32_t st_slot_off(uint32_t base, uint32_t k) { @@ -130,6 +132,7 @@ static void st_load(void) best = BASE[i]; bseq = h.seq; st_last = h.last; + st_midi = h.pad[0]; bh = h; } } @@ -141,6 +144,7 @@ static void st_load(void) } else { memset(st_slot, 0xFF, sizeof st_slot); st_last = 0; + st_midi = 0; } if (st_last >= ST_NSLOT) st_last = 0; if (best && bh.ver < 3u) st_types_v34(); /* saved before v34: the old amp / drive / cab numbers */ @@ -180,7 +184,7 @@ static int st_commit(void) int rc = 0; if (!st_ok) return -1; memset(&h, 0, sizeof h); - h.magic = ST_MAGIC; h.seq = st_seq + 1u; h.crc = (uint16_t)st_crc(); h.ver = ST_VER; h.nslot = ST_NSLOT; h.last = st_last; + h.magic = ST_MAGIC; h.seq = st_seq + 1u; h.crc = (uint16_t)st_crc(); h.ver = ST_VER; h.nslot = ST_NSLOT; h.last = st_last; h.pad[0] = st_midi; st_audio_hold(1); if (fl_erase4k(base, &k) || fl_erase4k(base + 0x1000u, &k)) rc = -2; for (k = 0; !rc && k < ST_NSLOT; k++) { @@ -228,4 +232,5 @@ static void px_store_boot(void) ST_IRQ_OFF(); /* (ota_fread turned them on) */ cur_preset = st_last; load_preset(cur_preset); + px_midi_cfg_unpack(&mid_cfg, st_midi); } diff --git a/firmware/ui.h b/firmware/ui.h index 960d2e8..677b288 100644 --- a/firmware/ui.h +++ b/firmware/ui.h @@ -23,13 +23,14 @@ */ #pragma once -enum { M_CHAIN, M_BLOCK, M_EDIT, M_TUNER, M_SAVE, M_MOVE }; +enum { M_CHAIN, M_BLOCK, M_EDIT, M_TUNER, M_SAVE, M_MOVE, M_MIDI }; static uint32_t st_gen; /* bumped on each save: the "EDITED" reference reloads */ static void st_get(uint32_t k, px_preset_t *p); static int st_save(uint32_t k, const px_preset_t *p); static int st_reset(uint32_t k); static int st_saved(uint32_t k); static void save_draw(void); +static void midi_draw(void); static uint32_t ui_mode = M_CHAIN, ui_blk = PX_B_AMP, ui_item = 0; #define UI_PAGE (ui_mode == M_BLOCK || ui_mode == M_EDIT) /* the block page has the focus */ @@ -143,7 +144,7 @@ static void draw_status(int force) /* the DSP load, from the main loop: "C1 42%" (2nd core), "C0", "BYP" (red) */ static void ui_status(const char *s, int red) { - if (ui_mode == M_TUNER || ui_mode == M_SAVE) return; + if (ui_mode == M_TUNER || ui_mode == M_SAVE || ui_mode == M_MIDI) return; int same = uc.status_red == red; for (int i = 0; i < 12; i++) { if (uc.status[i] != s[i]) same = 0; if (!s[i]) break; } if (same && !uc.full) return; @@ -309,6 +310,7 @@ static void ui_render(void) { if (ui_mode == M_TUNER) return; if (ui_mode == M_SAVE) { save_draw(); return; } + if (ui_mode == M_MIDI) { midi_draw(); return; } int scr = UI_PAGE ? 1 : 0; if (scr != uc.screen) { uc.screen = scr; uc.full = 1; } int f = uc.full; @@ -456,6 +458,65 @@ static void save_confirm(void) for (int i = 0; i < 7; i++) { fm1_wdt_feed(); fm1_delay_ms(100); } save_close(); } +/* ===================================================== MIDI page ======== */ +/* hold FX: the MIDI settings (docs/MIDI.md). KNOB changes the selected row, PUSH moves to the next row and, after the + * last one, leaves; HOME leaves. The settings are saved (a flash write, as SAVE) only if something changed. */ +enum { MI_CHAN, MI_SEND, MI_CLOCK, MI_ROWS }; +static uint32_t mi_row, mi_shown_hash = ~0u; +static uint8_t mi_orig; +static void midi_persist(void); /* openpocket.c: writes the settings to the store */ +static void midi_open(void) +{ + ui_mode = M_MIDI; mi_row = 0; mi_shown_hash = ~0u; mi_orig = px_midi_cfg_pack(&mid_cfg); + lcd_rect(0, 0, 240, 240, UI_BG); + lcd_rect(0, 0, 240, 4, UI_ACC); lcd_rect(0, 27, 240, 3, UI_ACC); lcd_rect(0, 4, 8, 23, UI_ACC); + ft_box(8, 4, 232, "MIDI", &FT_MD, BLK, UI_ACC, 0); + lcd_rect(0, F_Y, 240, 240u - F_Y, UI_PANEL); + ft_box(0, F_Y + 3u, 240, "KNOB: SET PUSH: NEXT HOME: OK", &FT_SM, UI_DIM, UI_PANEL, 1); +} +static void midi_draw(void) +{ + uint32_t h = mi_row | (uint32_t)px_midi_cfg_pack(&mid_cfg) << 4 | (uint32_t)mid_usb_up << 12 | + (uint32_t)mid_last[0] << 13 | (uint32_t)mid_last[1] << 16 | (uint32_t)mid_last[2] << 23; + if (h == mi_shown_hash) return; + mi_shown_hash = h; + static const char *const NAME[MI_ROWS] = {"CHANNEL", "SEND", "CLOCK > DELAY"}; + char v[MI_ROWS][8]; + if (mid_cfg.chan) { v[0][0] = (char)(mid_cfg.chan >= 10 ? '0' + mid_cfg.chan / 10 : ' '); v[0][1] = (char)('0' + mid_cfg.chan % 10); v[0][2] = 0; } + else { v[0][0] = 'O'; v[0][1] = 'M'; v[0][2] = 'N'; v[0][3] = 'I'; v[0][4] = 0; } + v[1][0] = v[2][0] = 0; + b_str(v[1], mid_cfg.send ? "ON" : "OFF"); + b_str(v[2], mid_cfg.clock ? "ON" : "OFF"); + for (uint32_t r = 0; r < MI_ROWS; r++) { + uint32_t y = 44u + r * 36u; + uint16_t bg = r == mi_row ? C_CARD : UI_BG; + lcd_rect(0, y, 240, 32, bg); + ft_box(8, y + 4u, 130, NAME[r], &FT_SM, r == mi_row ? UI_TXT : UI_DIM, bg, 0); + ft_box(140, y + 2u, 92, v[r], &FT_MD, r == mi_row ? UI_ACC : UI_TXT, bg, 2); + } + ft_box(0, 160, 240, mid_usb_up ? "USB: HOST CONNECTED" : "USB: NO HOST", &FT_SM, mid_usb_up ? GRN : UI_DIM, UI_BG, 1); + char l[24]; l[0] = 0; + if (mid_last[0] == PXM_PC) { b_str(l, "LAST: PC "); b_num(l, mid_last[1] + 1u); } + else if (mid_last[0] == PXM_CC) { b_str(l, "LAST: CC "); b_num(l, mid_last[1]); b_str(l, " = "); b_num(l, mid_last[2]); } + else b_str(l, "LAST: NONE"); + ft_box(0, 186, 240, l, &FT_SM, UI_TXT, UI_BG, 1); +} +static void midi_close(void) +{ + ui_mode = M_CHAIN; ui_item = 0; + if (px_midi_cfg_pack(&mid_cfg) != mi_orig) midi_persist(); + lcd_rect(0, 0, 240, 240, UI_BG); + uc.screen = -1; + ui_invalidate(); +} +static void midi_knob(int dd) +{ + if (mi_row == MI_CHAN) mid_cfg.chan = (uint8_t)((mid_cfg.chan + 17u + (uint32_t)(dd > 0 ? 1 : 16)) % 17u); + else if (mi_row == MI_SEND) mid_cfg.send ^= 1u; + else mid_cfg.clock ^= 1u; +} +static void midi_push(void) { if (++mi_row >= MI_ROWS) midi_close(); } + static void ui_preset_step(int dd) { int n = px_preset_count(); diff --git a/tests/edsim.c b/tests/edsim.c index e9a3b43..0d12720 100644 --- a/tests/edsim.c +++ b/tests/edsim.c @@ -97,6 +97,7 @@ static int ota_wire_send(const uint8_t *p, uint32_t n) return 0; } +#include "../firmware/px_midi.h" #include "../firmware/px_store.h" #include "../firmware/px_editor.h" diff --git a/tests/midiio.c b/tests/midiio.c new file mode 100644 index 0000000..f14f163 --- /dev/null +++ b/tests/midiio.c @@ -0,0 +1,193 @@ +/* SPDX-License-Identifier: GPL-3.0-only + * OpenPocket — the glue between usb.c's MIDI rings and the pedal (firmware/px_midi_io.h) on a PC, with the rings, the + * screen mode and the store stubbed: what a host's Program Change, Control Change and clock do to the sound, when the + * pedal must ignore them, that the input ring is always emptied (usb.c stops reading the host's packets when it is + * nearly full, and the editor's SysEx arrives in the same packets), and what the pedal sends back. + * cc -O1 -Wall -Wno-unused-function -Iengine -Ifirmware -o build/midiio tests/midiio.c engine/px_dsp.c firmware/px_math.c -lm && build/midiio */ +#include +#include +#include +#include +#include "px_dsp.h" +#include "px_midi.h" + +static int failed; +static void ok(int c, const char *what) { printf("%-76s %s\n", what, c ? "ok" : "FAIL"); if (!c) failed++; } + +/* ---- usb.c's rings (same names, same meaning) ---- */ +#define MQ 64u +#define RING_PUBLISH() ((void)0) +static uint32_t midi_in_q[MQ], midi_out_q[MQ], midi_in_ms[MQ]; +static volatile uint32_t mi_w, mi_r, mo_w, mo_r; +static volatile uint8_t midi_in_overflow; +static int host_up = 1; +static int usb_midi_out_ready(void) { return host_up; } +static uint32_t midi_out_room(void) { return host_up ? MQ - (mo_w - mo_r) : 0u; } +static int midi_out_try(uint32_t pkt) +{ + if (!host_up || mo_w - mo_r >= MQ) return 0; + midi_out_q[mo_w % MQ] = pkt; + mo_w++; + return 1; +} +static int midi_enqueue(uint32_t pkt, uint32_t ms) /* as usb.c's midi_enqueue (+ the overflow rule) */ +{ + if (midi_in_overflow) return 0; + if (mi_w - mi_r >= MQ) { midi_in_overflow = 1; return 0; } + midi_in_q[mi_w % MQ] = pkt; + midi_in_ms[mi_w % MQ] = ms; + mi_w++; + return 1; +} +static uint32_t pop_out(void) { return mo_r != mo_w ? midi_out_q[mo_r++ % MQ] : 0; } + +/* ---- the pedal ---- */ +enum { M_CHAIN, M_BLOCK, M_EDIT, M_TUNER, M_SAVE, M_MOVE, M_MIDI }; +static px_preset_t PRESET; +static uint32_t cur_preset, master_vol = 80, ui_mode = M_CHAIN, ui_item; +static uint8_t st_midi, st_ok = 1; +static int commits; +static int ui_nitems(void) { return 2 + px_nparams(PX_B_AMP, PRESET.type[PX_B_AMP]); } +static void load_preset(uint32_t i) { px_preset_factory(&PRESET, (int)i); } +static int st_commit(void) { commits++; return 0; } +typedef struct { int dummy; } op_font; +static op_font FT_MD; +static uint32_t ft_box(uint32_t x, uint32_t y, uint32_t w, const char *s, const op_font *f, uint16_t fg, uint16_t bg, int a) +{ (void)x; (void)y; (void)w; (void)s; (void)f; (void)fg; (void)bg; (void)a; return 0; } +#define UI_ACC 0 +#define UI_BG 0 +#include "px_midi_io.h" + +static uint32_t cc(uint32_t ch, uint32_t n, uint32_t v) { return 0xBu | (0xB0u | ch) << 8 | n << 16 | v << 24; } +static uint32_t pc(uint32_t ch, uint32_t n) { return 0xCu | (0xC0u | ch) << 8 | n << 16; } +static void reset(void) +{ + mi_w = mi_r = mo_w = mo_r = 0; midi_in_overflow = 0; host_up = 1; ui_mode = M_CHAIN; ui_item = 0; master_vol = 80; + memset(&mid_cfg, 0, sizeof mid_cfg); mid_cfg.send = 1; + memset(&mid_tx, 0, sizeof mid_tx); px_midi_clock_reset(&mid_clk); + cur_preset = 5; load_preset(5); + px_midi_service(); /* first look: the pedal learns what it is playing */ +} + +int main(void) +{ + reset(); + midi_enqueue(pc(0, 12), 1); + px_midi_service(); + ok(cur_preset == 12, "Program Change 12 selects slot 13"); + { + px_preset_t f; px_preset_factory(&f, 12); + ok(!memcmp(&f, &PRESET, sizeof f), "the sound is slot 13's factory preset"); + } + ok(pop_out() == 0, "a Program Change from the host is not sent back"); + + reset(); + midi_enqueue(pc(0, 60), 1); + px_midi_service(); + ok(cur_preset == 5, "Program Change 60 (there are 52 slots) is ignored"); + + reset(); + midi_enqueue(cc(0, 20 + PX_B_AMP, 0), 1); + midi_enqueue(cc(0, 7, 127), 2); + px_midi_service(); + ok(PRESET.on[PX_B_AMP] == 0 && master_vol == 100, "CC 25 = 0 switches the amp off; CC 7 = 127 sets MASTER to 100"); + ok(pop_out() == 0, "the host's changes are not echoed"); + + reset(); + PRESET.p[PX_B_DELAY][0] ^= 1; /* a knob turned on the pedal */ + px_midi_service(); + { + uint32_t o = pop_out(); + ok((o & 15u) == 0xB && ((o >> 16) & 0xFF) == px_cc_param(PX_B_DELAY, 0), "a knob turned on the pedal is sent as a Control Change"); + ok(pop_out() == 0, "...once"); + } + cur_preset = 9; load_preset(9); /* a footswitch */ + px_midi_service(); + ok(pop_out() == pc(0, 9) && pop_out() == 0, "a preset change on the pedal is one Program Change"); + + reset(); + host_up = 0; + PRESET.on[PX_B_DELAY] ^= 1; + px_midi_service(); + host_up = 1; + px_midi_service(); + ok(pop_out() == 0, "with no host connected nothing is queued, and nothing old is sent when one connects"); + + reset(); + ui_mode = M_SAVE; + midi_enqueue(pc(0, 20), 1); + midi_enqueue(cc(0, 20 + PX_B_AMP, 0), 2); + px_midi_service(); + ok(cur_preset == 5 && PRESET.on[PX_B_AMP] == 1, "the SAVE page is open: Program and Control Change are ignored"); + ok(mi_r == mi_w, "...but the ring is emptied"); + ui_mode = M_TUNER; + midi_enqueue(pc(0, 20), 1); + px_midi_service(); + ok(cur_preset == 5, "the tuner is open: ignored"); + ui_mode = M_MIDI; + midi_enqueue(pc(0, 20), 1); + px_midi_service(); + ok(cur_preset == 5, "the MIDI page is open: ignored"); + ui_mode = M_EDIT; ui_item = 7; + midi_enqueue(pc(0, 20), 1); + px_midi_service(); + ok(cur_preset == 20 && ui_mode == M_BLOCK && (int)ui_item < ui_nitems(), "a Program Change while editing goes back to the block page, the selection kept in range"); + + reset(); + mid_cfg.chan = 4; + midi_enqueue(pc(0, 11), 1); + midi_enqueue(pc(3, 12), 2); + px_midi_service(); + ok(cur_preset == 12, "channel 4: channel 1's Program Change is ignored, channel 4's is taken"); + + reset(); + { /* a flood (a DAW sending notes, clock and CCs the map does not use) is drained and never wedges the ring */ + uint32_t i; + for (i = 0; i < 1000; i++) { + midi_enqueue(0x9u | 0x90u << 8 | (i & 127u) << 16 | 100u << 24, i); + midi_enqueue(0xFu | 0xF8u << 8, i); + if (i % 40 == 0) { px_midi_service(); } + } + px_midi_service(); + ok(mi_r == mi_w && !midi_in_overflow, "after a flood the ring is empty and usb.c may queue again"); + ok(midi_enqueue(pc(0, 3), 3000) == 1, "and it accepts the next message"); + px_midi_service(); + ok(cur_preset == 3, "which is then taken"); + } + reset(); + { /* the flood fills the ring while the main loop is busy (a flash write): the overflow flag is cleared afterwards */ + uint32_t i; + for (i = 0; i < 100; i++) midi_enqueue(cc(0, 1, i & 127u), i); + ok(midi_in_overflow == 1, "(setup) the ring overflowed"); + px_midi_service(); + ok(!midi_in_overflow && mi_r == mi_w, "the overflow flag is cleared once the ring is empty"); + } + + reset(); + mid_cfg.clock = 1; + { + uint32_t i, ms = 1000; + for (i = 0; i < 24 * 5; i++) { midi_enqueue(0xFu | 0xF8u << 8, ms); ms += 21; px_midi_service(); } /* ~ 504 ms a quarter */ + { + int want = px_dly_value_for_ms(PRESET.type[PX_B_DELAY], 504.0f); + ok(PRESET.p[PX_B_DELAY][0] == want, "clock sync on: the delay TIME follows a 504 ms quarter note"); + } + ok(pop_out() == 0, "the tempo-derived change is not sent to the host"); + } + reset(); + for (uint32_t i = 0, ms = 1000; i < 24 * 5; i++, ms += 21) { midi_enqueue(0xFu | 0xF8u << 8, ms); px_midi_service(); } + { + px_preset_t f; px_preset_factory(&f, 5); + ok(PRESET.p[PX_B_DELAY][0] == f.p[PX_B_DELAY][0], "clock sync off (the default): the clock does nothing"); + } + + reset(); + { + uint32_t c0 = (uint32_t)commits; + mid_cfg.chan = 6; mid_cfg.send = 0; + midi_persist(); + ok(commits == (int)c0 + 1 && st_midi == px_midi_cfg_pack(&mid_cfg), "leaving the MIDI page writes the settings byte to the store, once"); + } + printf("\n%s\n", failed ? "MIDI glue tests FAILED" : "all MIDI glue tests passed"); + return failed ? 1 : 0; +} diff --git a/tests/run_tests.sh b/tests/run_tests.sh index ff82108..e394f92 100755 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -3,6 +3,7 @@ # tests/run_tests.sh [PocketX_016.fwsc] # - engine: every block x model x extreme knob, signal / silence / overload, with the pedal's own libm (px_math.c): # no NaN; long runs (3 s pluck + 8 s silence): nothing runs away (only delays at FDBK 100 hold on, by design) +# - MIDI: the musical-MIDI code (firmware/px_midi.h) on a PC: decoder, CC map, messages sent, settings byte, clock # - preset store: save / reload / power cut at every step of a save (firmware/px_store.h on a simulated flash) # - packages: tools/pxpkg.py rebuilds the official PocketX_016 app area byte for byte (needs the official file) # - installer: docs/pxota.js + docs/pxpkg.js against a simulated pedal running the real update code (ota.c, ldr_core.c) @@ -17,7 +18,15 @@ $CC -O2 -Iengine tests/engine/long.c engine/px_dsp.c firmware/px_math.c -lm -o b echo "== preset store" $CC -O1 -Iengine tests/test_store.c engine/px_dsp.c firmware/px_math.c -lm -o build/test_store && build/test_store | tail -2 echo "== screen (every page fully drawn)" -mkdir -p build/ui && $CC -O1 -Wall -Wno-unused-function -Iengine -Ifirmware -o build/uisim tests/uisim.c engine/px_dsp.c firmware/px_math.c -lm && build/uisim build/ui > build/ui/log.txt && if grep -q "never drawn" build/ui/log.txt; then grep "never drawn" build/ui/log.txt; echo "screen: FAIL"; else echo "screen: $(wc -l < build/ui/log.txt) pages ok (build/ui/*.ppm)"; fi +mkdir -p build/ui && $CC -O1 -Wall -Wno-unused-function -Iengine -Ifirmware -o build/uisim tests/uisim.c engine/px_dsp.c firmware/px_math.c -lm +build/uisim build/ui > build/ui/log.txt +if grep -q "never drawn" build/ui/log.txt; then grep "never drawn" build/ui/log.txt; echo "screen: FAIL"; exit 1; else echo "screen: $(wc -l < build/ui/log.txt) pages ok (build/ui/*.ppm)"; fi +echo "== MIDI (firmware/px_midi.h: decoder, Control Change map, what the pedal sends, settings, clock)" +for t in midisim midiio; do + $CC -O1 -Wall -Wno-unused-function -Iengine -Ifirmware -o build/$t tests/$t.c engine/px_dsp.c firmware/px_math.c -lm + build/$t > build/$t.log || { grep FAIL build/$t.log; exit 1; } + tail -1 build/$t.log +done echo "== simulated pedal" $CC -O1 -Wall -Wno-unused-function -o build/devsim tests/devsim.c if [ -n "$1" ]; then echo "== packages"; python3 tests/test_pxpkg.py "$1"; fi diff --git a/tests/test_installer.mjs b/tests/test_installer.mjs index cd7840b..661213f 100644 --- a/tests/test_installer.mjs +++ b/tests/test_installer.mjs @@ -78,10 +78,11 @@ ok(productOf(px) === "PocketX_924" && productOf(bb) === "BlackBox_924", "package const dump = join(TMP, "after_update.bin"); const sim = simAccess([join(TMP, "flash_old.bin"), "--dump", dump]); await sim.ready; + [...sim.access.outputs.values()][0].send([0xF0, 0x7E, 0x7F, 0x09, 0x01, 0xF7]); // a DAW's General MIDI reset: not ours const u = new Updater(sim.access, null, FAST); const steps = []; const who = await u.identify(); - ok(who && who.id.text === "PocketX_923" && IS_OPENPOCKET(who.id), "the pedal reports the OpenPocket it runs (PocketX_923)"); + ok(who && who.id.text === "PocketX_923" && IS_OPENPOCKET(who.id), "a foreign SysEx is dropped; the pedal reports the OpenPocket it runs (PocketX_923)"); let res = null, err = null; try { res = await u.install(logicalImage(px), "PocketX_924", (s) => steps.push(s)); } catch (e) { err = e; } ok(!err && res === "PocketX_924", `update 923 -> 924 ${err ? "(" + err.code + " " + err.message + ")" : ""}`); diff --git a/tests/test_store.c b/tests/test_store.c index c46d8e1..59b7db0 100644 --- a/tests/test_store.c +++ b/tests/test_store.c @@ -8,6 +8,7 @@ #include #include #include "px_dsp.h" +#include "../firmware/px_midi.h" static uint8_t nor[0x100000]; static int fails, ops, cut_at = -1, dead; /* power cut: after cut_at flash operations nothing more happens */ @@ -53,7 +54,7 @@ static void load_preset(uint32_t i) { st_get(i, &PRESET); } #define ST_IRQ_OFF() ((void)0) #include "../firmware/px_store.h" -static void reboot(void) { memset(st_slot, 0, sizeof st_slot); st_cur = st_seq = 0; st_last = 0; px_store_boot(); } +static void reboot(void) { memset(st_slot, 0, sizeof st_slot); st_cur = st_seq = 0; st_last = 0; st_midi = 0x55; mid_cfg.chan = 9; px_store_boot(); } static void loader_bytes_clear(void) { for (uint32_t s = 0xF0000u; s < 0xF4000u; s += 0x1000u) @@ -222,6 +223,48 @@ int main(void) loader_bytes_clear(); } loader_bytes_clear(); + + /* the MIDI settings ride in the header of the copy: saved with it, back after a power cycle */ + { + px_preset_t q; + px_preset_factory(&q, 4); + mid_cfg.chan = 7; mid_cfg.send = 0; mid_cfg.clock = 1; + st_midi = px_midi_cfg_pack(&mid_cfg); + CHECK(st_commit() == 0, "settings: a commit without a new sound"); + reboot(); + CHECK(mid_cfg.chan == 7 && !mid_cfg.send && mid_cfg.clock, "settings: channel 7, send off, clock on survive a power cycle"); + CHECK(st_save(4, &q) == 0, "settings: a preset save"); + reboot(); + CHECK(mid_cfg.chan == 7 && !mid_cfg.send && mid_cfg.clock, "settings: still there after a preset save (the header is rewritten with them)"); + { /* a store written before the settings existed has zero there: the defaults, not the last boot's values */ + st_hdr_t h; + uint32_t base = st_cur; + memcpy(&h, nor + base, sizeof h); + h.pad[0] = 0; + memcpy(nor + base, &h, sizeof h); + reboot(); + CHECK(mid_cfg.chan == 0 && mid_cfg.send && !mid_cfg.clock, "settings: a header with 0 there (every earlier OpenPocket) = omni, send on, clock off"); + } + { /* a power cut during the commit of settings: the old settings or the new, the presets intact */ + uint32_t olds = 0, news = 0, bad = 0; + for (int cut = 0; cut < 9000; cut += 11) { + uint8_t keep[0x100000]; + memcpy(keep, nor, sizeof nor); + mid_cfg.chan = 12; mid_cfg.send = 1; mid_cfg.clock = 0; + st_midi = px_midi_cfg_pack(&mid_cfg); + ops = 0; dead = 0; cut_at = cut; + st_commit(); + cut_at = -1; dead = 0; + reboot(); + if (mid_cfg.chan == 12) news++; else if (mid_cfg.chan == 0) olds++; else bad++; + st_get(4, &q); + if (!st_saved(4)) bad++; + memcpy(nor, keep, sizeof nor); + } + CHECK(bad == 0 && olds > 0 && news > 0, "settings: a power cut during a settings save leaves the old or the new, never a broken store"); + } + } + loader_bytes_clear(); printf(fails ? "%d FAILED\n" : "store tests passed\n", fails); return fails != 0; } diff --git a/tests/uisim.c b/tests/uisim.c index e2675bf..750fc4a 100644 --- a/tests/uisim.c +++ b/tests/uisim.c @@ -11,6 +11,7 @@ #include #include #include "px_dsp.h" +#include "../firmware/px_midi.h" enum { RED = 0xF800, GRN = 0x07E0, BLU = 0x001F, WHT = 0xFFFF, BLK = 0x0000, CYN = 0x07FF, YEL = 0xFFE0, ORG = 0xFD20 }; enum { UI_BG = 0x0841, UI_PANEL = 0x18E3, UI_SEL = 0xFB00, UI_ACC = 0xFD20, UI_TXT = 0xEF7D, UI_DIM = 0x8410, UI_OK = 0x4E6B, UI_OFF = 0x2124 }; @@ -52,6 +53,10 @@ static int a_level_db(int ch) { return ch ? -84 : -62; } static volatile uint8_t a_tmute; static int bat_mv = 3920, bat_usb_mv = 0; +static void b_num(char *d, uint32_t v) { char t[12]; int n = 0; while (*d) d++; do { t[n++] = (char)('0' + v % 10u); v /= 10u; } while (v); while (n) *d++ = t[--n]; *d = 0; } +static void b_str(char *d, const char *s) { while (*d) d++; while (*s) *d++ = *s++; *d = 0; } +static int midi_saved; +static void midi_persist(void) { midi_saved++; } #include "ui_text.h" #include "ui.h" @@ -105,5 +110,17 @@ int main(int argc, char **argv) tuner_open(); tuner_draw(110.5f); shot(d, "tuner_a_flat"); tuner_draw(110.0f); shot(d, "tuner_a_ok"); tuner_close(); poison(); ui_render(); status(); shot(d, "play_after_tuner"); + px_midi_cfg_unpack(&mid_cfg, 0); + midi_open(); midi_draw(); shot(d, "midi_defaults"); + midi_knob(1); midi_knob(1); midi_draw(); shot(d, "midi_channel_2"); + midi_push(); midi_knob(1); mid_usb_up = 1; mid_last[0] = PXM_CC; mid_last[1] = 25; mid_last[2] = 127; midi_draw(); shot(d, "midi_send_off_host_cc"); + midi_push(); midi_knob(1); mid_last[0] = PXM_PC; mid_last[1] = 11; midi_draw(); shot(d, "midi_clock_on_pc"); + midi_push(); poison(); uc.screen = -1; ui_invalidate(); ui_render(); status(); + if (midi_saved != 1 || ui_mode != M_CHAIN) { printf("midi: leaving the page after a change must save once and return to the chain\n"); return 1; } + midi_open(); midi_close(); + if (midi_saved != 1) { printf("midi: leaving without a change must not save\n"); return 1; } + mid_cfg.chan = 16; midi_open(); midi_knob(1); midi_draw(); shot(d, "midi_channel_wraps_to_omni"); + if (mid_cfg.chan != 0) { printf("midi: channel 16 + 1 must wrap to omni\n"); return 1; } + midi_knob(-1); if (mid_cfg.chan != 16) { printf("midi: omni - 1 must wrap to 16\n"); return 1; } return 0; } From fe7df6bf8788a6c75caf5c5c58193938f3eeb1dd Mon Sep 17 00:00:00 2001 From: Vince Speelman Date: Thu, 5 Nov 2026 20:15:10 -0500 Subject: [PATCH 3/5] MIDI test channel: v37 MIDI TEST package, installer ?channel=midi-test, docs and the hardware test plan OP_CHANNEL=midi-test builds firmware/openpocket-midi-test.bin and docs/firmware/midi-test/ with their own version and manifest; the release files and the default manifest are not touched. docs/MIDI.md (design, CC map, Bluetooth findings and future work), docs/MIDI-TEST.md (checklist for the captain), README and SOUNDS-AND-CONTROLS. --- .gitignore | 2 + Makefile | 1 + README.md | 3 + docs/BUILDING.md | 1 + docs/MIDI-TEST.md | 112 ++++++++++++++++++++ docs/MIDI.md | 200 ++++++++++++++++++++++++++++++++++++ docs/SOUNDS-AND-CONTROLS.md | 22 ++++ docs/firmware/README.md | 4 + docs/index.html | 12 ++- firmware/build.sh | 20 +++- tests/test_op.mjs | 9 +- tests/test_page.mjs | 17 ++- tools/op.mjs | 4 +- 13 files changed, 397 insertions(+), 10 deletions(-) create mode 100644 docs/MIDI-TEST.md create mode 100644 docs/MIDI.md diff --git a/.gitignore b/.gitignore index cd0c4ea..df9ac70 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ build/ firmware/build/ firmware/openpocket.bin +firmware/openpocket-*.bin +docs/firmware/midi-test/ docs/firmware/*.fwsc docs/firmware/manifest.json *.o diff --git a/Makefile b/Makefile index 591ee81..c75a2fe 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ # make build firmware/openpocket.bin, the update loader and docs/firmware/*.fwsc + manifest.json # make test the host tests (tests/run_tests.sh) # make package build + build/SHA256SUMS of the outputs +# make package-test the MIDI test channel package (docs/firmware/midi-test/, never the installer default) # make clean remove every build output (needs no Docker) # make shell a shell in the build container # make image (re)build the container image diff --git a/README.md b/README.md index 5e58bde..5a264cf 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@ Written from scratch for the pedal's own chip (JieLi AC791N, two cores), install Greenback, T75, Alnico Blue, Jensen, Cream, DI) with a MIC position - **Pro levels (v36)**: every amp, block and preset at the same loudness (-23 LUFS, measured on real guitar); 24 factory presets round the fitted amps, 28 USER slots; a PRESET LVL per preset; soft output limiter +- **MIDI over USB-C (test build v37)**: Program Change selects a preset, Control Change switches blocks and sets their models and + settings, the pedal tells the computer when you change the sound, MIDI clock sets the delay time. Class-compliant, no driver; + Bluetooth MIDI is not in yet. Reference: [docs/MIDI.md](docs/MIDI.md), hardware test: [docs/MIDI-TEST.md](docs/MIDI-TEST.md) - What changed since v33: [docs/WHATS-NEW-v34-v36.md](docs/WHATS-NEW-v34-v36.md) - **New screen (v36)**: a PLAY page readable from a distance (preset number and name, amp, chain in colour) and an EDIT page with big values and bars diff --git a/docs/BUILDING.md b/docs/BUILDING.md index de42e5f..13eada5 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -29,6 +29,7 @@ Apple silicon and other ARM machines Docker emulates it (Rosetta or QEMU); you c | `make doctor` | check Node, Docker and the image, one line each (exit 1 when the build cannot work) | | `make build` | `firmware/build.sh` in the container: `firmware/openpocket.bin`, the update loader, `docs/firmware/PocketX_9NN.fwsc`, `BlackBox_9NN.fwsc`, `manifest.json` | | `make package` | `build`, then `build/SHA256SUMS` | +| `make package-test` | the MIDI test channel (`OP_CHANNEL=midi-test`): `firmware/openpocket-midi-test.bin` and `docs/firmware/midi-test/` (v37 MIDI TEST, its own `manifest.json`), with `build/SHA256SUMS-midi-test`. The release files are not touched and the installer offers it only with `?channel=midi-test` | | `make test` | `tests/run_tests.sh` (engine, preset store, screen, simulated pedal, installer, editor; the browser page tests also run when Playwright is installed) | | `make clean` | remove every build output (on the host; needs neither Docker nor the image) | | `make shell` | a shell in the container (as your user) | diff --git a/docs/MIDI-TEST.md b/docs/MIDI-TEST.md new file mode 100644 index 0000000..28d5809 --- /dev/null +++ b/docs/MIDI-TEST.md @@ -0,0 +1,112 @@ +# MIDI over USB-C: hardware test plan + +For the first run of **OpenPocket v37 MIDI TEST** on a real pedal. Nothing here was tried on hardware yet: the pedal is the only +way to check the USB behaviour and the sound. The design is in [MIDI.md](MIDI.md). Takes about 45 minutes. Tick as you go and +send back the "report" block at the end. + +**Do this on one pedal first.** The Donner Pocket X is the better first choice: the M-VAVE BlackBox install is still being +tested (issue #1: a restart loop after power cycles in some versions, rescue mode on the installer page is the cure). + +## 0. Before you start + +- [ ] Know your way back (section 7). Have your official firmware file for the pedal at hand, and the SD-card recovery + (`flash/`, `fm1-1.bin`) if you made one. +- [ ] Chrome or Edge on a computer, a USB **data** cable, the pedal on and charged. +- [ ] A MIDI monitor: macOS: Audio MIDI Setup (Window > Show MIDI Studio) and the free MIDI Monitor; Windows: MIDI-OX or + Bome MIDI Translator; Linux: `aseqdump -l` then `aseqdump -p "OpenPocket"`. +- [ ] A DAW or a MIDI controller that can send Program Change and Control Change (Ableton, Logic, Reaper, GarageBand with a + MIDI app, or a free web tool such as the Web MIDI Monitor and sender pages). +- [ ] The package: a build of the pull request gives `docs/firmware/midi-test/` (`node tools/op.mjs package-test`), or the CI + artifact. Serve it with `node tools/op.mjs serve` (offline) and open `http://localhost:8765/index.html?channel=midi-test`, + or use the page you were given. + +## 1. Install the test build + +- [ ] Open the installer with `?channel=midi-test`. A warning banner says "TEST CHANNEL" and the package line says `v37 (midi-test)`. +- [ ] Connect the pedal, check that it is found, press Install, wait for "Done". Do not unplug while it works. +- [ ] The pedal restarts. The boot screen shows **v37 MIDI TEST**. +- [ ] The sound is normal: play a few presets, each block of one preset, the tuner (hold HOME). Note the number at the bottom + right ("C1 42%"): baseline DSP load with the same preset: ______ %. +- [ ] Open the web editor (`editor.html`): it connects, shows the presets, and a knob turned on the pedal moves on the page. + +## 2. The MIDI page + +- [ ] Hold FX for about a second: the **MIDI** page opens. Rows: CHANNEL OMNI, SEND ON, CLOCK > DELAY OFF. "USB: NO HOST" + or "HOST CONNECTED" is right. +- [ ] A short press of FX (not held) still cycles the effects (DRV, SFX, SYN...). It acts when you let go. +- [ ] Knob changes the row, PUSH goes to the next row, after the last row it leaves. HOME leaves too. +- [ ] Change CHANNEL to 3, leave. The screen shows "SAVING..." briefly, the sound mutes for a fraction of a second. Power + the pedal off and on: the page still says 3. + +Set it back to OMNI for the next steps. + +## 3. Over USB-C: the pedal receives + +Plug the pedal into the computer (the pedal should appear as a MIDI port named **OpenPocket**) and send: + +- [ ] **Program Change 0, 1, 2, 11**: the pedal shows preset 01, 02, 03, 12 each time, the sound follows. Program Change 60 does nothing. +- [ ] **CC 25 = 0 / 127** (AMP off / on): the AMP tile on the screen goes dark and back, the sound loses and gets its amp. +- [ ] **CC 77 sweep 0 to 127** (AMP setting 1, GAIN): the gain bar moves and the sound changes smoothly. No crackle, no + glitch, no mute. Also try CC 102 to 105 (DELAY settings), CC 106 (REVERB). +- [ ] **CC 57 = 0, 64, 127** (AMP model): the model changes (first, middle, last). +- [ ] **CC 7 sweep** (volume): the VOL number at the top follows. +- [ ] The MIDI page's "LAST:" line shows what you just sent (open it with FX held; it is read-only for the host while open). +- [ ] CHANNEL 3: a message on channel 1 does nothing, on channel 3 works. Back to OMNI. +- [ ] While the **SAVE page** (hold PUSH) is open, a Program Change from the computer is ignored. HOME to leave. +- [ ] **Clock:** CLOCK > DELAY ON, select a preset with the delay on (press DLY), start the DAW playing at 120 BPM: the delay + TIME shows about 500 ms (the nearest step is 498 ms) and follows a change of tempo to 90 BPM (about 670 ms). With + CLOCK > DELAY OFF nothing changes. + +## 4. Over USB-C: the pedal sends + +With the MIDI monitor listening to the pedal's port and SEND ON: + +- [ ] Footswitch up / down: one **Program Change** per press, with the slot number minus 1. +- [ ] Turn the knob on a block's page: **Control Change** messages with the numbers of [MIDI.md](MIDI.md). Switch a block: 127 / 0. +- [ ] Nothing is sent at power-on, and nothing when the computer was unplugged and is plugged back in. +- [ ] SEND OFF: the pedal sends nothing; it still receives. +- [ ] A change sent **to** the pedal is not sent back (watch the monitor while moving a DAW fader: no echo). +- [ ] Loop test: a DAW track that echoes everything it receives (MIDI through) back to the pedal does not make it chatter. + +## 5. Audio with MIDI flowing + +- [ ] Play a high-gain preset continuously for 5 minutes while the DAW sends a CC sweep and clock: no crackle, no dropout, no + mute, no "BYP" at the bottom right. Note the load: ______ % (baseline above). A rise of more than a few percent is worth reporting. +- [ ] Same with a long note flood (a keyboard or a DAW sending 100 notes a second): the pedal ignores the notes and the sound + stays clean. +- [ ] While the flood runs, use the **web editor** (change a preset, rename, save): it keeps working. (Before this release a + busy MIDI port could stop the editor.) + +## 6. The update path still works + +- [ ] On the normal installer page (without `?channel=`), Connect: the pedal is found and shows `OpenPocket v37`. Press Install + (this puts **v36** back) and wait for "Done": the boot screen shows v36, the sound is normal. +- [ ] Open the test page again and install v37 MIDI TEST once more: it also works in this direction. +- [ ] The editor still connects on both versions. + +## 7. Way back and recovery + +1. **To the previous OpenPocket:** the normal installer page (no `?channel=`), Install (v36). +2. **To the official firmware:** the installer page, step 3 "Back to the official firmware", with your official `.fwsc`. +3. **A pedal that restarts in a loop:** the installer's rescue mode (step 2), plug the pedal in; it installs by itself. +4. **A pedal that does not start at all:** the SD-card recovery (`flash/`, `makeimage.bat` / `node tools/op.mjs appimage`). + +This build does not change the boot, update-entry or recovery code, so all four work as for v36. + +## 8. Report back + +Please send, even if everything works: + +``` +Pedal: Pocket X / BlackBox Computer + OS: +Installed v37 MIDI TEST: yes/no Boot screen showed v37 MIDI TEST: yes/no +USB port name seen: Apps tried (DAW, monitor): +Section 2 (page): ok / what failed +Section 3 (receive): ok / what failed (which CC or PC numbers) +Section 4 (send): ok / what failed +Section 5: DSP load baseline ___ % with MIDI ___ %, any crackle / dropout / BYP: +Section 6 (update, both ways): ok / what failed +Anything strange on screen or in sound: +``` + +For a failure, add what the MIDI page's "LAST:" line showed and the exact bytes your monitor showed. A photo of the screen helps. diff --git a/docs/MIDI.md b/docs/MIDI.md new file mode 100644 index 0000000..3f733b6 --- /dev/null +++ b/docs/MIDI.md @@ -0,0 +1,200 @@ +# OpenPocket MIDI over USB-C + +OpenPocket answers MIDI Program Change and Control Change from a computer, a DAW or a controller over the USB-C cable, +and tells the computer when you change the sound on the pedal. This page is the design and the reference. The hardware +checklist for the first test is [MIDI-TEST.md](MIDI-TEST.md). + +**Status.** Written and tested on a PC (host simulators, see "How it was checked"). Not yet tried on a real pedal. It ships +as a separate test package (OpenPocket v37 MIDI TEST), not in the normal installer. **Bluetooth MIDI is not included**: see +"Bluetooth MIDI: why not yet". + +## What the pedal does + +**Receives** (USB, from the host): + +| Message | What happens | +| --- | --- | +| Program Change 0 to 51 | Loads preset slot 1 to 52, as the footswitches do (unsaved changes are dropped). 52 and above are ignored | +| Control Change, the map below | Switches a block on or off, picks a block's model, sets one of its 5 settings, or sets the MASTER volume | +| MIDI clock (24 per quarter note), when CLOCK > DELAY is on | Sets the delay TIME to one quarter note | +| Start, Continue, Stop | Restart the tempo measurement | +| Everything else | Ignored: notes, pitch bend, bank select (0 and 32), modulation, sustain, "all notes off", system messages | + +**Sends** (when SEND is on and a host is connected): + +| What you do on the pedal | What the computer receives | +| --- | --- | +| Change the preset (footswitch, knob, SAVE to another slot) | One Program Change with the slot number (0 to 51) | +| Turn a setting, switch a block, pick a model | One Control Change per change, with the value from the map | + +A change made by the host is not sent back. Changes made from the web editor are sent like changes made on the pedal. The +sound the pedal starts on is not sent at power-on. Nothing is sent while no host is connected, and nothing old is sent +when one connects. + +**Channel.** CHANNEL is OMNI (receive on any channel; send on channel 1) or 1 to 16 (receive and send on that channel only). +The clock and the transport bytes have no channel and are always taken. + +## The Control Change map + +Value scaling: a setting is 0 to 100 on the pedal and 0 to 127 in MIDI (`v * 127 / 100`, rounded; the way back is exact). +On or off: 64 and above is on, below 64 is off; the pedal sends 127 or 0. A model number n of N models is the slice +`n * 128 / N` to `(n + 1) * 128 / N - 1` of 0 to 127; the pedal sends the middle of the slice. Send the highest value to get the +last model. + +| Block | On / off | Model | Setting 1 | 2 | 3 | 4 | 5 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| GATE | 20 | 52 | 33 | 34 | 35 | 36 | 37 | +| COMP | 21 | 53 | 39 | 40 | 41 | 42 | 43 | +| PITCH | 22 | 54 | 44 | 45 | 46 | 47 | 48 | +| SFX | 23 | 55 | 49 | 50 | 51 | 70 | 71 | +| DRIVE | 24 | 56 | 72 | 73 | 74 | 75 | 76 | +| AMP | 25 | 57 | 77 | 78 | 79 | 80 | 81 | +| CAB | 26 | 58 | 82 | 83 | 84 | 85 | 86 | +| EQ | 27 | 59 | 87 | 88 | 89 | 90 | 91 | +| MOD | 28 | 60 | 92 | 93 | 94 | 95 | 96 | +| DELAY | 29 | 61 | 97 | 102 | 103 | 104 | 105 | +| REVERB | 30 | 62 | 106 | 107 | 108 | 109 | 110 | +| SYN | 31 | 63 | 111 | 112 | 113 | 114 | 115 | + +CC **7** (volume) sets the MASTER volume, 0 to 127 to 0 to 100, like the MASTER knob (turning the knob takes over again). + +What the "setting" numbers mean depends on the model (the screen shows the names): for the AMP they are GAIN, BASS, MID, +TREBLE, PRESENCE; setting 3 of GATE is the preset LEVEL (50 = 0 dB); setting 1 of DELAY is TIME. Only the settings the model +has do anything; the others are stored and ignored. See [SOUNDS-AND-CONTROLS.md](SOUNDS-AND-CONTROLS.md) and +[EDITOR-PROTOCOL.md](EDITOR-PROTOCOL.md). + +The numbers were chosen so that nothing special is touched: 0 and 32 (bank), 6 and 38 (data entry), 64 to 69 (pedals), 98 to 101 +(NRPN and RPN) and 120 to 127 (channel mode, "all notes off") are never used, and 7 is the only standard one. A few settings +use 33 to 51, which are the low halves of controllers 1 to 19; a controller that sends 14-bit pairs on those would move them +by accident. Map them to one of the free numbers in your DAW instead. + +The map is in one place in the source, `px_cc_param()` and its siblings in `firmware/px_midi.h`; a test checks that every +number is unique, reserved ones are free, and a value sent by one pedal is applied by another to the same sound. + +## Tempo for the delay + +Turn **CLOCK > DELAY** on (default off). When the host sends MIDI clock, the pedal times 24 clocks, and sets the delay TIME to +the quarter note once two measurements in a row agree within 3 ms (so a drifting host does not make the delay jump). Quarter +notes shorter than 200 ms or longer than 2 s (above 300 or below 30 BPM) are ignored, and the TIME knob only reaches 2 s. The +nearest TIME step is chosen: 12.25 ms steps up to 1 s, 50 ms steps above it. REVERSE has its own range (250 ms to 1 s). To +follow a tempo with another note value, set the delay's own TIME; this is not a division setting. + +## Settings and the screen + +**Hold the FX button for 0.8 s** (a short press still cycles the effects, now when you release it) to open the **MIDI page**: + +| Row | Values | Default | +| --- | --- | --- | +| CHANNEL | OMNI, 1 to 16 | OMNI | +| SEND | ON, OFF | ON | +| CLOCK > DELAY | ON, OFF | OFF | + +The knob changes the row, PUSH goes to the next one (after the last it leaves), HOME leaves. The page also shows whether a +host is connected and the last Program or Control Change received ("LAST: CC 25 = 127"), which is what to look at first when +nothing happens. Leaving saves the settings only if you changed something (one flash write, about 0.3 s of silence, like SAVE). + +The settings are one byte in the header of the preset store (`firmware/px_store.h`). Older copies have 0 there, which means the +defaults. A power cut while saving leaves the old or the new settings, never a broken store (tested). + +While the SAVE page, the MIDI page, the tuner or the block mover is open, the host's Program Change and Control Change are read +and ignored, so a DAW cannot change the preset under a save in progress. + +## How it is wired, and what stays separate + +The USB device is felucca-bsp's `chip/usb.c` as before, **unchanged**: a class-compliant USB-MIDI interface (no driver on macOS, +Windows 10 and later, Linux, iOS or Android; the port is named "OpenPocket"). It is polled at 2 kHz from a timer interrupt. This +work does not touch the descriptors, `ota.c`, the loader or the boot, update-entry and recovery paths. + +There are two kinds of traffic on the one cable, and they never meet: + +1. **SysEx** (`F0 ... F7`) belongs to the update and the editor only: the installer's update protocol (`ota.c`), the editor's + frames `F0 7D 4F 50 ...` ([EDITOR-PROTOCOL.md](EDITOR-PROTOCOL.md)) and the rescue key. `usb.c` collects SysEx into one frame + buffer for them. A SysEx that is not theirs (a General MIDI reset from a DAW, a patch dump) is taken out of that buffer and + thrown away by `ota_take`; a SysEx longer than the buffer is dropped. A test sends a foreign SysEx to the simulated pedal + before an update, and the update still works. +2. **Musical messages** (Program Change, Control Change, clock and transport) go into a 64-entry ring that `usb.c` fills and + `firmware/px_midi_io.h` empties every pass of the main loop. Nothing sends SysEx into that ring and nothing in it is SysEx. + +One fix came with this: until now nothing emptied that ring. `usb.c` leaves the host's USB packet unread while the ring is nearly +full, so a DAW sending clock or notes would, after a few dozen messages, also have stopped the editor's and the installer's SysEx, +which arrive in the same packets. The ring is now always drained (messages the pedal does not use are dropped), and the +overflow flag `usb.c` sets is cleared once the ring is empty. A test floods the ring and checks it recovers. + +Code map: `engine/px_dsp.c` has `px_dly_value_for_ms` (the TIME value nearest a delay time); `firmware/px_midi.h` is the pure +logic (decoder, CC map, what to send, settings byte, clock), with no hardware; `firmware/px_midi_io.h` is the glue to the rings +and the pedal's state; `firmware/ui.h` has the MIDI page; `firmware/px_store.h` keeps the settings byte. + +## Cost and safety + +- **CPU and audio.** The MIDI code runs on the main loop (CPU0), not in the audio interrupt and not on the DSP core. Per pass it + reads the ring (usually empty) and compares the 84 on/model/setting bytes of the preset with the last values sent. The binary + grows by about 3.3 KB. The DSP load was **not measured on a pedal** (no hardware here): the hardware checklist asks for the + "C1 xx%" reading with and without MIDI traffic. Nothing was added to the audio path. +- **Flash.** The only new write is the settings save, in the preset store's own area, through the existing commit. +- **Update and recovery paths.** Unchanged. The tests of the installer, the update loader and the editor run as before. +- **Never flash from here.** The test package is built and checked on a PC; the captain installs it. + +## Bluetooth MIDI: why not yet + +Bluetooth MIDI (the standard BLE MIDI service) is **not** in this release. What the repository and the official firmware show: + +- The repository has only register definitions for the chip's Bluetooth baseband (`firmware/bsp/chip/fm1_ble*.h`). They were + written from a clean-room fact sheet, and the files state that nothing in them has run on a real chip. The radio's analog + start-up has open items, and there is **no link layer, no L2CAP, no ATT or GATT server, no pairing code**. +- The official BlackBox firmware does contain a complete Bluetooth stack and a BLE-MIDI service (a scout report of + 2026-11-05, kept privately and not in this repository): a standard GATT table with the BLE MIDI service, a parser for the + timestamped packets with running status and multi-packet SysEx, all as ordinary tasks of its own operating system. There is no + hardware or ROM shortcut: the stack is code in that image, bound to the vendor's operating system, and cannot be copied (it + is vendor code). The same report found how the official firmware describes itself on USB (a MIDI interface with one input and + one output cable plus USB audio), and found **no published Program Change or Control Change map** in it. +- JieLi's public Apache-2.0 SDK ships the controller and host stacks only as precompiled libraries that expect the vendor + operating system; whether those may be linked here needs the captain's decision on their licence, and each one needs OpenPocket to + supply the operating-system services it calls. + +**What a BLE stack would need** (future work, in the order it would be done): + +1. The captain scans a stock pedal with a phone (advertised name, data, interval) and connects it to a Mac as a Bluetooth MIDI + device, to fix the target behaviour. No code. +2. An advertise-only build on a test channel, to prove the radio comes up from the stored calibration without disturbing the audio. +3. Either write a minimal stack from the Bluetooth Core specification (link layer slave, L2CAP, ATT, a GATT server with the + BLE MIDI service `03B80E5A-EDE8-4B33-A751-6CE34EC4C700` and characteristic `7772E5DB-3868-4112-A1A9-F2669D106BF3`, + LE security for pairing), or build against the SDK's libraries once their licence is settled. +4. A BLE-MIDI framing layer: a header byte and per-message timestamps, running status, SysEx across packets, and a decoder that + ends in the same `px_midi_decode` used here (the musical code is already independent of the transport). +5. A setting to switch the radio off (saved in the store; default off, so a pedal that never uses it pays nothing), and a + measurement of the DSP load and glitches with a link up. +6. Regulatory care: the radio must run with the chip's stored calibration, and the transmit power must not exceed what the + stock firmware uses. + +## How it was checked (and what was not) + +Run `node tools/op.mjs test` (see [BUILDING.md](BUILDING.md)): + +- `tests/midisim.c`: the packet decoder (channel filter, bad status, bad data bytes, SysEx never decoded, cable number), the CC + map (unique, reserved numbers free, exact inverse, value scaling round trips), what the pedal sends (Program Change, one + Control Change per change, no echo, SEND off), the settings byte, MIDI clock (120 BPM, jitter, gaps, a changing tempo), and + that a sound sent by one pedal is applied unchanged by another. +- `tests/midiio.c`: the real glue code against the ring: Program Change and Control Change change the sound, are ignored while + SAVE, tuner or the MIDI page is open, channel filtering, a flood is drained and recovers, clock sync on and off. +- `tests/test_store.c`: the settings survive a power cycle, a preset save and a power cut at every step. +- `tests/uisim.c`: the MIDI page is drawn completely, the channel wraps, leaving saves only if changed. +- `tests/test_installer.mjs`: an update still succeeds after a foreign SysEx. +- `tests/test_op.mjs`, `tests/test_page.mjs`: the test package command and the installer's test channel. + +**Not checked, because it needs the pedal:** the USB enumeration and the port name on each OS, a real DAW or app sending, the +timing of replies, the DSP load and any dropout with MIDI flowing, the real screen, the update path on the new build, power +cycles. That is what [MIDI-TEST.md](MIDI-TEST.md) is for. + +## The test package + +`node tools/op.mjs package-test` (or `make package-test`) builds **OpenPocket v37 MIDI TEST** into `docs/firmware/midi-test/` +(`PocketX_937.fwsc`, `BlackBox_937.fwsc`, its own `manifest.json`) and `firmware/openpocket-midi-test.bin`. The boot screen and +the editor say `v37 MIDI TEST`. The normal files (`docs/firmware/manifest.json`, v36) are not touched, and the normal installer +page never offers it. To install it, open the installer with `?channel=midi-test` (a banner says it is a test build). To go back, +open the normal page and press Install (v36), or use the official-firmware way back on the same page. + +## Licences and references + +The USB code is felucca-bsp's (MPL-2.0, see [NOTICE.md](../NOTICE.md)); the MIDI code here is GPL-3.0-only like the rest of +OpenPocket. The CC map is OpenPocket's own. Facts about the official firmware come from a private analysis of the captain's +own device, used only to learn how it behaves, and no vendor code, bytes or tables are reproduced. diff --git a/docs/SOUNDS-AND-CONTROLS.md b/docs/SOUNDS-AND-CONTROLS.md index f1bc9db..2184d91 100644 --- a/docs/SOUNDS-AND-CONTROLS.md +++ b/docs/SOUNDS-AND-CONTROLS.md @@ -147,6 +147,28 @@ core, `BYP` = overload guard: turn a block off). `EDITED`: the sound differs fro Top centre of PLAY: the **battery** (v36), from the chip's own VBAT/4 channel (green, amber below 30 %, red below 15 %; `USB` when the 5 V supply is there); the tuner page shows the same reading in volts. +## MIDI over USB-C (test build v37; [MIDI.md](MIDI.md)) +**Hold FX** for 0.8 s: the MIDI page (CHANNEL OMNI or 1 to 16, SEND, CLOCK > DELAY). Program Change 0 to 51 loads slot 1 to 52; +Control Change sets the sound; the pedal sends Program Change and Control Change for what you do on it. + +| Block | On / off | Model | Setting 1 | 2 | 3 | 4 | 5 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| GATE | 20 | 52 | 33 | 34 | 35 | 36 | 37 | +| COMP | 21 | 53 | 39 | 40 | 41 | 42 | 43 | +| PITCH | 22 | 54 | 44 | 45 | 46 | 47 | 48 | +| SFX | 23 | 55 | 49 | 50 | 51 | 70 | 71 | +| DRIVE | 24 | 56 | 72 | 73 | 74 | 75 | 76 | +| AMP | 25 | 57 | 77 | 78 | 79 | 80 | 81 | +| CAB | 26 | 58 | 82 | 83 | 84 | 85 | 86 | +| EQ | 27 | 59 | 87 | 88 | 89 | 90 | 91 | +| MOD | 28 | 60 | 92 | 93 | 94 | 95 | 96 | +| DELAY | 29 | 61 | 97 | 102 | 103 | 104 | 105 | +| REVERB | 30 | 62 | 106 | 107 | 108 | 109 | 110 | +| SYN | 31 | 63 | 111 | 112 | 113 | 114 | 115 | + +On / off: 64 and up is on. Settings 0 to 127 are 0 to 100. Model: the whole 0 to 127 range is divided between the block's models. +CC 7 is the MASTER volume. Details, the clock sync and the limits: [MIDI.md](MIDI.md). + ## Saving your sounds (v25) - `EDITED` at the top of the PLAY screen means the sound has been edited since the preset was loaded. - **Long press on PUSH**: SAVE page. The knob picks the slot (1 to 52; the current preset is offered first; the diff --git a/docs/firmware/README.md b/docs/firmware/README.md index 889122d..d2fc368 100644 --- a/docs/firmware/README.md +++ b/docs/firmware/README.md @@ -1,3 +1,7 @@ The installer's packages land here when you run `build.bat` (or `firmware/build.sh`): `PocketX_9NN.fwsc`, `BlackBox_9NN.fwsc` and `manifest.json`. They are published with the release and on GitHub Pages by `PUBLISH-GITHUB.bat`, not kept in the repository. + +A test build (for example `node tools/op.mjs package-test`) goes to a subfolder, `midi-test/`, with its own `manifest.json`. The +installer page offers it only through `index.html?channel=midi-test`, with a banner; the normal page and the files above are never +changed by it. diff --git a/docs/index.html b/docs/index.html index fdc946d..9395bfe 100644 --- a/docs/index.html +++ b/docs/index.html @@ -85,6 +85,7 @@

2

…

+
@@ -148,6 +149,7 @@

3

foot1: "OpenPocket is an independent project, not affiliated with Donner or M-VAVE. Install at your own risk. Source code: GPL-3.0.", foot2: "Update protocol and loader from Felucca and felucca-bsp (Hügelton Instruments, GPL-3.0 / MPL-2.0). JieLi AC79 SDK files: Apache-2.0. Inter typeface: SIL OFL 1.1.", e_notfound: "Pedal not found. Check the cable (data), close other MIDI apps, then try again.", + testchan: "TEST CHANNEL: this installs a test build, not the release. It is for people who agreed to try it, and it is not the version the normal installer page offers. To go back, open this page without ?channel=… and press Install.", bbwarn: "M-VAVE BlackBox: v31 is the fix for issue #1 (OpenPocket restarted every 4 s after the pedal was switched off and on), still being tested on the BlackBox. Please report how it goes on GitHub. If your BlackBox restarts in a loop, use the rescue mode below.", toeditor: "Preset editor →", rescueon: "Rescue mode, for a pedal that restarts in a loop: tick this, press Connect, then plug the pedal in (or let it restart). The install starts by itself as soon as the pedal answers; it stops the pedal's sound processing at once, so the restarts stop. It installs the official file chosen in step 3 if there is one for this pedal, otherwise OpenPocket.", @@ -178,15 +180,19 @@

3

function setMsg(el, text, cls = "") { el.textContent = text; el.className = "msg " + cls; } const errText = (e) => (e && e.code && T.en["e_" + e.code] ? t("e_" + e.code) + (e.detail || "") : (e && e.message) || String(e)); +/* a test channel (?channel=midi-test): packages from firmware//, never offered without the link */ +const CHANNEL = (new URLSearchParams(location.search).get("channel") || "").replace(/[^a-z0-9-]/g, ""); +const FW = "firmware/" + (CHANNEL ? CHANNEL + "/" : ""); let access = null, up = null, manifest = null, lastDev = null, busy = false, official = null; if (!navigator.requestMIDIAccess) { $("nomidi").hidden = false; $("connect").disabled = true; } +$("testchan").hidden = !CHANNEL; async function loadManifest() { try { - const r = await fetch("firmware/manifest.json", { cache: "no-store" }); + const r = await fetch(FW + "manifest.json", { cache: "no-store" }); manifest = await r.json(); - $("pkgver").textContent = "v" + manifest.version; + $("pkgver").textContent = "v" + manifest.version + (CHANNEL ? " (" + CHANNEL + ")" : ""); } catch (e) { $("pkgver").textContent = "—"; log("manifest: " + e.message); } } @@ -244,7 +250,7 @@

3

async function packageFor(model) { const p = manifest && manifest.packages.find((x) => x.model === model); if (!p) throw Object.assign(new Error(model), { code: "pkg", detail: model }); - const r = await fetch("firmware/" + p.file, { cache: "no-store" }); + const r = await fetch(FW + p.file, { cache: "no-store" }); if (!r.ok) throw Object.assign(new Error(p.file), { code: "pkg", detail: p.file }); const bytes = new Uint8Array(await r.arrayBuffer()); if (productOf(bytes) !== p.product) throw new Error("package identity mismatch"); diff --git a/firmware/build.sh b/firmware/build.sh index 506303d..901f402 100755 --- a/firmware/build.sh +++ b/firmware/build.sh @@ -27,12 +27,25 @@ CPU1OBJ="" if grep -q "^#define DSP_CPU1 1" "$HERE/openpocket.c"; then $CC -c "$BSP/chip/fm1_cpu1.S" -o "$O/cpu1.o"; CPU1OBJ="$O/cpu1.o" fi +# A test channel build (OP_CHANNEL=midi-test): its own version number (OP_TEST_VERSION, default 37) and a boot-screen tag, its +# own files (firmware/openpocket-midi-test.bin, docs/firmware/midi-test/). The release files are not touched. +CHAN="${OP_CHANNEL:-}" +BIN="$HERE/openpocket.bin"; PKGDIR="$HERE/../docs/firmware" +: > "$O/channel.h" +if [ -n "$CHAN" ]; then + case "$CHAN" in *[!a-z0-9-]*|"") echo "ERROR: OP_CHANNEL must be lowercase letters, digits and -"; exit 1;; esac + TESTVER="${OP_TEST_VERSION:-37}" + printf '#define OP_VERSION_NUM "%s"\n#define OP_TAG " MIDI TEST"\n' "$TESTVER" > "$O/channel.h" + BIN="$HERE/openpocket-$CHAN.bin"; PKGDIR="$HERE/../docs/firmware/$CHAN" + rm -f "$BIN" + echo "Test channel: $CHAN, version $TESTVER -> $BIN, $PKGDIR" +fi # AC791N hardware FPU (same options as the JieLi SDK for the WL82) + DSP engine at 44.1 kHz FP="-mcpu=r3 -mfprev1" DSPDEF="-DPX_SR=44100.0f -DPX_OVERSAMPLE=4" INC="-I$HERE/inc -I$HERE/../engine" $CC $FP -Os -ffunction-sections -fno-builtin -Wall -Wno-unused-function $DSPDEF $INC \ - -I"$BSP/chip" -I"$BSP/boards/fm1" -c "$HERE/openpocket.c" -o "$O/openpocket.o" + -I"$BSP/chip" -I"$BSP/boards/fm1" -include "$O/channel.h" -c "$HERE/openpocket.c" -o "$O/openpocket.o" $CC $FP -O2 -fno-builtin $DSPDEF $INC -c "$HERE/../engine/px_dsp.c" -o "$O/px_dsp.o" $CC $FP -O2 -fno-builtin -c "$HERE/px_math.c" -o "$O/px_math.o" # official JieLi I2S driver (LLVM bitcode from the AC79 SDK, Apache-2.0) -> pi32v2 object @@ -43,7 +56,7 @@ $CC -Os -c -x ir "$HERE/sdk/iis.bc" -o "$O/iis.o" "$TC/common/bin/objcopy" -O binary -j .text "$O/openpocket.elf" "$O/text.bin" "$TC/common/bin/objcopy" -O binary -j .data "$O/openpocket.elf" "$O/data.bin" "$TC/common/bin/objcopy" -O binary -j .ram_text "$O/openpocket.elf" "$O/rt.bin" 2>/dev/null || : > "$O/rt.bin" -python3 - "$TC" "$O" "$HERE/openpocket.bin" <<'PY' +python3 - "$TC" "$O" "$BIN" <<'PY' import re,subprocess,sys tc,out,dest=sys.argv[1:] syms=subprocess.run([f"{tc}/common/bin/objdump","-t",f"{out}/openpocket.elf"],capture_output=True,text=True,check=True).stdout @@ -79,5 +92,6 @@ python3 "$HERE/../tools/mkloader.py" "$L/loader.bin" "$L/ota.bin" # ---- update packages for the web installer -> docs/firmware/ (PocketX_9NN.fwsc, BlackBox_9NN.fwsc, manifest.json) ---- VER=$(sed -n 's/^#define OP_VERSION_NUM "\([0-9]*\)".*/\1/p' "$HERE/openpocket.c") -python3 "$HERE/../tools/pxpkg.py" "$HERE/openpocket.bin" "$L/ota.bin" "$HERE/../docs/firmware" --version "$VER" \ +[ -n "$CHAN" ] && VER="$TESTVER" +python3 "$HERE/../tools/pxpkg.py" "$BIN" "$L/ota.bin" "$PKGDIR" --version "$VER" \ --sdk-files "$HERE/sdk/packager" diff --git a/tests/test_op.mjs b/tests/test_op.mjs index 50598cf..e8e2f1a 100644 --- a/tests/test_op.mjs +++ b/tests/test_op.mjs @@ -49,6 +49,11 @@ assert.equal(r.calls.filter((c) => c[0] === "run").length, 1, "package is one co assert.deepEqual(r.calls.at(-1).slice(0, -1), base(["sh", "-c"])); assert.match(r.calls.at(-1).at(-1), /^sh firmware\/build\.sh && .*sha256sum .*openpocket\.bin.*> build\/SHA256SUMS/); +r = op(["package-test"]); +assert.equal(r.calls.filter((c) => c[0] === "run").length, 1, "package-test is one container run"); +assert.match(r.calls.at(-1).at(-1), /^OP_CHANNEL=midi-test sh firmware\/build\.sh && .*sha256sum firmware\/openpocket-midi-test\.bin docs\/firmware\/midi-test\/\*\.fwsc/); +assert.doesNotMatch(r.calls.at(-1).at(-1), /docs\/firmware\/manifest\.json/, "the test package never touches the default manifest"); + r = op(["shell"]); assert.deepEqual(r.calls.at(-1), ["run", "--rm", "-it", ...base(["bash"]).slice(2)], "shell has a tty and the user"); @@ -97,12 +102,12 @@ r = op(["build"], { path: join(tmp, "empty") }); assert.notEqual(r.status, 0); assert.match(r.err, /Docker is not installed or not running/, "no docker at all"); -for (const p of ["build/x", "firmware/build/y", "docs/firmware/PocketX_900.fwsc", "docs/firmware/manifest.json", "engine/px_render"]) touch(p); +for (const p of ["firmware/openpocket-midi-test.bin", "docs/firmware/midi-test/PocketX_937.fwsc", "build/x", "firmware/build/y", "docs/firmware/PocketX_900.fwsc", "docs/firmware/manifest.json", "engine/px_render"]) touch(p); touch("docs/firmware/README.md"); touch("engine/px_dsp.c"); r = op(["clean"], { path: join(tmp, "empty") }); assert.equal(r.status, 0, "clean needs no docker"); -for (const p of ["build", "firmware/build", "firmware/openpocket.bin", "docs/firmware/PocketX_900.fwsc", "docs/firmware/manifest.json", "engine/px_render"]) { +for (const p of ["build", "firmware/build", "firmware/openpocket.bin", "firmware/openpocket-midi-test.bin", "docs/firmware/midi-test", "docs/firmware/PocketX_900.fwsc", "docs/firmware/manifest.json", "engine/px_render"]) { assert.ok(!existsSync(join(root, p)), `${p} removed`); } for (const p of ["docs/firmware/README.md", "engine/px_dsp.c", "guitar.wav"]) assert.ok(existsSync(join(root, p)), `${p} kept`); diff --git a/tests/test_page.mjs b/tests/test_page.mjs index f095fa9..4c52b75 100644 --- a/tests/test_page.mjs +++ b/tests/test_page.mjs @@ -24,8 +24,9 @@ const TYPES = { ".html": "text/html", ".js": "text/javascript", ".json": "applic const server = createServer((req, res) => { try { const p = join(SITE, decodeURIComponent(req.url.split("?")[0]).replace(/\/$/, "/index.html")); + const body = readFileSync(p); res.writeHead(200, { "content-type": TYPES[extname(p)] || "application/octet-stream" }); - res.end(readFileSync(p)); + res.end(body); } catch (_) { res.writeHead(404); res.end(); } }).listen(0); const url = `http://127.0.0.1:${server.address().port}/`; @@ -112,6 +113,20 @@ await page.waitForFunction(() => /PocketX_924/.test(document.getElementById("msg ok(/PocketX_924/.test(await page.textContent("#msg")), "rescue: the pedal appears, the install runs by itself"); ok(!(await page.isChecked("#rescueOn")), "rescue: the mode turns itself off once done"); await page.screenshot({ path: join(OUT, "page_4_rescue.png"), fullPage: true }); +// a test channel: only through ?channel=NAME, from its own folder, with its banner; the normal page has none +cpSync(join(FIX, "firmware"), join(SITE, "firmware", "midi-test"), { recursive: true }); +{ + const ch = await browser.newPage({ viewport: { width: 820, height: 900 } }); + await ch.goto(url + "?channel=midi-test"); + await ch.waitForFunction(() => /midi-test/.test(document.getElementById("pkgver").textContent), null, { timeout: 5000 }); + ok(await ch.isVisible("#testchan"), "test channel: the banner says it is a test build"); + ok(/\(midi-test\)/.test(await ch.textContent("#pkgver")), "test channel: the packages come from firmware/midi-test/"); + await ch.goto(url + "?channel=../../etc"); + await ch.waitForFunction(() => document.getElementById("pkgver").textContent !== "…", null, { timeout: 5000 }); + ok((await ch.textContent("#pkgver")) === "—", "test channel: a path in the name is cleaned (firmware/etc/ is asked for, nothing outside firmware/)"); + await ch.screenshot({ path: join(OUT, "page_5_test_channel.png"), fullPage: true }); + ok(!(await page.isVisible("#testchan")), "the normal page shows no test banner"); +} await browser.close(); if (sim) sim.stdin.end(); server.close(); diff --git a/tools/op.mjs b/tools/op.mjs index 5159b78..6176ae5 100644 --- a/tools/op.mjs +++ b/tools/op.mjs @@ -14,6 +14,7 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const IMAGE = "openpocket-build"; const native = Boolean(process.env.NATIVE || process.env.OP_IN_CONTAINER); const OUTS = "firmware/openpocket.bin docs/firmware/*.fwsc docs/firmware/manifest.json"; +const TEST = "firmware/openpocket-midi-test.bin docs/firmware/midi-test/*.fwsc docs/firmware/midi-test/manifest.json"; function fail(message) { console.error(`\nERROR: ${message}`); @@ -74,8 +75,9 @@ const commands = { build: () => inContainer(["sh", "firmware/build.sh"]), test: () => inContainer(["sh", "tests/run_tests.sh"]), package: () => inContainer(["sh", "-c", `sh firmware/build.sh && mkdir -p build && sha256sum ${OUTS} > build/SHA256SUMS && cat build/SHA256SUMS`]), + "package-test": () => inContainer(["sh", "-c", `OP_CHANNEL=midi-test sh firmware/build.sh && mkdir -p build && sha256sum ${TEST} > build/SHA256SUMS-midi-test && cat build/SHA256SUMS-midi-test`]), clean() { - for (const p of ["build", "firmware/build", "firmware/openpocket.bin", "docs/firmware/manifest.json", "engine/px_render"]) { + for (const p of ["build", "firmware/build", "firmware/openpocket.bin", "firmware/openpocket-midi-test.bin", "docs/firmware/midi-test", "docs/firmware/manifest.json", "engine/px_render"]) { rmSync(resolve(root, p), { recursive: true, force: true }); } const dir = resolve(root, "docs/firmware"); From 108a11cd3fbb6cf8c514c7a1d70346fcec0425c7 Mon Sep 17 00:00:00 2001 From: Vince Speelman Date: Thu, 5 Nov 2026 20:15:19 -0500 Subject: [PATCH 4/5] CI: build the MIDI test channel package and upload it as its own artifact --- .github/workflows/build.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1fba492..9560839 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,8 +12,6 @@ jobs: - uses: actions/checkout@v4 - name: Build the container, the firmware and the installer packages run: node tools/op.mjs package - - name: Host tests - run: node tools/op.mjs test - uses: actions/upload-artifact@v4 with: name: openpocket-build @@ -22,3 +20,14 @@ jobs: docs/firmware/*.fwsc docs/firmware/manifest.json build/SHA256SUMS + - name: Build the MIDI test channel package (never the installer default) + run: node tools/op.mjs package-test + - uses: actions/upload-artifact@v4 + with: + name: openpocket-midi-test + path: | + firmware/openpocket-midi-test.bin + docs/firmware/midi-test/ + build/SHA256SUMS-midi-test + - name: Host tests + run: node tools/op.mjs test From 357708e87dadd3e9eb277f1d3e189a48f12b7784 Mon Sep 17 00:00:00 2001 From: Vince Speelman Date: Thu, 5 Nov 2026 20:17:31 -0500 Subject: [PATCH 5/5] no-mistakes(review): Add package-test rule to Makefile --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index c75a2fe..8fcdcbf 100644 --- a/Makefile +++ b/Makefile @@ -12,8 +12,8 @@ # make render IN=guitar.wav [OUT=out.wav] [PRESET=1] a WAV through the engine # make appimage [DUMP=fm1-1.bin] fm1-300.bin for the SD-card recovery # `make NATIVE=1 build` skips Docker (toolchain installed with firmware/setup.sh). -.PHONY: doctor build test package clean shell image serve render appimage -doctor build test package clean shell image serve: +.PHONY: doctor build test package package-test clean shell image serve render appimage +doctor build test package package-test clean shell image serve: @node tools/op.mjs $@ render: @node tools/op.mjs render "$(IN)" "$(OUT)" "$(PRESET)"