schwung-dsp-development

star 1

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/`.

m-dwyer By m-dwyer schedule Updated 6/4/2026

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 section
  • MODULES.md — module index grouped by component type and quick descriptions
  • CLAUDE.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: Faust
  • audio_fx: Faust
  • midi_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:

  1. Edit src/modules/<id>/module.json — add/modify entries in capabilities.ui_hierarchy.levels.root.params. Each entry needs key, name, type, min, max, default, step.
  2. Plain C only: add a matching float <key>; field to the state struct in <id>_core.h. The generated set_param/get_param will write/read it directly.
  3. Faust only: add a matching hslider("<key>", default, min, max, step) declaration to <id>.dsp. The adapter captures it by label via buildUserInterface.
  4. Run mise run gen-params.
  5. Add the key to every preset in <id>/presets.json with a value inside [min, max], then run mise run gen-presets.
  6. Faust only: run mise run gen-faust.
  7. Use the new param in the DSP.
  8. 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.
  9. Add local audition metadata to <id>/metadata.json: a concise tooltip under params.<key> and a musical randomization hint under randomize.<key>. Keep these ranges inside the legal module.json min/max, but narrower when full extremes are only useful for stress testing. Use mode: "bounded" for a useful fixed range, mode: "around_default" when randomization should stay near the default/current setting, and mode: "full" only when the whole legal range is musically useful. Do not put local help text or audition-only randomize hints in Schwung-facing module.json unless Schwung officially supports those fields.
  10. Add or extend the assertion in tests/test_<id>_core.c.
  11. 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:

  1. Never edit generated files by hand. _params.gen.inc, _presets.gen.inc, ui_chain.js, and _faust.c are produced by gen-params, gen-presets, gen-ui-chain, and gen-faust. Hand edits will be silently overwritten the next time those run, and validate will flag drift in CI. If you want different output, edit the source (module.json, presets.json, or .dsp) or the generator.

  2. Always re-run generators after editing their sources. Run gen-params and gen-ui-chain after editing module.json, gen-presets after editing presets.json, and gen-faust after editing .dsp. pnpm run validate includes check steps that fail on stale generated output.

  3. Don't deploy from a dirty working tree. scripts/install-to-move.sh refuses 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.

  4. Don't hand-edit the goldens. goldens/<id>/metrics.json is regenerated by pnpm run bless-renders after you've listened/looked at the suite and decided the change is intentional. The .wav golden files are gitignored (only the metrics matter for CI).

  5. 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.

  6. Stay inside the constraints. No malloc/free in the audio loop — the core struct is calloc'd once at instance creation. The host runs the audio thread at SCHED_FIFO 90 on a single core with ~2.9 ms per 128-frame block; per-block allocations or syscalls will glitch or crash. Schwung sets FPCR FZ (flush-to-zero), so denormals are silently flushed — don't add denormal guards.

  7. 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:

  1. mise run validate fails? Run the named gen script. Usually gen-params/gen-ui-chain (after editing module.json), gen-presets (after editing presets.json), or gen-faust (after editing .dsp).
  2. mise run test fails? The core smoke test exercises init, param clamping, processing finite/bounded output. Read the assertion that failed first, not the whole test.
  3. Renders sound wrong? mise run plot → look at renders/plots/<id>/. Spectrum tells you cutoff frequency, harmonic content, and noise floor at a glance. Waveform tells you envelope shape, clipping, and DC offset.
  4. A parameter extreme misbehaves? MODULE_ID=<id> mise run plot-stress → inspect renders/plots/<id>-stress/. The generated stress cases come from module.json, so if a new param has unsafe min/max behavior this is where it should show up.
  5. WASM builds but no sound in browser? Check that the module is registered in src/modules/index.json and the WASM file exists at web/wasm/<id>.wasm.
  6. check-renders fails on a Faust module after editing .dsp? Expected — DSP changed. Listen to the renders, look at the plots, decide if intentional, then pnpm run bless-renders.
  7. Faust output looks weird? The generated _faust.c is readable but verbose. Trust the .dsp source — 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/ and stdfaust.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.
Install via CLI
npx skills add https://github.com/m-dwyer/moveforge --skill schwung-dsp-development
Repository Details
star Stars 1
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator