name: schwung-dsp-development
description: Author DSP modules for Ableton Move's Schwung host inside the moveforge repo (~/src/moveforge). Covers sound generators (synth voices), audio FX, and MIDI FX, in either Faust (.dsp) or plain C. Use this skill whenever the user mentions creating, modifying, debugging, or deploying Schwung modules, moveforge modules, Move plugins, faust modules, or DSP iteration — even if they don't name the skill explicitly. Trigger on phrases like "new synth", "add a delay", "new module", "Faust drive", "Schwung deploy", "fix this DSP", or paths under src/modules/.
Schwung DSP Development
Guide DSP iteration in the moveforge repo at ~/src/moveforge. The repo builds custom modules for Ableton Move's Schwung host. Each module compiles to the same dsp.so (aarch64 device build) and web/wasm/<id>.wasm (browser audition) from a single source tree, so what works locally is what plays on device.
This skill exists because there are now two authoring paths (Faust and plain C) sharing one wrapper pipeline, and the right path depends on the kind of module and what the user wants to iterate on. Picking the wrong path early causes wasted refactor work later.
Read first
Before suggesting changes, skim these in ~/src/moveforge:
README.md— current command surface and the authoring-paths sectionMODULES.md— module index grouped by component type and quick descriptionsCLAUDE.md— architecture, conventions, ABI, hard constraints (sample rate, block size, no per-block allocation)docs/faust-first-schwung-dsp-workflow.md— the design doc that motivated the Faust path
Don't re-derive these. Specifically, read CLAUDE.md's "Architecture" and "Development Conventions" sections — they're the canonical source for what shapes a module.
Pick the authoring path
The first decision is Faust or plain C. The kind of module narrows it:
| Module kind | Recommended path | Reason |
|---|---|---|
audio_fx |
Faust | Filter cascades, delays, waveshapers, reverbs are dramatically shorter in declarative Faust. Reference: src/modules/faust_drive/. |
sound_generator |
Faust if a generic voice (osc → env → filter); plain C for unusual oscillator math, FM topologies, low-pass-gate shaping, or anything where you want full control. Faust reference: src/modules/faust_voice/. C references: src/modules/westfold/ (West Coast), src/modules/dustline/ (subtractive). |
|
midi_fx |
Plain C, always. Faust is sample-stream first and contorts around discrete MIDI events. Reference: src/modules/arpy/. |
When in doubt, ask the user: "Faust for declarative DSP, or plain C for full control?" Don't silently pick.
File layout
The _core.h header is the API contract. Both paths implement the same contract — the wrapper (<id>.c) and tests can't tell which is in use. The difference is what implements it:
src/modules/<id>/
├── module.json metadata + param schema (single source of truth)
├── metadata.json local/web-only help text, including param tooltips
├── presets.json preset values + render directives for the suite
├── ui.js solo-mode on-device UI shim
├── ui_chain.js GENERATED chain-mode UI: preset browser, then 8-encoder paged param editor
└── dsp/
├── <id>_core.h public API contract (shared)
├── <id>_params.gen.inc GENERATED from module.json — never edit by hand
├── <id>_presets.gen.inc GENERATED from presets.json — never edit by hand
├── <id>.c Schwung wrapper (plugin_api_v2 / audio_fx_api_v2 / midi_fx_api_v1)
│
├── <id>_core.c PLAIN C: the DSP implementation
│ ─ OR ─
├── <id>.dsp FAUST: canonical source (you edit this)
├── <id>_faust.c FAUST: GENERATED by `mise run gen-faust` — never edit by hand
└── <id>_adapter.c FAUST: bridges generated DSP to the _core.h API
A module is Faust-backed if and only if <id>.dsp exists. The build scripts detect this and compile the right .c. There is no flag, no config — the file layout is the signal.
Output scope. Scaffolded sound_generator and audio_fx wrappers ship with an output-waveform scope (the chain UI draws it while the module is sounding). It taps the wrapper's float output via src/modules/_shared/scope.h — no DSP-core changes needed. It is configured only in module.json (capabilities.scope, the single source of truth): style is envelope (default, honest min/max — leave this for noise/poly/FM/fold voices), triggered (phase-locked — good for mono harmonic voices), line, or none to disable; plus mode (continuous/oneshot) and window. mise run gen-params turns that block into <id>_scope.gen.inc, which the wrapper includes — so re-run gen-params after editing it (validate flags drift). midi_fx modules have no audio out and so no scope. See docs/scope-adaptive-plan.md.
Workflow: create a new module
pnpm run new-module -- --id myfx --kind audio_fx
pnpm run new-module -- --id mysynth --kind sound_generator
pnpm run new-module -- --id myarp --kind midi_fx
pnpm run new-module -- --id hand_tuned --kind sound_generator --dsp c
The scaffolder renders the matching template pack from
templates/modules/<component_type>/<dsp>/, renders
{{moduleId}}/{{moduleUpper}}/{{moduleName}}/{{moduleAbbrev}}
placeholders, runs gen-params, gen-presets, gen-ui-chain, registers in
src/modules/index.json, and prints next steps tailored to the kind.
Defaults:
sound_generator: Faustaudio_fx: Faustmidi_fx: C
Use --dsp c for audio DSP only as a documented exception. Use --dry-run to
inspect the template pack and rendered file list without writing files.
Workflow: add or change a parameter
Both paths share this loop:
- Edit
src/modules/<id>/module.json— add/modify entries incapabilities.ui_hierarchy.levels.root.params. Each entry needskey,name,type,min,max,default,step. - Plain C only: add a matching
float <key>;field to the state struct in<id>_core.h. The generatedset_param/get_paramwill write/read it directly. - Faust only: add a matching
hslider("<key>", default, min, max, step)declaration to<id>.dsp. The adapter captures it by label viabuildUserInterface. - Run
mise run gen-params. - Add the key to every preset in
<id>/presets.jsonwith a value inside[min, max], then runmise run gen-presets. - Faust only: run
mise run gen-faust. - Use the new param in the DSP.
- If the parameter surface changed, run
mise run gen-ui-chain. Generated chain UIs expose a preset browser first, then a scrollable param editor where the jog wheel selects/edits the focused parameter and Move encoders 1-8 control the page containing that selected param. - Add local audition metadata to
<id>/metadata.json: a concise tooltip underparams.<key>and a musical randomization hint underrandomize.<key>. Keep these ranges inside the legalmodule.jsonmin/max, but narrower when full extremes are only useful for stress testing. Usemode: "bounded"for a useful fixed range,mode: "around_default"when randomization should stay near the default/current setting, andmode: "full"only when the whole legal range is musically useful. Do not put local help text or audition-only randomize hints in Schwung-facingmodule.jsonunless Schwung officially supports those fields. - Add or extend the assertion in
tests/test_<id>_core.c. - Run
mise run validate(param drift + gen drift + preset/UI range).
Workflow: iterate on sound
# 1. Edit the DSP source
# - Plain C: src/modules/<id>/dsp/<id>_core.c
# - Faust: src/modules/<id>/dsp/<id>.dsp
# 2. Faust: regenerate
MODULE_ID=<id> mise run gen-faust
# 3. Smoke test
mise run test # all modules
# or:
MODULE_ID=<id> ./scripts/test.sh # just this one
# 4. Render the preset suite
MODULE_ID=<id> mise run suite
# 5. Visual inspection (waveforms + log-frequency spectrum)
MODULE_ID=<id> mise run plot
# PNGs at renders/plots/<id>/
# 6. Stress min/max parameter extremes
MODULE_ID=<id> mise run stress
MODULE_ID=<id> mise run plot-stress
# WAVs at renders/<id>-stress/, PNGs at renders/plots/<id>-stress/
# 7. Compare against blessed goldens (or initialize them)
mise run check-renders
# If intentional change: MODULE_ID=<id> pnpm run bless-renders
# 8. Browser audition with hot reload
mise run dev
# http://localhost:8765/ → pick <id> in the module selector
For AI-assisted iteration: ask for small, contained changes (one filter, one envelope). Always render and check the plots before judging the sound — audio bugs are much easier to catch from a deterministic WAV fixture than from code review alone.
Stress renders are generated from module.json and cover default, each exposed param at min/max, all-max, and hot/fast cases. Browser audition randomize is intentionally different: it uses local metadata.json randomize hints and a Subtle/Medium/Wild amount to explore musical ranges quickly. Stress still exercises the full legal range. They cover sound generators and audio FX; MIDI FX are skipped because they render traces, not WAVs. Stress checks are safety gates, not golden comparisons: clipped samples, excessive DC, unexpected silence, too-hot peaks, and stereo imbalance fail. Use mise run stress-all or mise run plot-stress-all when auditing the whole audio-module set; expect these commands to expose older modules that need headroom/DC fixes.
Build, package, deploy
# Browser WASM (Emscripten via Docker — automatic)
MODULE_ID=<id> mise run wasm
# aarch64 Move package (Docker cross-compile)
MODULE_ID=<id> mise run move
# → dist/<id>/ and dist/<id>-module.tar.gz
# The tarball includes module.json, ui.js, ui_chain.js, presets.json, dsp.so
# Deploy to a real device (only when the user has hardware and asks)
MODULE_ID=<id> mise run install
# COMPONENT_TYPE is inferred from module.json. Override with COMPONENT_TYPE=...
Run mise run check (or mise run check-all for every module) before suggesting a commit. It's the canonical gate: typecheck + validate + test + suite + check-renders + plot + host build. Also run MODULE_ID=<id> mise run stress for changed audio modules; run mise run stress-all before promoting stress checks into a broad cleanup because it intentionally fails on existing unsafe extremes.
Rules
These are load-bearing — violating them causes real bugs or wastes time:
Never edit generated files by hand.
_params.gen.inc,_presets.gen.inc,ui_chain.js, and_faust.care produced bygen-params,gen-presets,gen-ui-chain, andgen-faust. Hand edits will be silently overwritten the next time those run, andvalidatewill flag drift in CI. If you want different output, edit the source (module.json,presets.json, or.dsp) or the generator.Always re-run generators after editing their sources. Run
gen-paramsandgen-ui-chainafter editingmodule.json,gen-presetsafter editingpresets.json, andgen-faustafter editing.dsp.pnpm run validateincludes check steps that fail on stale generated output.Don't deploy from a dirty working tree.
scripts/install-to-move.shrefuses by default. The point is that every device build maps to a known commit — if it sounds wrong on the device, you need to be able to reproduce the source.Don't hand-edit the goldens.
goldens/<id>/metrics.jsonis regenerated bypnpm run bless-rendersafter you've listened/looked at the suite and decided the change is intentional. The.wavgolden files are gitignored (only the metrics matter for CI).Render before claiming a fix. "It compiles" doesn't tell you it sounds right. Render the suite, run stress, look at the plots, listen, then commit.
Stay inside the constraints. No
malloc/freein the audio loop — the core struct is calloc'd once at instance creation. The host runs the audio thread atSCHED_FIFO 90on a single core with ~2.9 ms per 128-frame block; per-block allocations or syscalls will glitch or crash. Schwung setsFPCR FZ(flush-to-zero), so denormals are silently flushed — don't add denormal guards.C is the device-facing language. No C++ in modules.
libstdc++is not guaranteed on Move. The reference modules (in~/src/schwung) confirm this.
Reference modules — when to look at which
When you need a concrete pattern to copy:
| Need | Look at |
|---|---|
| Current module catalog by type | MODULES.md |
| Faust audio_fx with stereo I/O | src/modules/faust_drive/ |
| Faust sound_generator with C-owned MIDI | src/modules/faust_voice/ (see how gate/freq/gain zones are captured and pushed each block) |
| Hand-written FM oscillator + wavefolder + low-pass gate | src/modules/westfold/dsp/westfold_core.c |
| Hand-written subtractive voice + resonant filter | src/modules/dustline/ |
| Hand-written stereo feedback delay (audio_fx) | src/modules/trail/ |
| MIDI FX (event dispatch, clock sync, voice tracking) | src/modules/arpy/ |
| Shared Faust adapter boilerplate | src/host/faust_adapter.h |
| Faust architecture template | src/host/faust_module_arch.c.in |
| Schwung ABI references | src/host/plugin_api_v1.h, audio_fx_api_v2.h, midi_fx_api_v1.h |
| Real Schwung host code (rarely needed) | ~/src/schwung/ (separate clone) |
Debugging checklist
When a module misbehaves:
mise run validatefails? Run the named gen script. Usuallygen-params/gen-ui-chain(after editingmodule.json),gen-presets(after editingpresets.json), orgen-faust(after editing.dsp).mise run testfails? The core smoke test exercises init, param clamping, processing finite/bounded output. Read the assertion that failed first, not the whole test.- Renders sound wrong?
mise run plot→ look atrenders/plots/<id>/. Spectrum tells you cutoff frequency, harmonic content, and noise floor at a glance. Waveform tells you envelope shape, clipping, and DC offset. - A parameter extreme misbehaves?
MODULE_ID=<id> mise run plot-stress→ inspectrenders/plots/<id>-stress/. The generated stress cases come frommodule.json, so if a new param has unsafe min/max behavior this is where it should show up. - WASM builds but no sound in browser? Check that the module is registered in
src/modules/index.jsonand the WASM file exists atweb/wasm/<id>.wasm. check-rendersfails on a Faust module after editing.dsp? Expected — DSP changed. Listen to the renders, look at the plots, decide if intentional, thenpnpm run bless-renders.- Faust output looks weird? The generated
_faust.cis readable but verbose. Trust the.dspsource — start there, regenerate, and only inspect the generated C if symbol-level debugging needs it.
What this skill does NOT do
- Doesn't help you author the actual Faust DSP language. For Faust idioms (filter design, oscillator construction, FFT use, polyphony), point the user at
https://faustdoc.grame.fr/manual/andstdfaust.lib. - Doesn't help with Schwung host internals — modules are leaf nodes. If the host itself needs changes, that's a separate repo (
~/src/schwung). - Doesn't help with the React/Vite browser UI beyond confirming the module is registered and the WASM exists.
- Doesn't help with the Move device (network, OS, USB). Those are outside this repo.