overdub
FirmwareM-VAVE FM-1

CRATE

zisonzishen/crate-fm1 · ★ 1

Slice and performance sampler for jazz-hiphop, meant to sit beside an FM-1 running SLOOP: upload samples over USB-MIDI, slice them into 16 pads and sequence them.

Built on Felucca

README from GitHub, GPL-3.0

CRATE

A slice/performance sampler firmware for the M-VAVE FM-1, aimed at jazz-hiphop production. It is designed to sit next to a second FM-1 running SLOOP: SLOOP sequences, CRATE plays samples and slices.

Status: Phases 0–5 in the firmware — upload, play, slice, sequence and character. The firmware boots, drives the panel, LCD and LEDs, enumerates as a USB-MIDI device, accepts firmware updates, takes sample uploads over USB-MIDI SysEx into the flash pool, plays them from the 27 keys and from MIDI notes, and slices a sample into 16 pads (equal or transient) in slice mode. Each slice has its own pitch, level, cutoff, ADSR, loop, reverse and choke group, edited on the panel and persisted when slice mode is left (or the slot cursor moves). A two-track 16-step sequencer records from the keys and drives voices and a 24 PPQN USB-MIDI clock with swing, nudge, ratchets and gates. The master bus carries a character chain — rate reduction, bit crush, drive, vinyl and a soft limiter — edited on the REC page.

The host test suites (tests/run_tests.sh) cover the engine, the slot format, the flash arena, the upload protocol, the slice paths, the sequencer timing, the A/B pattern records and the character chain; tests/run_tool_tests.sh replays the Python uploader's real SysEx through the firmware parser. Both pass, and ./build.sh produces a 39.5 KiB image with no warnings. tools/verify.sh runs all three in one go. Hardware flashing of this build is the outstanding verification step.

Controls

ControlAction
27 note keysslot mode: trigger slots 0..26; a looped slot loops while held
USB-MIDI notesnote 36 (C2) = slot 0 … note 99 = slot 63 (MPC pad layout)
HOMEslice mode on/off
K1 encoderselected slot (the slot that slice mode and the slicers act on)
SAVEslice the selected slot into 16 equal pads, and save the table
EDITslice it at detected transients (up to 16), and save the table
keys 0..15 in slice modeplay slices 0..15 of the selected slot; the last one hit is the edited slice
MIDI 36..51 in slice modethe same 16 slices
K2 encoder (slice mode)slice pitch, 1 semitone per click (range −48..+36)
K3 encoder (slice mode)slice level
K4 encoderslice low-pass cutoff
PRESETS encoder (slice mode)slice ADSR preset (1 default, 2 pluck, 3 swell, 4 stab)
ENV (slice mode)slice loop on/off
LFO (slice mode)slice reverse on/off
GLO (slice mode)slice choke group 0..15 (0 = none)
SEQsequencer play/stop
ARParm step record (stopped only); a key writes its pad into the cursor step and advances
K2 encoder (slot mode)sequencer step cursor
K3 encoder (slot mode)sequencer track (1..2)
ENV (slot mode)clear the cursor step
LFO (slot mode)gate of the cursor step: one-shot / half a step
GLO (slot mode)velocity of the cursor step (40..127)
EDIT (slot mode)ratchet of the cursor step (1,2,3,4,6,8 hits)
SAVE (slot mode)micro-timing of the cursor step (0..48/48 of a step late)
PRESETS encoder (slot mode)swing, 50..75 %
PLAYpanic: stop the sequencer and cut every voice
RECcharacter (FX) page on/off
K1 encoder (FX page)bit depth, 16 down to 4 bits
K2 encoder (FX page)sample-rate reduction, 1x, 2x, 4x, 8x, 16x, 32x
K3 encoder (FX page)drive (soft saturation), 0..127
K4 encoder (FX page)vinyl hiss and crackle, 0..127
ENV (FX page)reset the character chain
SELECT encodermaster volume
ALGORITHM encodersequencer tempo, 40..240 BPM
OCT- + OCT+ held 5 sM-VAVE mask-ROM UBOOT (USB update mode)

The status screen shows loaded slots, pool use and free space, sounding voices and whether the character chain is on, master volume, USB state and an audio heartbeat (half-buffers rendered since boot), plus a final line with the sequencer transport, tempo, edit track/step and that step's pad (SEQ STOP 120 T1 S03 05, -- for a rest; the tempo reads EXT while the sequencer follows an external clock). On the FX page the sixth line shows the live settings (C12 R4 D64 V20), and the REC LED is lit.

Hardware facts this code relies on

  • JieLi AC791N, 44.1 kHz, ALNK0 I2S double-buffered DMA, 256-frame halves.
  • 240x240 SPI LCD; 27 note keys, 14 buttons, 7 encoders, LED matrix.
  • USB full-speed device: USB-MIDI on EP1; updates via the M-UPGRADE SysEx protocol.
  • 3.5 mm TRS MIDI is input only in stock hardware (UART1 RX; CRATE reads notes and clock from it). A TRS MIDI output (for clocking SLOOP) is a Phase 0 hardware spike, not yet confirmed.
  • No analog audio input. Sampling sources are USB file upload (v1) and USB audio recording (stretch, Phase 6).

Sample engine (Phase 1)

The playback core is pure C in firmware/src/eng/, with no HAL or register access, so the exact firmware code compiles unchanged in the host harness under tests/.

  • crate_ima.h — IMA ADPCM (DVI4) 4-bit decoder, the standard algorithm.
  • crate_dsp.h — fixed-point helpers: Q15/Q16 multiply, clamp, the 2^(n/12) pitch ratio and the one-pole LP coefficient.
  • crate_tables.h — generated pitch / envelope / LP tables (tools/gen_tables.py); values hand-checked against exact math.
  • crate_sampler.h — the voice engine: 8 voices, pitch resampling, one-pole LP, linear ADSR, forward loop, reverse, choke, voice stealing.
  • crate_slot.h — the on-flash sample pool (Phase 2): slot records, a CRC16 pool header, and a one-pass loader that reconstructs a zone's loop-entry state and reverse grid from the stored ADPCM (so a bad upload cannot poison playback state).

Sample format

A zone is a contiguous run of IMA ADPCM (4-bit, ~4:1) plus a header:

FieldMeaning
data, npacked ADPCM bytes (two samples per byte, low nibble first), sample count
ls, le, lpred, lidxloop start/end and the decoder state at ls
rate_q16source sample rate / 44100, Q16
root16root pitch in 1/16 semitones (MIDI note × 16)
grid64 packed decoder states, for reverse playback

Reverse playback

ADPCM is forward-only, so reverse reads forward-decoded windows: grid[k] stores the decoder state at sample k·n/64, and cr_rev_fill decodes a ≤64-sample window forward from the nearest checkpoint before the voice walks it backward. Each refill costs at most 127 decodes.

Fixed point

Samples are Q15, phases Q16, the envelope Q16. Pitch is 2^(p/16 / 12) via a 192-entry top-octave table (shifted), and the resample step is exp2(pitch16)·rate_q16 in Q16 source samples per output sample.

Host tests

tests/run_tests.sh   # needs a C compiler (WSL)

The harness encodes reference signals with an independent IMA encoder and checks round-trip, DC/root/±12-semitone rendering, one-shot end, loop, reverse, voice stealing and choke against exact expected samples (test_sampler.c), plus the slot-format load path and pool-header validation (test_slot.c), the append-only arena on a RAM fake of the NOR window (test_store.c), the whole upload state machine — framing, packing, ordering, CRC and the committed pool (test_upload.c), equal/transient slicing, slice entry states, slice-relative reverse grids and the persisted slice record on a RAM fake of the meta area (test_slice.c), and the sequencer's swing grid, micro-timing, ratchets, gates, note-offs, MIDI clock count, stop sequence and wrapping player clock (test_seq.c), and the A/B pattern records — round trip, newest-wins, corruption fallback, wrapping sequence numbers and the range repair on load (test_pattern.c), the serial MIDI byte parser — running status, one-data statuses, interleaved realtime, SysEx/common cancellation (test_midiparse.c), and the character chain — idle transparency, crush, hold, drive, vinyl and the limiter knee (test_fx.c). tests/run_tool_tests.sh then runs the real uploader (Python 3) and replays its .syx output through cu_service on the RAM flash fake (test_syx.c), checking every reply and the committed ADPCM bytes.

Slices (Phase 3)

A slot's sample can be cut into up to 16 performance slices. A slice is a window [lo, hi) of the same continuous ADPCM stream; its decoder entry state is not stored — crate_zone_load_span() runs the decoder from sample 0 to lo and captures it, exactly as the whole-sample zone would decode there. That makes a slice indistinguishable from pressing play at that point of the sample, however the sample was encoded.

Each slice carries pitch (1/16 semitone), LP cutoff, ADSR, velocity, level, loop and reverse flags, and a choke group. play.c applies them to the voice; the panel slicers use the neutral defaults, and the editor knobs above adjust the slice of the last pad hit. Edits stay in RAM and are written once when slice mode is left or the slot cursor moves, so a session of clicks costs one flash erase. Slices 0..15 map to the low 16 keys (pads 16..31 internally, so they never collide with slot pads), and to MIDI notes 36..51 while slice mode is on.

Slicing runs on the device in one decode pass per pass: SAVE cuts 16 equal parts, EDIT places up to 15 boundaries at energy onsets (64-sample windows, ≥10 ms apart, ranked by strength, then laid out in time order). The table is persisted per slot in the meta area:

RangeSizeUse
0xE5000..0xE5000+64*51232 KiBone 512-byte slice record per slot

Eight records share a 4 KiB sector, so saving one record reads, patches and rewrites its sector; the audio is silenced during the erase (XIP stalls).

Sequencer (Phase 4)

Two tracks × 16 steps on a 16th-note grid, driven from the 1 kHz TIMER5 millisecond tick by eng/crate_seq.h (pure C, host-tested) and mapped to the panel and voices by seq.c.

  • Swing 50..75 % delays the second 16th of every 8th (MPC convention). The MIDI clock is deliberately left straight: slaves hear a steady 24 PPQN master while the hits sit off the grid.
  • Micro-timing delays a step by up to 48/48 of a step. Late only — that is the laid-back jazz-hiphop feel — and it keeps every hit schedulable.
  • Ratchet splits a step into 1..8 evenly spaced hits; gate either lets the sample ring (one-shot) or sends a note-off halfway through the hit.
  • Record works stopped only: ARP arms it, each key press writes its pad into the cursor step (the same pad again clears the step) and advances the cursor. In slice mode the low 16 keys record slice ids (0..15), so a sliced break can be sequenced pad by pad; outside slice mode the same step plays slot id 0..15 instead, because the step stores the pad, not the mode it was recorded in.
  • Transport sends USB-MIDI START (0xFA), STOP (0xFC) and 24 PPQN CLOCK (0xF8) to the host, so the sequencer is the master clock for a DAW or the SLOOP FM-1 over a USB-MIDI bridge. TRS MIDI output still depends on the unconfirmed TX pin (see below).
  • Slave mode: a START (or CONTINUE while stopped) on either MIDI input puts the pattern on the incoming 24 PPQN clock — the same scheduler in pulse units (6 pulses per 16th), swing and micro-timing included. STOP stops it; a second without a clock is treated as a lost master and also stops it. The master's START/STOP is not echoed back, and the status line shows EXT in place of the tempo. Pressing SEQ always starts on the internal clock again.
  • TRS MIDI IN (UART1 RX, 31250 baud) feeds notes and clock through the same parser and queue as USB-MIDI; note 36 = slot 0 / slice 0 exactly as over USB. The TRS input is untested on hardware.
  • The pattern, tempo and swing persist in the project area (0x93000), in two A/B 4 KiB sectors: a save writes the older copy with the next sequence number, so a power loss mid-write leaves the other copy intact. An edited pattern is written once the transport is stopped, no voice is sounding and the last edit is 2 s old (a 4 KiB erase silences the DMA for a moment).

The event core is bounded per tick (≤64 events, ≤200 ms of catch-up), and the transport is stopped before any flash erase or zone rebuild, so a sequencer hit can never land in a zone (or an erased pool slot) that is being changed.

Character (Phase 5)

The mono bus runs a fixed chain in eng/crate_fx.h (pure C, host-tested) at 44.1 kHz, in this order:

StageRangeWhat it does
Rate reduction1x..32xsample-and-hold before the rest of the chain
Bit crush16..4 bitsarithmetic shift, truncating toward -inf (the SP grit)
Drive0..127gain 1.0..4.0 into a tanh soft clip
Vinyl0..127xorshift hiss plus sparse decaying crackle
Limiteralways onlinear below 0.875 FS, tanh shoulder to full scale

Every stage is 0 = off, so the chain is bit-exact transparent when idle. The limiter is instantaneous, so it cannot pump; it replaces the old hard clamp and maps anything above the knee into [0.875, 1.0] FS. Nothing in the chain uses division or 64-bit arithmetic (the tanh table is generated by tools/gen_tables.py), so the ISR stays free of library calls.

The parameters are global (one character setting for the whole bus, as on the SP-1200), edited on the REC page and not yet persisted across power cycles — they reset to off at boot.

Flash layout (Phase 2)

The writable plain window is 0x93000..0xFF000 (436 KiB; 0xFF000..0x100000 is the JieLi key_mac area and is never touched):

RangeSizeUse
0x93000..0x9700016 KiBproject: sequencer pattern, two A/B 4 KiB sectors
0x97000..0xE0000292 KiBsample pool: slot table + IMA ADPCM data
0xE0000..0xE500020 KiBOTA loader staging (transient, erased at boot)
0xE5000..0xF900080 KiBslice metadata (Phase 3)
0xF9000..0xFF00024 KiBglobal settings (A/B commit)

The pool stops before 0xE0000 because the M-UPGRADE update loader already owns 0xE0000..0xE5000. A sample is a 40-byte slot record plus its ADPCM bytes; loop-entry state and the reverse grid are recomputed from the data at load, never trusted from the upload.

Uploading samples

tools/crate_upload.py encodes a WAV (mono/stereo, 8/16/24/32-bit, any rate 8–96 kHz) as one continuous IMA ADPCM stream — the same state progression as the firmware decoder — and sends it over USB-MIDI SysEx:

python3 tools/crate_upload.py info
python3 tools/crate_upload.py upload 3 break.wav --name BREAK --loop 0:352800
python3 tools/crate_upload.py upload 5 kick.wav  --name KICK  --oneshot
python3 tools/crate_upload.py erase 3
python3 tools/crate_upload.py format

Live transfer needs mido + python-rtmidi; without them (or with --dump FILE.syx) the tool writes a standard .syx file instead, so the protocol can be exercised before the hardware is ready. DATA chunks are acknowledged one at a time, so a lost frame cannot silently leave a hole: the device also rejects out-of-order offsets and checks the CRC over the whole sample; only END publishes the new slot record. A transfer that fails or is interrupted leaves the pool header unchanged — but the slot being written has already had its old bytes erased at BEGIN, so re-upload it.

Wire format: F0 7D 43 52 54 <cmd> <pack7(payload)> F7, with commands INFO (0x01), BEGIN (0x02), DATA (0x03), END (0x04), DROP (0x05), ERASE (0x06) and FORMAT (0x07); replies repeat the command with bit 7 set and start with a status byte. crate_upload.h is the normative description.

Building

The JieLi PI32v2 toolchain (clang 4.0.1 r3) is Linux x86-64 only. On Windows, build inside WSL2 Ubuntu. This machine keeps the distro on D: (D:\wsl\Ubuntu, imported with wsl --import); .wslconfig puts the WSL swap file on D: as well, so nothing large lands on C:.

# 1. toolchain -> ~/.jieli/toolchain
tools/get_toolchain.sh

# 2. AC79 SDK (three files are used; the version is pinned)
git clone --depth 1 --branch AC79NN_SDK_V1.2.1_2023-12-13 \
    https://gitee.com/Jieli-Tech/fw-AC79_AIoT_SDK.git ~/fw-AC79_AIoT_SDK

# 3. build
./build.sh
# -> build/crate.fwsc, build/crate.bin, build/loader/ota.bin

JIELI_TOOLCHAIN and AC79_SDK override the default paths. build.sh runs tools/build.py, which compiles the app, builds the update loader, checks the image budget and enforces the rule that every hardware register access lives in firmware/hal/.

Installing

The package identity is FM-1_900; both installers check the M-UPGRADE handshake, the model and the loader marker before writing anything.

Browser (no installs, no network)

web/install.html speaks the same WebMIDI update protocol as the SLOOP / Felucca installer. Serve the directory and open the page in Chrome or Edge:

cd web && python3 -m http.server 8000
# http://localhost:8000/install.html

Select build/crate.fwsc, connect the FM-1 with a USB data cable and press Install. WebMIDI requires a secure context; http://localhost is one, while a file opened from disk (file://) may not be. The page uses web/fm1ota.js and web/fm1pkg.js (GPL-3.0-only, from SLOOP) unchanged.

Python

The FM-1 must be in update mode (hold OCT- and OCT+ for 5 s at power-on, or send the M-UPGRADE command over USB-MIDI). Then:

python3 tools/fm1_install.py build/crate.fwsc

Hardware smoke test

Connect only the CRATE unit; a second FM-1 running SLOOP must be unplugged, since both answer the same USB-MIDI device name. Go in this order — each step depends on the one above it.

#DoExpect
1Power on, no USBLCD shows CRATE; the heartbeat line counts up; status lines show 0 slots and free pool
2Press note keys, one encoderkeys trigger slots (silent with an empty pool); SELECT changes master volume; ALGORITHM changes tempo
3Plug in USBthe computer lists one MIDI port; tools/crate_upload.py finds it
4Upload a WAV to slot 3free space falls by the sample size; slot count rises; the sample survives a power cycle
5Press key 3, then another keythe sample plays once; voices count returns to 0 when it ends
6HOME on, SAVE, then EDIT16 equal slices, then transient slices; keys 0..15 play different parts
7ARP, tap keys, SEQthe pattern repeats at the tempo; PLAY stops it and cuts all voices
8Send MIDI clock from the computer over USBthe tempo line reads EXT and follows the host; stopping the clock stops the transport
9REC on, K1..K4REC LED lights; line 6 shows C.. R.. D.. V..; each knob is audible (crush, downsample, drive, vinyl); ENV resets to C0 R0 D0 V0
10Power cycleslots, slices and patterns persist; the character chain starts off

The character chain is not persisted (it resets at boot by design, so a unit never boots gritty). Report a failure with the LCD lines, which LED is lit, and whether the heartbeat advances — that separates a UI fault from a dead ISR.

Source layout

firmware/hal/        board drivers (header-only; register access lives here)
firmware/crt0.S      boot
firmware/app.ld      memory map
firmware/loader/     update loader
firmware/src/crate.c single compilation unit
firmware/src/audio.c ALNK0 ISR: engine render bus, master gain, character chain
firmware/src/ui.c    panel, LCD text and status screen, LEDs
firmware/src/main.c  boot, TIMER5, main loop, OTA hooks
firmware/src/store.c Phase 2 board glue: NOR pool + USB-MIDI upload service
firmware/src/play.c  Phase 3 glue: pool -> zones -> voices, key/MIDI map
firmware/src/seq.c   Phase 4 glue: sequencer tick, step record, clock out/in
firmware/src/midi_uart.c  Phase 4 glue: TRS MIDI IN ring -> the MIDI queue
firmware/src/eng/    Phase 1 engine + Phase 2 pool/upload + Phase 3/4 logic
                     (pure C, header-only)
firmware/src/*.c     scaffold: libc, lcd, usb, ota (reused, replaced per phase)
tests/               host golden tests for the engine
.github/workflows/   CI: the host suites, no toolchain needed
tools/               build, packaging, installer, table generator
web/                 browser installer (WebMIDI, no installs)

Licence and provenance

GPL-3.0-only (see LICENSE). The HAL, boot/link scaffolding, update loader, packaging and installer are reused from Felucca / SLOOP (C) Leo Kuroshita (@kurogedelic), Hügelton Instruments, also GPL-3.0-only. Files reused unchanged carry their original headers; the CRATE sources carry SPDX-License-Identifier: GPL-3.0-only.