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
| Control | Action |
|---|---|
| 27 note keys | slot mode: trigger slots 0..26; a looped slot loops while held |
| USB-MIDI notes | note 36 (C2) = slot 0 … note 99 = slot 63 (MPC pad layout) |
| HOME | slice mode on/off |
| K1 encoder | selected slot (the slot that slice mode and the slicers act on) |
| SAVE | slice the selected slot into 16 equal pads, and save the table |
| EDIT | slice it at detected transients (up to 16), and save the table |
| keys 0..15 in slice mode | play slices 0..15 of the selected slot; the last one hit is the edited slice |
| MIDI 36..51 in slice mode | the same 16 slices |
| K2 encoder (slice mode) | slice pitch, 1 semitone per click (range −48..+36) |
| K3 encoder (slice mode) | slice level |
| K4 encoder | slice 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) |
| SEQ | sequencer play/stop |
| ARP | arm 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 % |
| PLAY | panic: stop the sequencer and cut every voice |
| REC | character (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 encoder | master volume |
| ALGORITHM encoder | sequencer tempo, 40..240 BPM |
| OCT- + OCT+ held 5 s | M-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, the2^(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:
| Field | Meaning |
|---|---|
data, n | packed ADPCM bytes (two samples per byte, low nibble first), sample count |
ls, le, lpred, lidx | loop start/end and the decoder state at ls |
rate_q16 | source sample rate / 44100, Q16 |
root16 | root pitch in 1/16 semitones (MIDI note × 16) |
grid | 64 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:
| Range | Size | Use |
|---|---|---|
0xE5000..0xE5000+64*512 | 32 KiB | one 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
EXTin 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:
| Stage | Range | What it does |
|---|---|---|
| Rate reduction | 1x..32x | sample-and-hold before the rest of the chain |
| Bit crush | 16..4 bits | arithmetic shift, truncating toward -inf (the SP grit) |
| Drive | 0..127 | gain 1.0..4.0 into a tanh soft clip |
| Vinyl | 0..127 | xorshift hiss plus sparse decaying crackle |
| Limiter | always on | linear 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):
| Range | Size | Use |
|---|---|---|
0x93000..0x97000 | 16 KiB | project: sequencer pattern, two A/B 4 KiB sectors |
0x97000..0xE0000 | 292 KiB | sample pool: slot table + IMA ADPCM data |
0xE0000..0xE5000 | 20 KiB | OTA loader staging (transient, erased at boot) |
0xE5000..0xF9000 | 80 KiB | slice metadata (Phase 3) |
0xF9000..0xFF000 | 24 KiB | global 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.
| # | Do | Expect |
|---|---|---|
| 1 | Power on, no USB | LCD shows CRATE; the heartbeat line counts up; status lines show 0 slots and free pool |
| 2 | Press note keys, one encoder | keys trigger slots (silent with an empty pool); SELECT changes master volume; ALGORITHM changes tempo |
| 3 | Plug in USB | the computer lists one MIDI port; tools/crate_upload.py finds it |
| 4 | Upload a WAV to slot 3 | free space falls by the sample size; slot count rises; the sample survives a power cycle |
| 5 | Press key 3, then another key | the sample plays once; voices count returns to 0 when it ends |
| 6 | HOME on, SAVE, then EDIT | 16 equal slices, then transient slices; keys 0..15 play different parts |
| 7 | ARP, tap keys, SEQ | the pattern repeats at the tempo; PLAY stops it and cuts all voices |
| 8 | Send MIDI clock from the computer over USB | the tempo line reads EXT and follows the host; stopping the clock stops the transport |
| 9 | REC on, K1..K4 | REC LED lights; line 6 shows C.. R.. D.. V..; each knob is audible (crush, downsample, drive, vinyl); ENV resets to C0 R0 D0 V0 |
| 10 | Power cycle | slots, 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.