From 87ccff7a6190943b45a2368461a45b55d38303b9 Mon Sep 17 00:00:00 2001 From: mohoyt Date: Sat, 9 May 2026 19:48:04 +0200 Subject: [PATCH 1/2] Updated drumdrum with provsional 8mu support --- releases/33_drumdrum/CMakeLists.txt | 1 + releases/33_drumdrum/README.md | 39 +++- releases/33_drumdrum/drumdrum.uf2 | Bin 109568 -> 112128 bytes releases/33_drumdrum/editor.html | 254 +++++++++++++++++--------- releases/33_drumdrum/info.yaml | 2 +- releases/33_drumdrum/midi_host.cpp | 273 ++++++++++++++++++++++++++++ releases/33_drumdrum/midi_host.h | 28 +++ releases/33_drumdrum/shared_state.h | 1 + releases/33_drumdrum/tusb_config.h | 9 +- releases/33_drumdrum/usb_core1.cpp | 11 +- 10 files changed, 520 insertions(+), 98 deletions(-) create mode 100644 releases/33_drumdrum/midi_host.cpp create mode 100644 releases/33_drumdrum/midi_host.h diff --git a/releases/33_drumdrum/CMakeLists.txt b/releases/33_drumdrum/CMakeLists.txt index 8ca736f..3f3edf2 100644 --- a/releases/33_drumdrum/CMakeLists.txt +++ b/releases/33_drumdrum/CMakeLists.txt @@ -27,6 +27,7 @@ add_executable(drumdrum midi_sysex.cpp monome_mext.c grid_ui.cpp + midi_host.cpp ) # Warnings: catch accidental double-precision and other issues diff --git a/releases/33_drumdrum/README.md b/releases/33_drumdrum/README.md index 6b02774..11bf084 100644 --- a/releases/33_drumdrum/README.md +++ b/releases/33_drumdrum/README.md @@ -4,13 +4,14 @@ A DFAM-style 8-step sequencer for the [Music Thing Modular Workshop System Compu drumdrum gives you a dual-VCO pitch sequencer with per-step velocity, white noise, step triggers, and end-of-cycle triggers — the core building blocks of a DFAM-style percussion voice, all from a single program card. Sequence data is randomised on every reset, so you can roll the dice on a new pattern any time. -You can drive the sequencer three ways, all sharing the same state: +You can drive the sequencer four ways, all sharing the same state: - **The card itself** — three knobs, the switch, and the six panel LEDs (always available). - **A Monome Grid** (16×8) plugged into the front USB jack for hands-on visual editing. +- **A [Music Thing 8mu](https://www.musicthing.co.uk/8mu)** plugged into the front USB jack for fader-based control of all 8 steps at once. - **A browser editor** in Chrome/Edge over WebMIDI when the card is connected to a computer. -The card decides between Grid and browser at boot from the USB-C cable orientation: peripheral plugged in → Grid mode, computer plugged in → browser mode. Power-cycle to switch. Panel knobs and switch keep working in all modes. +The card decides between host mode (Grid / 8mu) and device mode (browser) at boot from the USB-C cable orientation: peripheral plugged in → host mode, computer plugged in → browser mode. Within host mode, Grid vs 8mu is auto-detected from the device's USB class — no configuration needed. Power-cycle to switch between host and device. Panel knobs and switch keep working in all modes. ## Controls @@ -98,6 +99,34 @@ row 7 │ ▓▓▓▓▓▓▓▓ steps 1..8 ▓▓▓▓▓▓▓▓ │ Edits made on the Grid persist — the panel knob will not overwrite them unless you actually turn it (the knob has a "must move to take over" guard so a parked knob can't silently clobber Grid changes). +## Music Thing 8mu mode + +Plug a [Music Thing 8mu](https://www.musicthing.co.uk/8mu) into the card's front USB-C jack and the eight faders become live edit controls for the eight sequencer steps. Same auto-detection as the Grid — the firmware notices it's a class-compliant USB MIDI device (rather than CDC) and routes accordingly. Panel knobs and switch keep working in parallel. + +### Default mapping + +The 8mu's factory faders send CC 34–41, which is what the firmware listens for out of the box. Buttons and the second velocity bank need configuration in the [8mu web editor](https://www.musicthing.co.uk/8mu) — point each control at the CC numbers below. + +| CC | Source | Effect | +|---------|------------------|-----------------------------------------------------| +| **34–41** | faders (factory) | Step pitches 1–8 (raw 7-bit value), or step velocities (`value × 2`) when in velocity-edit mode | +| **50–57** | faders (alt bank) | Step velocities 1–8, regardless of edit mode | +| **28** | fader | Edit cursor (0 → step 1, 127 → step 8) | +| **22** | button (press) | Toggle pitch ↔ velocity edit mode | +| **23** | button (press) | Toggle play/pause | +| **24** | button (press) | Reset to step 1 | + +Buttons act on the rising edge (CC value crossing ≥ 64), so a press registers once even if your button sends a release event afterwards. + +A typical 8mu setup uses banks: bank 1 = pitches (factory faders + buttons mapped to CC 22–24); bank 2 = velocities (faders re-mapped to CC 50–57, same buttons); bank 3 = edit cursor + transport (one fader on CC 28, buttons on CC 22–24). + +### Notes + +- The 8mu sends data only when something changes, so step parameters stay at whatever they were until you actually move a fader. Toggling pitch ↔ velocity mode does not reset values; just sweep the fader for the step you want to change. +- Pitch changes are 7-bit (0–127, the full MIDI range, 1:1 with fader position). Velocity changes are 8-bit (0–254, computed as `CC × 2`). +- The 4 buttons and the 6-axis accelerometer (CC 42–49) beyond CC 22–24 are ignored in this firmware. Only CC messages are recognised — note-on / note-off / sysex are dropped. +- 8mu and Grid can't be plugged into the front jack at the same time; pick one per session. + ## Browser editor (WebMIDI) When you plug the card into a computer instead of a Grid, it appears as a USB MIDI device named **DrumDrum**. Open `editor.html` in Chrome or Edge — it's a single self-contained file with no build step: @@ -262,15 +291,15 @@ Flash the resulting `drumdrum.uf2` to the Workshop Computer by holding BOOT whil ## Technical Details - **Core 0** runs the sequencer and audio DSP in `ProcessSample()` at 48 kHz in interrupt context. Pure integer arithmetic, no float, no division. -- **Core 1** owns the USB stack — TinyUSB host (Monome Grid via the vendored mext serial protocol) or device (USB MIDI for the browser editor), decided once at boot from the USB-C CC pins via `USBPowerState()`. -- All three control surfaces share a single `SharedState` struct (`shared_state.h`); cross-core writes are atomic on the M0+, no locks needed. `tickEpoch` is the cross-core "something changed" signal. +- **Core 1** owns the USB stack — TinyUSB host (Monome Grid via the vendored mext serial protocol, or 8mu via a small in-tree class-compliant USB MIDI host driver since TinyUSB 0.18 doesn't ship one) or device (USB MIDI for the browser editor), decided once at boot from the USB-C CC pins via `USBPowerState()`. +- All four control surfaces (panel, Grid, 8mu, browser editor) share a single `SharedState` struct (`shared_state.h`); cross-core writes are atomic on the M0+, no locks needed. `tickEpoch` is the cross-core "something changed" signal. - White noise via xorshift32 PRNG, seeded from the hardware timer on each boot. - CV Out 1 uses EEPROM-calibrated `CVOutMIDINote()` for accurate 1V/oct tracking. - Audio Out 2 approximates 1V/oct on the 12-bit audio DAC (~28.4 DAC units/semitone, uncalibrated). - System clock set to 144 MHz to reduce ADC tonal artifacts; all code copied to RAM (`copy_to_ram`) to eliminate flash cache jitter. - 150 ms boot mute holds audio + pulse outputs at zero so DAC settling and the first trigger don't click. -**Source files:** `main.cpp` (sequencer + audio + role select), `shared_state.h` (cross-core data), `usb_core1.cpp` (USB task pump), `tusb_config.h` + `usb_descriptors.c` (TinyUSB), `monome_mext.c/h` (Grid serial protocol, vendored from MLRws), `grid_ui.cpp/h` (Grid layout + key dispatch), `midi_sysex.cpp/h` (browser-protocol parser/encoder), `editor.html` (browser editor). +**Source files:** `main.cpp` (sequencer + audio + role select), `shared_state.h` (cross-core data), `usb_core1.cpp` (USB task pump), `tusb_config.h` + `usb_descriptors.c` (TinyUSB), `monome_mext.c/h` (Grid serial protocol, vendored from MLRws), `grid_ui.cpp/h` (Grid layout + key dispatch), `midi_host.cpp/h` (in-tree class-compliant USB MIDI host driver for 8mu), `midi_sysex.cpp/h` (browser-protocol parser/encoder), `editor.html` (browser editor). ## License diff --git a/releases/33_drumdrum/drumdrum.uf2 b/releases/33_drumdrum/drumdrum.uf2 index 6bb6922e6b7e7c3dd18eb2ba8542ba9af10fc504..700bf020526b5d8e824a2de3b2eec4b427408d1f 100644 GIT binary patch delta 19595 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 delta 17194 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 diff --git a/releases/33_drumdrum/editor.html b/releases/33_drumdrum/editor.html index 50b93bb..30c20eb 100644 --- a/releases/33_drumdrum/editor.html +++ b/releases/33_drumdrum/editor.html @@ -11,9 +11,13 @@ html, body { margin: 0; padding: 0; background: #f3f1ea; height: 100%; } body { font-family: 'Space Grotesk', system-ui, sans-serif; display: flex; align-items: center; justify-content: center; - min-height: 100vh; padding: 28px; } + min-height: 100vh; padding: 28px; + -webkit-text-size-adjust: 100%; } #root { width: 100%; max-width: 1180px; } @keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.45 } } + @media (max-width: 720px) { + body { padding: 10px; align-items: flex-start; } + } @@ -35,6 +39,18 @@ } function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } +// Track viewport width so layouts can collapse below a phone-sized breakpoint. +function useIsMobile(bp = 720) { + const get = () => typeof window !== 'undefined' && window.innerWidth < bp; + const [m, setM] = React.useState(get); + React.useEffect(() => { + const on = () => setM(get()); + window.addEventListener('resize', on); + return () => window.removeEventListener('resize', on); + }, []); + return m; +} + // ─── SysEx protocol (matches midi_sysex.h on the firmware) ─────────── const SYSEX_MFR = 0x7D; const SYSEX_SET_PITCH = 0x01; @@ -211,6 +227,7 @@ const p = usePattern(); const [sel, setSel] = React.useState(0); const c = theme(dark); + const m = useIsMobile(); React.useEffect(() => { document.body.style.background = c.bg; }, [dark]); @@ -257,67 +274,84 @@ return (
-
- - - -
+
+ + + +
); } // ─── Header ────────────────────────────────────────────────────────── -function Header({ p, c, dark, setDark }) { +function Header({ p, c, m, dark, setDark }) { return ( -
+
-
Workshop Computer · DFAM-style sequencer
-
drumdrum.
-
- - - +
+ +
+ + +
); } -function Status({ c, conn }) { +function Status({ c, conn, m }) { const tones = { connected: { bg: c.okBg, bd: c.okBd, fg: c.ok }, searching: { bg: c.warnBg, bd: c.warnBd, fg: c.warn }, disconnect: { bg: c.badBg, bd: c.badBd, fg: c.bad }, }; const t = tones[conn.tone] || tones.searching; + // On mobile the connected label includes the long port name; trim to keep + // the badge from forcing the row wider than the viewport. + const label = (m && conn.tone === 'connected') ? 'Connected' : conn.label; return (
- {conn.label} + {label}
); } @@ -343,14 +377,14 @@ ); } -function Transport({ p, c }) { +function Transport({ p, c, m }) { return ( ); } // ─── Step detail ───────────────────────────────────────────────────── -function StepDetail({ p, c, sel, setSel }) { +function StepDetail({ p, c, m, sel, setSel }) { const pitch = p.pitch[sel]; const vel = p.velocity[sel]; return (
-
- -
- step - {String(sel + 1).padStart(2, '0')} +
+
+ +
+ step + {String(sel + 1).padStart(2, '0')} +
-
- setSel((sel + p.seqLength - 1) % p.seqLength)}>← - setSel((sel + 1) % p.seqLength)}>→ - ←/→ +
+ setSel((sel + p.seqLength - 1) % p.seqLength)}>← + setSel((sel + 1) % p.seqLength)}>→ + {!m && ( + ←/→ + )}
- p.setStepPitch(sel, v)} /> - p.setStepVel(sel, v)} /> + p.setStepPitch(sel, v)} /> + p.setStepVel(sel, v)} />
); } -function NavBtn({ c, children, onClick }) { +function NavBtn({ c, m, children, onClick }) { return ( ); } -function PitchEditor({ c, pitch, onChange }) { +function PitchEditor({ c, m, pitch, onChange }) { return (
@@ -619,20 +680,23 @@ {noteName(pitch)} · {pitch}
- - + +
); } -function PianoStrip({ c, value, onChange }) { - const center = clamp(value, 18, 108); +function PianoStrip({ c, m, value, onChange }) { + // Show fewer keys on mobile so each one is large enough to tap. + const span = m ? 24 : 36; + const half = Math.floor(span / 2); + const center = clamp(value, half, 127 - half); const startOct = Math.floor(center / 12) - 1; - const startNote = startOct * 12; - const notes = Array.from({ length: 36 }, (_, i) => startNote + i); + const startNote = clamp(startOct * 12, 0, 128 - span); + const notes = Array.from({ length: span }, (_, i) => startNote + i); return (
@@ -668,7 +732,7 @@ ); } -function VelocityEditor({ c, vel, onChange }) { +function VelocityEditor({ c, m, vel, onChange }) { return (
@@ -679,7 +743,7 @@
- +
); } @@ -731,7 +795,7 @@ ); } -function BigSlider({ c, min, max, value, onChange }) { +function BigSlider({ c, m, min, max, value, onChange }) { const ref = React.useRef(null); const dragging = React.useRef(false); const set = (e) => { @@ -750,21 +814,25 @@ e.currentTarget.releasePointerCapture?.(e.pointerId); }; const t = (value - min) / (max - min); + const trackH = m ? 12 : 8; + const handleW = m ? 22 : 14; + const handlePad = m ? 6 : 4; return (
@@ -773,7 +841,15 @@ } // ─── Footer ────────────────────────────────────────────────────────── -function Footer({ c }) { +function Footer({ c, m }) { + if (m) { + return ( +
+ SysEx · 7D · MIDI thru WebMIDI +
+ ); + } return (
+#include + +// ── Driver-private state ───────────────────────────────────────────── +// Only one MIDI device tracked at a time. The Workshop Computer's front +// jack is a single port; a hub could in principle bring more, but v1 +// only listens to the first MIDI device that mounts. +static volatile bool s_connected = false; +static uint8_t s_dev_addr = 0; +static uint8_t s_ep_in = 0; // bulk-IN endpoint address (with direction bit) +static uint16_t s_ep_in_size = 64; // bulk-IN max packet size +static uint8_t s_last_itf = TUSB_INDEX_INVALID_8; // highest interface we claimed + +// Per-button rising-edge state (CC value <64 → ≥64 = press). +static uint8_t s_btn22_prev = 0; +static uint8_t s_btn23_prev = 0; +static uint8_t s_btn24_prev = 0; + +// USB-MIDI Event Packets are 4 bytes each. 64 bytes = up to 16 events +// per IN transfer, which is plenty: an 8mu sweeps a fader at maybe +// 100 Hz max. +static uint8_t s_rx_buf[64]; + +// ── Helpers ────────────────────────────────────────────────────────── +static inline bool button_press_edge(uint8_t* prev, uint8_t value) { + bool press = (value >= 64) && (*prev < 64); + *prev = value; + return press; +} + +static void handle_cc(uint8_t cc, uint8_t v) { + bool changed = false; + if (cc >= 34 && cc <= 41) { + const uint8_t i = (uint8_t)(cc - 34); + if (gState.midiHostVelocityMode) { + gState.velocity[i] = (uint8_t)(v << 1); + } else { + gState.pitch[i] = v; + } + changed = true; + } else if (cc >= 50 && cc <= 57) { + gState.velocity[cc - 50] = (uint8_t)(v << 1); + changed = true; + } else if (cc == 28) { + uint8_t step = (uint8_t)((v * 8u) >> 7); + if (step > 7) step = 7; + gState.editStep = step; + changed = true; + } else if (cc == 22) { + if (button_press_edge(&s_btn22_prev, v)) { + gState.midiHostVelocityMode ^= 1; + changed = true; + } + } else if (cc == 23) { + if (button_press_edge(&s_btn23_prev, v)) { + gState.playing ^= 1; + changed = true; + } + } else if (cc == 24) { + if (button_press_edge(&s_btn24_prev, v)) { + // Mirrors the Pulse In 2 reset: jump to step 1. currentStep + // is normally written only by Core 0, but a single-byte + // store from Core 1 is atomic on the M0+ — and the audio + // ISR's only "reaction" is to display/play step 0, which + // is exactly what we want. + gState.currentStep = 0; + changed = true; + } + } + if (changed) gState.tickEpoch++; +} + +static void rearm_rx(void) { + if (!s_connected || s_ep_in == 0) return; + if (!usbh_edpt_claim(s_dev_addr, s_ep_in)) return; + uint16_t const len = (s_ep_in_size > sizeof(s_rx_buf)) + ? (uint16_t)sizeof(s_rx_buf) : s_ep_in_size; + if (!usbh_edpt_xfer(s_dev_addr, s_ep_in, s_rx_buf, len)) { + // claim is rolled back inside usbh_edpt_xfer on failure + } +} + +static void clear_state(void) { + s_connected = false; + s_dev_addr = 0; + s_ep_in = 0; + s_ep_in_size = 64; + s_last_itf = TUSB_INDEX_INVALID_8; + s_btn22_prev = 0; + s_btn23_prev = 0; + s_btn24_prev = 0; +} + +// ── Public API ─────────────────────────────────────────────────────── +extern "C" void midi_host_init(void) { + clear_state(); +} + +// ── TinyUSB class-driver callbacks ─────────────────────────────────── +// +// Driver lifecycle: +// driver_init — once at host stack init. +// driver_open — when a matching interface descriptor arrives +// during enumeration. We claim two interfaces +// (Audio-Control + MIDIStreaming) by parsing +// through to the bulk endpoints and opening them. +// driver_set_config — once the device is in CONFIGURED state. We +// kick off the first IN read here, then signal +// the host stack we're done with this interface. +// driver_xfer_cb — every time a bulk-IN packet completes. +// driver_close — on disconnect. + +static bool driver_init(void) { + clear_state(); + return true; +} + +static bool driver_deinit(void) { + clear_state(); + return true; +} + +static bool driver_open(uint8_t rhport, uint8_t dev_addr, + tusb_desc_interface_t const* itf_desc, uint16_t max_len) { + (void)rhport; + + // We only claim Audio-class interfaces. The descriptor parser at + // usbh.c:1681 groups Audio-Control (subclass 1) + MIDIStreaming + // (subclass 3) together via assoc_itf_count=2 when CFG_TUH_MIDI=1. + // Some devices skip Audio-Control entirely, so accept either entry. + if (itf_desc->bInterfaceClass != TUSB_CLASS_AUDIO) return false; + if (itf_desc->bInterfaceSubClass != 1 /* AUDIO_SUBCLASS_CONTROL */ && + itf_desc->bInterfaceSubClass != 3 /* AUDIO_SUBCLASS_MIDI_STREAMING */) { + return false; + } + + // Only one MIDI device at a time. + if (s_connected) return false; + + uint8_t const* p_desc = (uint8_t const*)itf_desc; + uint8_t const* p_end = p_desc + max_len; + + // Walk forward to the MIDIStreaming interface. If we entered on + // MIDIStreaming directly, the very first iteration matches. + // Stash its interface number so set_config can tell the host stack + // to resume past our claimed group — see driver_set_config below. + uint8_t ms_itf_num = TUSB_INDEX_INVALID_8; + while (p_desc < p_end) { + if (tu_desc_type(p_desc) == TUSB_DESC_INTERFACE) { + tusb_desc_interface_t const* itf = (tusb_desc_interface_t const*)p_desc; + if (itf->bInterfaceClass == TUSB_CLASS_AUDIO && + itf->bInterfaceSubClass == 3 /* MIDIStreaming */) { + ms_itf_num = itf->bInterfaceNumber; + p_desc = tu_desc_next(p_desc); + break; + } + } + p_desc = tu_desc_next(p_desc); + } + if (ms_itf_num == TUSB_INDEX_INVALID_8) return false; + + // Now scan for the bulk endpoints. Stop at the next interface. + uint8_t ep_in = 0, ep_out = 0; + uint16_t ep_in_size = 64; + while (p_desc < p_end) { + uint8_t const dt = tu_desc_type(p_desc); + if (dt == TUSB_DESC_INTERFACE) { + break; + } else if (dt == TUSB_DESC_ENDPOINT) { + tusb_desc_endpoint_t const* ep = (tusb_desc_endpoint_t const*)p_desc; + if (ep->bmAttributes.xfer == TUSB_XFER_BULK) { + if (tu_edpt_dir(ep->bEndpointAddress) == TUSB_DIR_IN) { + if (!tuh_edpt_open(dev_addr, ep)) return false; + ep_in = ep->bEndpointAddress; + ep_in_size = tu_edpt_packet_size(ep); + } else { + if (!tuh_edpt_open(dev_addr, ep)) return false; + ep_out = ep->bEndpointAddress; + } + } + } + p_desc = tu_desc_next(p_desc); + } + if (ep_in == 0) return false; // need at least an IN endpoint + (void)ep_out; // OUT not used in v1 (no SysEx writes back to 8mu) + + s_dev_addr = dev_addr; + s_ep_in = ep_in; + s_ep_in_size = ep_in_size; + s_last_itf = ms_itf_num; + return true; +} + +static bool driver_set_config(uint8_t dev_addr, uint8_t itf_num) { + (void)itf_num; + s_connected = true; + rearm_rx(); + // Tell the host stack this driver has finished configuring. The + // stack's loop advances `itf_num++` and resumes searching for the + // next driver from there (usbh.c:1740). When our driver claimed + // both AudioControl + MIDIStreaming, both interface numbers map + // back to us in dev->itf2drv[], so we MUST return the highest + // claimed interface number — passing TUSB_INDEX_INVALID_8 (0xFF) + // would wrap to 0, find our driver again, and recurse until the + // stack overflows. The TinyUSB header note for IAD-binding drivers + // says exactly this: "should return itf_num + 1 when complete". + usbh_driver_set_config_complete(dev_addr, s_last_itf); + return true; +} + +static bool driver_xfer_cb(uint8_t dev_addr, uint8_t ep_addr, + xfer_result_t result, uint32_t xferred_bytes) { + (void)dev_addr; + if (ep_addr != s_ep_in) { + // OUT-completion or stray callback — ignore. + return true; + } + if (result == XFER_RESULT_SUCCESS) { + // Parse 32-bit USB-MIDI Event Packets. Each packet: + // byte 0: (cable_num << 4) | code_index_number + // bytes 1..3: MIDI message bytes + // 8mu has only one cable, but we accept any cable index. + for (uint32_t i = 0; i + 4 <= xferred_bytes; i += 4) { + uint8_t const cin = (uint8_t)(s_rx_buf[i] & 0x0F); + if (cin == MIDI_CIN_CONTROL_CHANGE) { + handle_cc((uint8_t)(s_rx_buf[i + 2] & 0x7F), + (uint8_t)(s_rx_buf[i + 3] & 0x7F)); + } + // Other CINs (note on/off, sysex, pitch bend, etc.) are + // silently dropped — 8mu's button defaults could be notes, + // but our v1 protocol uses CC 22-24 as documented. + } + } + // Always re-arm: a stalled or aborted xfer should still try again. + rearm_rx(); + return true; +} + +static void driver_close(uint8_t dev_addr) { + if (s_dev_addr == dev_addr) { + clear_state(); + } +} + +// Aggregate-initialised in field order to avoid the C++ designated-init +// extension. Match the order of usbh_class_driver_t in usbh_pvt.h: +// name, init, deinit, open, set_config, xfer_cb, close. +static usbh_class_driver_t const s_drivers[] = { + { + "MIDI", + driver_init, + driver_deinit, + driver_open, + driver_set_config, + driver_xfer_cb, + driver_close, + }, +}; + +extern "C" usbh_class_driver_t const* usbh_app_driver_get_cb(uint8_t* driver_count) { + *driver_count = (uint8_t)(sizeof(s_drivers) / sizeof(s_drivers[0])); + return s_drivers; +} diff --git a/releases/33_drumdrum/midi_host.h b/releases/33_drumdrum/midi_host.h new file mode 100644 index 0000000..f594a3e --- /dev/null +++ b/releases/33_drumdrum/midi_host.h @@ -0,0 +1,28 @@ +#pragma once + +// Minimal class-compliant USB MIDI host for Music Thing 8mu support. +// +// Pico SDK 2.2.0 ships TinyUSB 0.18, which does not include a MIDI host +// class driver — only a descriptor-parser hint enabled via CFG_TUH_MIDI. +// We register our own driver via TinyUSB's usbh_app_driver_get_cb() +// extension point and read 32-bit USB-MIDI Event Packets directly off +// the bulk-IN endpoint. CC messages are dispatched into gState. +// +// CC mapping (channel-agnostic, edge-detected on buttons): +// 34..41 faders step pitches (or velocities when edit mode = 1) +// 50..57 faders step velocities (always) +// 28 fader edit cursor (0..7) +// 22 button (press) toggle pitch ↔ velocity edit mode +// 23 button (press) toggle play/pause +// 24 button (press) reset to step 1 +// all others ignored + +#ifdef __cplusplus +extern "C" { +#endif + +void midi_host_init(void); + +#ifdef __cplusplus +} +#endif diff --git a/releases/33_drumdrum/shared_state.h b/releases/33_drumdrum/shared_state.h index 93bb050..82f5269 100644 --- a/releases/33_drumdrum/shared_state.h +++ b/releases/33_drumdrum/shared_state.h @@ -27,6 +27,7 @@ struct SharedState { uint8_t editStep; // 0..7 selected step for editing uint8_t currentStep; // 0..7 playback position uint8_t playing; // 0/1 playback enable + uint8_t midiHostVelocityMode; // 0=8mu faders edit pitch, 1=velocity uint32_t tickEpoch; // ++ on every step advance (cross-core signal) }; diff --git a/releases/33_drumdrum/tusb_config.h b/releases/33_drumdrum/tusb_config.h index 0adb18d..4be59e8 100644 --- a/releases/33_drumdrum/tusb_config.h +++ b/releases/33_drumdrum/tusb_config.h @@ -28,7 +28,7 @@ extern "C" { #define CFG_TUD_MIDI_TX_BUFSIZE 128 #define CFG_TUD_MIDI_EP_BUFSIZE 64 -// ── Host stack (Monome Grid over CDC + FTDI) ───────────────── +// ── Host stack (Monome Grid over CDC + FTDI, Music Thing 8mu over MIDI) ───── #define CFG_TUH_ENUMERATION_BUFSIZE 256 #define CFG_TUH_HUB 1 #define CFG_TUH_DEVICE_MAX (CFG_TUH_HUB ? 4 : 1) @@ -40,6 +40,13 @@ extern "C" { #define CFG_TUH_MSC 0 #define CFG_TUH_VENDOR 0 +// TinyUSB 0.18 (shipped with Pico SDK 2.2.0) has no MIDI host class driver, +// only a one-block descriptor-parser hint that groups class-compliant USB +// MIDI's Audio-Control + MIDIStreaming interfaces under a single driver +// (usbh.c:1681). CFG_TUH_MIDI=1 turns that hint on; the actual driver is +// our own minimal one in midi_host.cpp, registered via usbh_app_driver_get_cb. +#define CFG_TUH_MIDI 1 + // Modern Monome Grids assert DTR/RTS on enumeration; older FTDI-based // units expect 115200 8N1. Both are mext-protocol grids on the wire. #define CFG_TUH_CDC_LINE_CONTROL_ON_ENUM 0x03 diff --git a/releases/33_drumdrum/usb_core1.cpp b/releases/33_drumdrum/usb_core1.cpp index d438112..837ec18 100644 --- a/releases/33_drumdrum/usb_core1.cpp +++ b/releases/33_drumdrum/usb_core1.cpp @@ -19,6 +19,7 @@ #include "midi_sysex.h" #include "monome_mext.h" #include "grid_ui.h" +#include "midi_host.h" #include "tusb.h" #include "bsp/board_api.h" @@ -26,10 +27,16 @@ volatile uint8_t gUsbHostMode = 0; -static void run_grid_loop(void) +static void run_host_loop(void) { board_init(); + // Both host paths coexist: a Grid plugged in fires CDC mount cbs + // into mext, an 8mu (or any class-compliant USB MIDI device) fires + // our app-registered MIDI driver. Whichever shows up wins; the + // other path stays idle. mext_task() drives tuh_task(), which is + // what dispatches the MIDI driver's xfer callbacks. mext_init(MEXT_TRANSPORT_HOST, 0); + midi_host_init(); tusb_init(); grid_ui_init(); while (true) { @@ -52,7 +59,7 @@ static void run_device_loop(void) extern "C" void core1_entry(void) { if (gUsbHostMode) { - run_grid_loop(); // never returns + run_host_loop(); // never returns } else { run_device_loop(); // never returns } From 72649f224b0b277b863598fce98b9de02fb572f6 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 9 May 2026 17:48:28 +0000 Subject: [PATCH 2/2] Update README with folder information --- releases/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/releases/README.md b/releases/README.md index 96a27de..30c757b 100644 --- a/releases/README.md +++ b/releases/README.md @@ -26,7 +26,7 @@ | 30_cirpy_wavetable | Wavetable oscillator that using wavetables from Plaits, Braids, and Microwave, | 0.1
Functional but WIP | Circuit Python | @todbot / Tod Kurt | | 31_esp | A MS-20-style External Signal Processor that includes a preamp, bandpass filter, envelope follower, gate, and 1v/oct pitch outs. | 1.0
Released | C++(ComputerCard) | Ben Regnier | | 32_vink | Dual delay loops with sigmoid saturation for Jaap Vink / Roland Kayn style feedback patching | 1.1
Functional | C++(ComputerCard) | Ben Regnier | -| 33_drumdrum | DFAM-style 8-step sequencer
[Web editor](https://mohoyt.com/drumdrum.html) | 1.1.0
Functional but WIP | C++ (ComputerCard) | Moses Hoyt | +| 33_drumdrum | DFAM-style 8-step sequencer
[Web editor](https://mohoyt.com/drumdrum.html) | 1.2.0
Functional but WIP | C++ (ComputerCard) | Moses Hoyt | | 37_compulidean | Generative Euclidean drum + sample player. | (see source repo)
Functional, but WIP | C++/Arduino, with vscode+platformio. | Tristan Rowley | | 38_od | Loopable chaotic Lorenz attractor trajectories and zero-crossings as CV and pulses, with sensitivity to initial conditions. | 1.0
Released | MicroPython | M. John Mills | | 39_knots | Six-engine oscillator firmware for the Music Thing Workshop System | 0.2
Released | C++ (RPi Pico SDK / ComputerCard) | Jeff Fletcher |