FaustMod offers a modular visual design environment to Faust (Functional Audio Stream), a functional programming language for sound synthesis and audio processing with a strong focus on the design of synthesizers, musical instruments, audio effects, etc.
A browser-based modular audio synthesis. Patch DSP components together on a node canvas, hear the result live, write your own DSP in the built-in Faust editor, and save/open patches. DSP is written in the Faust language and runs as WebAssembly AudioWorklets in the browser.
- React + Vite + TypeScript — app shell and tooling
- rete.js v2 (React renderer) — the node editor
- @grame/faustwasm — in-browser Faust → WASM AudioWorklet compiler
- Web Audio API — routing, mixing, stereo I/O
- ~700 palette components — 647 precompiled Faust DSP blocks (oscillators, filters, delays, reverbs, envelopes, dynamics, distortion, modulation, math, routing…) plus 52 interactive widgets, all in one searchable palette.
- Every block documents itself — hovering a palette entry or a node header shows the
block's own Faust standard-library documentation (description, usage, parameters,
source library and licence), extracted at build time from the
.libsources inside the compiler that builds the factories, so it can't drift. The DSP editor's title bar links the Faust libraries reference. - Sequencing & pitch — clock divider/multiplier, Euclidean sequencer (steps/pulses/rotation), arpeggiators (chord shapes clocked), and per-scale quantizers (major, minor, modes, pentatonic, blues, whole-tone, chromatic).
- Mixing & modulation — a Mixer 4 (level/pan/send → L, R, aux), an 8→mono sub-mixer, a constant-power Pan, a 2×2 mod matrix, an attenuverting CV mixer, and a morphing Wavetable oscillator.
- Instrument/widget nodes — oscilloscope (signal + trigger, resizable), a four-trace Scope x4, spectrogram, spectrum analyzer, analog VU meter, digital voltmeter, R/G/B/Y LEDs, stereo Correlation and Loudness (LUFS + peak) meters, a control-rate CV Plotter, an Audio Health probe (the audio thread's own callback timing, not the UI's), a numeric Value Monitor, and 8/16-step sequencers (drag for pitch, click to mute, shift-drag for velocity → frequency, gate and velocity outputs).
- Drawable editors — widgets whose drawn shape is the parameter: a breakpoint Envelope, a Wavetable Draw cycle, a Transfer Curve waveshaper, a Multislider bank and a 10-band Graphic EQ.
- Sequencing — a master Transport (run/stop/reset/tap generating the 16th clock), an 8×16 Drum Grid with a trigger out per lane, a Piano Roll clip editor (keyboard gutter, notes you stretch and move, a velocity lane feeding the vel output), a Euclid Circle, a Turing Machine shift register and a Probability Gate.
- Control / playability — on-screen Keyboard (mouse or A–K keys), MIDI In and MIDI Out (Web MIDI) plus a MIDI Monitor, a rotary Knob, an XY Pad macro, a Selector switch, a typed Number Box, a Morph Pad (four snapshots blended by a puck), Pads x8, a Randomize dice, a Group Frame (sits behind its contents and carries them when moved), a Comment note, a Clock (BPM), and an Env VCA (gate-driven ADSR).
- Sample player — load an audio file; a trigger plays it, with a rate/pitch control, a waveform view with draggable start/end markers and a loop toggle. Plus a Looper (record/play/overdub) and Convolution reverb from a loaded impulse response and stereo output.
- Granular — load a file and get a continuous windowed grain cloud with position, grain size, density, pitch and spray control inputs (scan/modulate them for textures).
- User Defined DSP — a right-hand palette of your own Faust modules. Create one with
+ New DSP, edit it in a floating CodeMirror editor (Faust syntax highlighting,
Compile to check, Save as a draft, or Done to apply), double-click to edit, rename/delete,
and drag onto the canvas. A slider in your code (
hslider(...)) declares a control-input connector (not an on-screen knob). Stored inlocalStorage. See User Defined DSP below. - Custom blocks (import) — paste a self-describing block definition (Faust source + port metadata) via Block → Import DSP Block…; compiled in-browser and added to your DSP. See Custom DSP blocks.
- Multiple tabs — one patch per tab; only the active tab plays.
- Recording + devices — record the master output (Rec button →
.webm, or a Record node driven from the patch); pick audio input/output devices, the audio buffer size (small/balanced/large), and whether Audio Input gets the browser's speech processing (echo cancel / noise suppression / AGC — off by default, since it gates and resamples music) in File → Settings. - Example patches — bundled, musically-verified demos (Acid Bassline, Evolving Pad, FM Pluck, Karplus Melody) in a preset browser; opening one drops the whole patch on a new tab to take apart.
- File management — a top menu (File / Edit / View / Block / Help) with New, Open,
Save, Save As, Export, undo/redo, and the
.faustmodpatch format. - Portable backup — patches, your DSP modules and settings live in
localStorage, which is tied to one browser on one machine; File → Export All… writes the whole library to a single JSON file and Import All… restores it elsewhere. - AI DSP authoring — the New DSP editor has a Make button that generates Faust from a prompt via your own OpenRouter key (File → Settings). See Using an external AI.
- Bring-your-own-AI — or drive an external chat: File → Export Catalog for AI… gives it the formats + catalog to write whole patches. See Using an external AI below.
npm install
npm run dev # http://localhost:5173
npm run build # type-check + production buildDrag a component from the left palette onto the canvas, drag between sockets to patch,
then Start. Use + New DSP (right panel) to write your own Faust DSP, the File
menu to save/open .faustmod patches, Block → Import DSP Block… to import a Faust
block, and File → Export Catalog for AI… to drive an external AI.
The DSP blocks are precompiled at build time into WASM factories. These artifacts —
public/factories/ and src/generated/catalog.json — are generated, not committed
(they're in .gitignore), so a fresh clone must build them before the app can load any
blocks:
npm run catalog # compile all blocks → public/factories/ + src/generated/catalog.json
npm run catalog -- --force # force a full rebuild (skips the up-to-date check)You normally don't run this by hand — it's wired to run automatically:
npm run dev→predevrunsnpm run catalognpm run build→prebuildrunsnpm run catalog
The step skips when already fresh (catalog newer than scripts/blocks.mjs), so it
only pays the compile cost when the block definitions change. Requires no external tools —
libfaust runs in Node via @grame/faustwasm. See
Block catalog & precompiled factories
for how it works and how to add blocks.
FaustMod is a static SPA with no backend — nothing to provision, no secrets, no database. The repo ships everything the deploy needs:
| File | Role |
|---|---|
Dockerfile |
Two stages: node:22-alpine runs npm ci && npm run build (which builds the block catalog via prebuild), then nginx:1.27-alpine serves the resulting dist/. |
nginx.conf |
Static serving: gzip, application/wasm MIME type, immutable year-long caching for /assets/ and .wasm, no-cache on index.html, and SPA fallback to index.html. |
fly.toml |
App faustmod, region yyz, internal port 80, forced HTTPS, one shared-cpu-1x/256 MB machine that auto-stops when idle and auto-starts on request (min_machines_running = 0). |
First time on a new machine, install flyctl — on macOS:
brew install flyctlon Linux (or WSL):
curl -L https://fly.io/install.sh | shon Windows (PowerShell): iwr https://fly.io/install.ps1 -useb | iex. Then authenticate:
fly auth loginThen, from the repo root:
fly deployThat's the whole loop — fly deploy builds the image remotely from the Dockerfile,
pushes it, and rolls the machine. It serves https://faustmod.fly.dev.
Notes:
- Don't build locally first.
.dockerignoreexcludesdist/andnode_modules/, so the image is always built from source inside the container; a stale localdist/can't leak into a deploy. - The catalog is built in the image.
public/factories/andsrc/generated/catalog.jsonare gitignored, andprebuildregenerates them duringnpm run build— so the deployed catalog always matchesscripts/blocks.mjsat that commit. - First request after idle is slow. The machine auto-stops when nothing is hitting it,
so a cold start adds a second or two. Set
min_machines_running = 1infly.tomlto keep one warm (at the cost of running it 24/7). - Claiming the app name. A fresh fork needs its own app: change
appinfly.toml, thenfly launch --no-deploy(orfly apps create <name>) before the firstfly deploy.
fly logs # nginx access/error output
fly status # machine state, region, last release
fly open # open the deployed app in a browserThe audio engine (src/audio/) is deliberately decoupled from the editor
(src/editor/):
| Layer | Responsibility |
|---|---|
FaustService |
Loads precompiled block factories (createFactoryNode) with no compiler for the built-in library; also owns libfaust (compile) to build user-authored DSP at runtime. |
AudioEngine |
Owns the AudioContext and master gain → speakers. Created lazily on first user gesture. |
AudioGraph |
Holds the desired graph (nodes, connections, params). While "live", mirrors it into real Web Audio nodes. |
units.ts |
FaustUnit / ConstantUnit / OutputUnit / InputUnit — each exposes Faust channels as individual mono ports via ChannelSplitter/ChannelMerger. FaustUnit handles both kinds of control input: a hslider/nentry param binds to the worklet's AudioParam, and a plain signal input with a default is fed by an internal ConstantSourceNode until a connection detaches it. |
Each Faust component runs as its own AudioWorklet (instantiated from a precompiled
factory); a rete connection becomes a splitter.connect(merger, srcCh, dstCh) call. The editor pushes every change
(nodecreated, connectioncreated, param edits…) into AudioGraph, so playback stays
in sync with the canvas whether or not audio is currently running.
Component metadata (ports, defaults) is declared up front and loaded from the generated
catalog.json, so the editor knows each node's sockets and parameters instantly — no
compilation happens at startup (see below).
The built-in library is 647 DSP blocks, and it must not slow startup. So blocks are precompiled at build time, never in the browser:
scripts/blocks.mjsdeclares candidate blocks as families of Faust functions.npm run catalog(run automatically byprebuild) compiles each one once in Node with libfaust, emits a tinypublic/factories/<id>.wasm(~2 KB) +<id>.jsonper block, and writessrc/generated/catalog.jsonwith all the metadata. Blocks that fail to compile — or whose real I/O count doesn't match — are pruned, so the shipped catalog is always valid.scripts/faust-docs.mjsreads the standard-library documentation straight out of the.libsources packed insidelibfaust-wasm.dataand attaches each block's description, usage, parameters, library and licence to its catalog entry — which is where the hover tooltips come from, and how the licence of every block is known (see THIRD_PARTY_NOTICES.md).- At runtime the UI imports
catalog.json(bundled, parsed instantly) and renders the palette. No libfaust, no compilation at startup — the UI is interactive immediately. - A block's factory is fetched lazily (
FaustService.createFactoryNode) only when a node of that type is first placed/played:fetch~2 KB wasm →WebAssembly.compile→ node.
libfaust (the ~3 MB compiler) is only loaded for user-authored DSP (the New DSP editor and imported blocks), not for the built-in library.
To add blocks: extend the families in scripts/blocks.mjs and run npm run catalog.
Two rules the families follow. Vary only what Faust needs at compile time — filter
order, stage count, channel count. A block whose only variable is a value that could be
an input port should be one parametric block instead: 30 fixed-frequency EQ bands were
removed for exactly this, since Peak EQ takes the frequency as a port and can be swept.
And never trust a remembered Faust signature — the build prunes anything that fails to
compile or whose real I/O count doesn't match, so a wrong guess ships as a silently
missing palette entry. Check the real signature (the .lib sources are inside
node_modules/@grame/faustwasm/libfaust-wasm/libfaust-wasm.data) and offline-render new
blocks before committing.
The palette, audio graph, and AI all scale to the new count with no other changes.
Nodes have input ports and output ports only — there are no inline parameter knobs.
Every parameter is an audio-rate control input. process(freq, gain) = ... declares
two control inputs; wiring a signal into one modulates that parameter sample-by-sample.
- An input with a
default(e.g.freq = 220) is a control input. When nothing is connected, an internalConstantSourceNode(default)drives it. Connecting a node detaches the default so the incoming signal takes over. - An input without a default is a signal input (silent when unconnected).
- The Constant node is the only node with an editable value; it's how you set a
specific frequency, cutoff, gain, etc. — wire
Constant(440) → freq.
This means an LFO into a filter's cutoff, or an envelope into a VCA's gain, is just a
normal connection.
DSP blocks are declared in scripts/blocks.mjs and compiled by
npm run catalog — LIBRARY in src/components/library.ts
now holds only the special nodes (Constant, Audio In/Out, patch terminals) and merges the
generated catalog. A block is B(id, title, category, args, body, tooltip?), where args
are the process arguments in order — sig() for a signal input, ctl() for a control
input with a default (and optional min/max/unit):
B("tremolo", "Tremolo", "Dynamics",
[sig("x", "in"), ctl("rate", "rate", 5, 0.1, 20, "Hz"), ctl("depth", "depth", 0.5, 0, 1)],
"x * (1 - depth * (0.5 + 0.5 * os.osc(rate)))");import("stdfaust.lib") is prepended, and the block is compiled at build time: its real
input/output count is checked against the declared args, and anything that fails either
check is pruned from the shipped catalog.
Documenting ports: a block's tooltip, or the tooltip field on a component,
InputSpec or OutputSpec, is how you document what a node/port does. On hover, the node
header and each port show a tooltip combining your text, the Faust standard-library docs
for the underlying function (pulled in automatically by scripts/faust-docs.mjs), and
auto-generated facts (default value, range, unit, whether it's a control or signal input).
Nodes shrink-wrap their content, so they vary in size and add ports without wasted space.
The editor's dark 3D-metallic look (glowing category-accented headers, beveled node
bodies, pill controls, signal cables, line grid) lives in
src/editor/theme/ and is applied through rete-react-plugin's
customize hooks (ThemedNode, ThemedSocket) plus theme.css. Header accents are set
per category in accents.ts.
The right-hand User Defined DSP palette holds your own Faust modules (stored in
localStorage). + New DSP creates one from a small template; double-click any entry to
open a floating CodeMirror editor (Faust syntax highlighting, standard shortcuts, and
Cancel / Compile / Save / Done — Save keeps a draft without compiling and flags it
with an amber dot; Done recompiles and applies). Rename (✎) and delete (×) inline, and
drag the entry onto the canvas.
Ports come straight from your code, read from the compiled Faust JSON (derivePorts in
src/audio/faustIO.ts): audio channels become signal ports, and a UI param
(hslider/nentry/button) declares a named control-input connector — with its default
and range — rather than an on-screen knob. At runtime the module compiles via libfaust and
is wrapped in the same FaustUnit as everything else, so a param control input binds to the
worklet's AudioParam (a wired signal drives it; unconnected, the declared default holds).
Note: the built-in catalog blocks instead declare their control values as named signal inputs with metadata in
scripts/blocks.mjs. A user module gets the same treatment viahslider, since the editor only has the code to work from. New to Faust? See the Faust manual.
Block → Import DSP Block… takes a self-describing block definition — Faust source plus
the port metadata the control-input model needs (labels + defaults). It's compiled
in-browser with libfaust to verify (and to read the I/O count), then added to your
User Defined DSP and persisted in localStorage:
{
"format": "faustmod-block",
"title": "My Lowpass", "category": "Custom",
"inputs": [ {"label":"in"}, {"label":"cutoff","default":1000,"min":20,"max":20000,"unit":"Hz"} ],
"outputs": [ {"label":"out"} ],
"code": "import(\"stdfaust.lib\"); process(x, cutoff) = x : fi.lowpass(2, cutoff);"
}process() takes its control values as named signal inputs in the same order as
inputs (this is the audio-rate control-input model — see above). Custom blocks are
compiled at runtime; built-in blocks load precompiled factories.
Patches save as .faustmod (JSON): metadata, nodes, connections, and any custom
blocks embedded so a patch is self-contained. Save/Open use the File System Access API
(with download/upload fallback). See src/patch/format.ts.
In-app (DSP blocks): the New DSP editor has a Make button. It sends your prompt (and the current code) to OpenRouter with a small Faust/FaustMod coding-guidelines system prompt and drops the generated program into the editor. It uses your own OpenRouter key (set it, with the model, in File → Settings); the key stays in your browser and is sent only to openrouter.ai, so FaustMod pays no tokens. No catalog/patch context is sent — the models already know Faust; they just need the connector conventions.
External chat (whole patches): rather than pay per-token for a built-in LLM (which would need
the whole ~650-block catalog in context), File → Export Catalog for AI… downloads a
brief (the .faustmod/block file formats + the DSP-block catalog + the control/instrument
widget nodes) as a Markdown file. Give it to an external AI (e.g. as Project knowledge), ask
for a patch or a DSP block, and paste the result back via Open (patch) or
Block → Import DSP Block….
Patches that use custom blocks are self-contained, so the AI can write those without any
catalog at all.
src/
audio/ FaustService, AudioEngine, AudioGraph, units, monitors (widgets),
push-measuring units (plot, loudness, spectrogram, record, health),
devices, types
components/ library, widgets, LibraryService, customBlocks (registry)
editor/ rete editor setup + DspNode + theme/ + widgets/ (React bodies)
patch/ format (.faustmod), PatchManager (file I/O), TabsManager, aiBrief
ui/ App, MenuBar, TabBar, LibraryPanel, modals, styles
Instrument nodes (scope, meters, LEDs, sequencer…) are kind: "widget" components
(src/components/widgets.ts). Each realizes into a custom audio unit — in
src/audio/monitors.ts (AnalyserNode taps, the sequencer worklet),
src/audio/tableUnits.ts (drawable editors, multi-output CV, stereo analysis),
src/audio/seqUnits.ts (the clocked pattern worklet) or src/audio/fileUnits.ts
(convolver, looper) — that registers in a Monitors map keyed by node id. The matching
React body in src/editor/widgets/ reads that map each frame to animate. Resizable
widgets persist their size, and any widget state (drawn points, grid cells, notes) in
the patch node's state.
Measure in the audio thread, push to the UI. Anything that must not miss data —
the CV Plotter (plotUnit.ts), Loudness (loudnessUnit.ts), Spectrogram
(spectrogramUnit.ts), Record (recordUnit.ts) and the Audio Health probe
(healthUnit.ts) — measures inside the worklet on the audio clock and postMessages
the results, rather than having the UI poll an AnalyserNode each frame. A polling
widget silently loses whatever happened while a background tab or a janky frame stalled
the main thread; a pushed queue is delivered intact when the tab wakes. Audio Health
exists to tell the two apart: it counts missed deadlines from inside the audio thread,
where main-thread throttling can't reach — and allows for Chrome rendering quanta in
bursts, so a burst boundary isn't reported as a glitch.
Drawable widgets share src/editor/widgets/DrawCanvas.tsx: a DPR-correct canvas,
pointer drags reported in normalized 0..1 coordinates (so editor zoom cancels out),
usePersistedState for patch-backed state, and helpers that push the drawn shape to the
running unit. Momentary widgets must push values synchronously when they fire — a
browser that throttles timers will otherwise swallow a short pulse.
No dead space. A widget with one port per element (multislider, drum grid, pads,
morph pad) is as tall as its port stack, so its body must fill that height rather than
float in the middle of it: useFilledSize measures the body with a ResizeObserver and
the canvas is positioned absolutely inside it, so it can't inflate the box it measures.
The same applies to text bodies — the Value Monitor spreads its rows so each lines up
with the input it reads.
Hearing what breaks is genuinely useful.
- Something's broken?
Open an issue. The bug
template asks for your browser (audio behaviour varies a lot between them), the
console output, and the
.faustmodpatch if a specific one triggers it. For dropouts or crackle, drop an Audio Health node into the patch and include what it reports — it measures the audio thread itself, so it separates a real glitch from a busy UI. - Everything else — questions, blocks or widgets you'd like to see, patches you want to show off — goes to Discussions.
Pull requests aren't being accepted at the moment; see CONTRIBUTING.md.
FaustMod is free software licensed under the GNU General Public License v3.0 or later. Copyright © 2026 Andre Paquette.
It was MIT-licensed until July 2026. The change was necessary because the block catalog
compiles functions from the Faust standard libraries into the shipped app, and some of
those functions — Dario Sanfilippo's compressors and limiters, the Vital and Keith Barr
reverbs — are declared GPLv3 by their authors. FaustMod also bundles @grame/faustwasm
(libfaust) under LGPL-3.0, used unmodified. See
THIRD_PARTY_NOTICES.md for the details and for which blocks
carry which license.
Patches you build and DSP you write in FaustMod are yours: code generated by the Faust compiler is not covered by the compiler's licence, and the GPL applies to FaustMod itself, not to the audio or patch files you make with it.
