Skip to content

Repository files navigation

FaustMod

FaustMod modular synthesis workspace

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.

Stack

  • 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

Features

  • ~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 .lib sources 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 in localStorage. 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 .faustmod patch 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.

Getting started

npm install
npm run dev        # http://localhost:5173
npm run build      # type-check + production build

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

Building the block catalog

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 devpredev runs npm run catalog
  • npm run buildprebuild runs npm 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.

Deploying (Fly.io)

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 flyctl

on Linux (or WSL):

curl -L https://fly.io/install.sh | sh

on Windows (PowerShell): iwr https://fly.io/install.ps1 -useb | iex. Then authenticate:

fly auth login

Then, from the repo root:

fly deploy

That'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. .dockerignore excludes dist/ and node_modules/, so the image is always built from source inside the container; a stale local dist/ can't leak into a deploy.
  • The catalog is built in the image. public/factories/ and src/generated/catalog.json are gitignored, and prebuild regenerates them during npm run build — so the deployed catalog always matches scripts/blocks.mjs at 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 = 1 in fly.toml to keep one warm (at the cost of running it 24/7).
  • Claiming the app name. A fresh fork needs its own app: change app in fly.toml, then fly launch --no-deploy (or fly apps create <name>) before the first fly deploy.
fly logs        # nginx access/error output
fly status      # machine state, region, last release
fly open        # open the deployed app in a browser

Architecture

The 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).

Block catalog & precompiled factories (scaling)

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.mjs declares candidate blocks as families of Faust functions.
  • npm run catalog (run automatically by prebuild) compiles each one once in Node with libfaust, emits a tiny public/factories/<id>.wasm (~2 KB) + <id>.json per block, and writes src/generated/catalog.json with 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.mjs reads the standard-library documentation straight out of the .lib sources packed inside libfaust-wasm.data and 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.

The node model: control inputs, not knobs

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 internal ConstantSourceNode(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.

Adding a component

DSP blocks are declared in scripts/blocks.mjs and compiled by npm run catalogLIBRARY 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.

Theme

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.

User Defined DSP

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 / DoneSave 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 via hslider, since the editor only has the code to work from. New to Faust? See the Faust manual.

Custom DSP blocks

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 & file format

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.

Using AI

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.

Project layout

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

Widget nodes

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.

Bugs & discussion

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 .faustmod patch 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.

License

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.

About

Browser-based modular audio synthesis — patch Faust DSP blocks on a node canvas. Built with rete.js + WebAssembly AudioWorklets.

Resources

Contributing

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages