# FaustWave β€” complete documentation > FaustWave is a code-first sound design IDE built on Faust, the functional DSP language from GRAME. Instruments are node graphs (the Builder) or hand-written Faust (.dsp) that compile to WebAssembly and run live; around them sit a multi-track sequencer, mixer with master FX, patchbay routing, a recorder, a community Hub, and an AI assistant with full read/write access to the IDE. Every UI gesture is an action in one registry, exposed over MCP (Model Context Protocol), so anything a user can do an agent can do. Runs as a desktop app (Windows, macOS, Linux) and in the browser at https://faustwave.io/ide/. Text: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/), Mani Weber / FaustWave. Source of each chapter is noted above it. --- # What is FaustWave? FaustWave is a **code-first sound design IDE** built on top of [Faust](https://faust.grame.fr/), the high-level DSP language from GRAME. It's a desktop app you run locally β€” you build instruments by wiring node graphs, you arrange them with a sequencer, you mix and record, and you can drive any of it programmatically through an AI assistant or a published MCP API. That sentence is dense. Let's unpack it. ## Code-first means generative, not pre-rolled A traditional DAW gives you a fixed set of plugins. You arrange them, you tweak knobs, you record audio. The instruments and effects are black boxes that someone else wrote. FaustWave gives you **node graphs that compile to running DSP code**. When you drop an oscillator into a Builder canvas, you're not loading a pre-built osc plugin β€” you're picking a Faust primitive (`os.osc`), and FaustWave generates the Faust source for the whole graph, compiles it to WebAssembly on the fly, and runs it. The same DSP you build in five minutes ships as code: portable, reproducible, inspectable. You can read the generated Faust source at any time. > 🟦 **NEW TO FAUST?** Faust is a language designed exclusively for audio DSP. It's not C, not Python β€” it's a small functional language where every line is a signal flow. The Faust compiler turns Faust source into WebAssembly that runs inside the Electron AudioWorklet at audio-thread priority. You don't have to write Faust in FaustWave (the Builder generates it for you), but you can β€” and many people do, since the [Faust standard library](https://faustlibraries.grame.fr/) is huge. ## Built around the modular DAW shape, with extras The familiar shape is there: a master Transport Widget at the top centre, a left rail with Documents / Audio Inputs / MIDI / Patchbay / Knowledge / etc., a right rail with the Recorder / Audio Outputs / Activity, a right-pinned mixer + master FX, status info at the bottom. You have a multi-track sequencer with a piano-roll grid that knows about scales and chord progressions, a virtual keyboard, MIDI in/out, a recorder that taps the master output to WAV. What's different: - **Instruments are first-class documents.** Each Builder, each `.dsp` file, each `.lib` library is its own tab; you open and close them like files in an IDE. Multi-DSP projects are a thing. - **The Knowledge Base is queryable.** FaustWave ships with a local KB (Faust libs, theory snippets, node-kind reference) that the AI assistant uses for grounded answers, and that you can search by content. Semantic search runs locally on a small embedding model β€” no network round trip per query. - **Everything you can do, the AI can do.** Every UI gesture routes through a shared action registry β€” palette, keyboard shortcut, fader move, edge connect β€” and the same registry is what the AI assistant + the MCP server expose. The AI doesn't see a "limited subset"; it sees what you see. - **Hub for sharing.** A community Hub lets you publish instruments, sample packs, knowledge packs, full projects. Other users install them with one click. ## Where the AI fits FaustWave has an AI assistant pane (built on Anthropic's Claude or any OpenAI-compatible model β€” you bring your own key) with full read + write access to the IDE. You can ask it to build a DSP from a description, tweak params on the running engine, fill in a sequencer pattern, walk you through a Knowledge Base topic β€” and it acts in the IDE, not in a chat sandbox. The action registry is the choke point that keeps every AI action mirrored in the **Activity** log (right rail) so you can see what it did and audit. You can also drive FaustWave from outside via the MCP (Model Context Protocol) server β€” connect Claude Desktop to it and use FaustWave as a tool. Same surface, different transport. ## Who should use it - **Sound designers** who want to build their own instruments + effects without dropping to C++, and who want to script and share what they build. - **DSP students** learning Faust β€” the Builder turns abstract DSP graphs into something you can hear in seconds. - **Music coders** who like the idea of "DSP and play, then commit the source to git." - **Anyone curious about AI-driven music tools** β€” FaustWave is one of the few IDEs where the AI has parity with the human user, not a limited script API. ## Who shouldn't (yet) - If you want a polished beat-making sketchpad with pre-rolled sounds, use a tracker or Ableton. FaustWave assumes you want to *build* sounds, not pick them. - If you need a hundred plugins from Splice / Plugin Alliance, FaustWave doesn't host VST/AU. The bundled master-FX slots compile your own Faust sources; you can paste in any Faust DSP from the wild, but you can't drop in a VST. ## Now you're ready Continue to Β§ *Meet the workspace* for a two-minute click-tour of every panel β€” the buttons there open each surface live, so you learn the layout by watching it appear. Then Β§ *Your first DSP* starts the real 30 minutes: building a DSP, playing notes, and recording the result. ## The rest of the manual The FaustWave manual is split across 11 specialised books. All of them ship pre-installed β€” the **Knowledge** rail panel lists them under *Installed Packs*, ordered shallow-to-deep β€” and each also lives on the Hub as a book: - **FaustWave β€” Getting Started** (this book) β€” 30-minute path from app boot to recorded loop. - **FaustWave β€” Builder** β€” the visual graph editor in depth. - **FaustWave β€” Keyboard** β€” the virtual MIDI input. - **FaustWave β€” Sequencer** β€” the multi-track step grid, scenes, scripting. - **FaustWave β€” Mixer & Master** β€” tracks, FX rack, master volume. - **FaustWave β€” Patchbay & Routing** β€” the routing model + matrix. - **FaustWave β€” Recorder** β€” capturing master to WAV. - **FaustWave β€” Hub** β€” publishing + installing. - **FaustWave β€” AI Assistant** β€” the in-app AI + MCP + Command Palette. - **FaustWave β€” Reference** β€” keyboard shortcuts, the MCP catalog, file formats, themes. - **FaustWave β€” Node-Pack Authoring** β€” writing + publishing your own node kinds. Follow Getting Started linearly; jump into the others by topic when you need depth. --- # Meet the workspace (2 minutes) Every button in this chapter **opens the surface it describes, live** β€” click it, look at what appeared, read the two sentences, move on. Nothing here changes your project or makes sound; it's a walk through the rooms before you start working in them. (Each button fires a registered action β€” the same one the AI or an MCP client would call. You'll see every click land in the Activity log later.) ## Views β€” six postures The window is arranged by **views**, named after what you are doing, switched on the topbar's rail. Each holds its own panes, columns and open documents; switching never opens or closes anything, a running instrument keeps running. | View | Posture | On screen | |---|---|---| | **Start** | you do not need to know anything yet | Books Β· a book's text Β· Knowledge Β· Assistant | | **Build** | make a sound | Documents Β· the file Β· Inspector Β· **Analysis** Β· Keyboard | | **Compose** | write the music | Patterns Β· piano roll Β· Mixer Β· Keyboard | | **Perform** | play the set | Patterns (scene launcher) Β· Mixer Β· Modulators Β· Pads Β· Controller | | **Hub** | get and share | the Hub, edge to edge | | **Signal** | something is wrong | Devices Β· Activity Β· MIDI Monitor Β· Routing Β· **Analysis** | Anything else is one click away in each column's picker, and **Arrange** mode (the topbar toggle) lets you add, pin, rename or save views of your own. ```yaml actions: - action_id: view.open input: { view: build } label: "β–Ά Switch to Build" - action_id: view.list input: {} label: "β–Ά Every view with its panes and documents" ``` ## The topbar β€” always visible Across the top: the **Transport Widget** in the centre (Play, BPM, the Bar.Beat.Sixteenth counter, loop, and the red **Rec** dot) and a cluster of icon buttons on the right (Master Control, search, AI Assistant). The transport is the global heartbeat β€” every sequencer and every transport-following DSP wakes up when you press Play. No button for this one; it's already on screen. ## Documents β€” your files ```yaml actions: - action_id: surface.open input: { surface: library } label: "β–Ά Open the Documents panel" ``` The left rail's file home: **OPEN** (tabs open right now), **PROJECT** (every file saved in this project), **NEW** (spawn a fresh Builder, Faust DSP, or authoring file). Everything you build lives here as a plain file β€” instruments are documents, not opaque session state. ## Patchbay β€” who talks to whom ```yaml actions: - action_id: patchbay.toggle input: { open: true } label: "β–Ά Open the Patchbay" ``` The routing matrix: Sources as rows, Sinks as columns, one click per cable. FaustWave never routes anything implicitly β€” if the Keyboard plays your synth, it's because a cell in this matrix is filled. When something is silent, this is the first place to look. ## Sequencer β€” the step grid ```yaml actions: - action_id: surface.open input: { surface: sequencer-default#roll } label: "β–Ά Open the Sequencer" ``` A multi-track piano-roll grid driven by a shared Faust master clock (sample-accurate, no JS-timer jitter). Scale-aware highlighting and a diatonic chord palette live here too β€” Β§ *Your first pattern* uses both. ## Keyboard β€” play without hardware ```yaml actions: - action_id: surface.open input: { surface: keyboard-default } label: "β–Ά Open the Keyboard" ``` An on-screen MIDI keyboard. It's a real MIDI Source in the Patchbay β€” route it to any DSP and click keys. No hardware needed to test a synth. ## Master Control β€” the mixer ```yaml actions: - action_id: surface.open input: { surface: faustwave-bundled/mixer } label: "β–Ά Show Master Control" ``` The right-pinned strip: one MixerTrack per attached source (gain, mute, solo, sends), the **Master FX rack**, and the master volume + meter at the top. The **"+ ADD AUDIO"** button here is how a saved DSP gets a path to your speakers. ## Recorder β€” capture the master ```yaml actions: - action_id: surface.open input: { surface: recorder-default } label: "β–Ά Open the Recorder panel" ``` Your recordings library β€” every WAV you capture with the topbar Rec dot, newest first, with in-app preview. Lossless 32-bit float, exactly what the speakers heard. ## Knowledge β€” this book lives here ```yaml actions: - action_id: surface.open input: { surface: knowledge } label: "β–Ά Open the Knowledge Base" ``` The local knowledge base: search across Faust libraries, node kinds, music theory, and the manual you're reading right now β€” every shipped book is listed under *Installed Packs*. Semantic search runs on a local embedding model; nothing leaves your machine. ## Command Palette β€” everything by name ```yaml actions: - action_id: palette.overlay.toggle input: { open: true } label: "β–Ά Open the Command Palette" ``` `Ctrl+K` (or `⌘K`). Every action in FaustWave is registered under a searchable id β€” the palette is the fastest route to anything you can't remember the location of. Press `Escape` to close it and come back. ## AI Assistant β€” the second operator ```yaml actions: - action_id: chat.panel.toggle input: { open: true } label: "β–Ά Open the Assistant panel" - action_id: settings.open input: { section: ai-provider } label: "β–Ά Set up an AI provider (optional)" ``` The in-app AI (`Ctrl+L`) sees the same action registry you do β€” it can build instruments, route cables, fill patterns, and every move it makes lands in the Activity log for you to audit. It needs a provider (an API key, or a local endpoint like Ollama) β€” the second button jumps straight to that Settings section. Entirely optional: everything in this manual works without it. ## Hub β€” the community ```yaml actions: - action_id: hub.open input: {} label: "β–Ά Open the Hub" ``` Publish and install instruments, sample packs, node packs, knowledge books, whole projects. Requires a (free) account for publishing; browsing installed content works offline. ## That's the map Also on the rails, when you need them: **Audio Inputs / Audio Outputs** (device pick), **MIDI** (hardware devices), **Logs**, and the **Faust Compiler** history. You now know where everything lives β€” continue to Β§ *Your first DSP* and start using it. --- # Your first instrument (5 minutes) ```yaml actions: - action_id: documents.add input: type: builder title: "My first instrument" save_as: b label: "β–Ά 1. Spawn a Builder" - action_id: builder.graph.node.add input: module_id: "{{b.module_id}}" kind: osc x: 80 y: 120 save_as: osc label: "β–Ά 2. Add an oscillator" - action_id: builder.graph.node.add input: module_id: "{{b.module_id}}" kind: output x: 320 y: 120 save_as: out label: "β–Ά 3. Add an output node" - action_id: builder.graph.connect input: module_id: "{{b.module_id}}" source_node_id: "{{osc}}" target_node_id: "{{out}}" target_port: in_l label: "β–Ά 4. Wire osc β†’ output" - action_id: documents.save input: module_id: "{{b.module_id}}" label: "β–Ά 5. Save it into the project" ``` By the end of this chapter you'll hear a sine wave you wrote by wiring two nodes. That's intentionally trivial β€” the same workflow scales to a full polyphonic synth or a guitar amp simulator. We start small so you learn the gestures. > 🟦 **HANDS-FREE PATH**: the Action buttons above build + save the Builder β€” Spawn β†’ Add osc β†’ Add output β†’ Wire β†’ Save. The last two steps that make it audible β€” **"+ ADD AUDIO"** (route it onto a MixerTrack) and **Play** β€” are walked in the prose below; "+ ADD AUDIO" is a UI picker over your project files (its MCP mirror is `mixer.tracks.input.set`, see **FaustWave β€” Reference**), and Play is `transport.play {}`. Every step below also lists the MCP action behind it (`action_id { input }`). You can ignore those on a first read β€” they're there so a future reader running this as a playable Book tutorial sees the same actions a click would fire, and so anyone driving FaustWave from Claude Desktop knows what to call. ## Open FaustWave When the app boots you land on the workspace. The **Documents** rail icon (top of the left rail β€” it looks like a stack of papers) has three sections: **OPEN** (the document tabs open right now), **PROJECT** (every file saved in your project β€” click to open, trash to delete), and **NEW** (spawn a fresh module). If you've never opened FaustWave before, you have an empty project (FaustWave generates a project name automatically β€” typically something like `A-AAA-1`). That's fine β€” we'll create our DSP directly here. > 🟦 **WHY A PROJECT?** A FaustWave project is a folder on disk that holds your instruments, your samples, your sequencer state, and the routing between them. Everything auto-saves to the active project, so there's no Cmd+S anxiety. You can have many projects; switch between them from the project pill in the topbar. ## Step 1 β€” Start a Builder Click the **Documents** rail icon. Under the **NEW** section, click **Builder**. A new Builder tab opens with an empty canvas. > πŸ”˜ **MCP**: `documents.add { type: "builder" }` The Builder is FaustWave's visual graph editor. You drop nodes, you wire them, and the Builder generates Faust source from your graph in real time. You can see the generated source any time via the canvas right-click menu β†’ *Show Faust source*. ## Step 2 β€” Add an oscillator Right-click anywhere on the canvas. A node picker opens at your cursor. Type `osc` in the search box and hit Enter β€” or scroll down to the **Source** category and pick **Osc**. You should see a single oscillator node on the canvas with one knob (`freq`, default 440 Hz) and one output port (`out`). > πŸ”˜ **MCP**: `builder.graph.node.add { module_id: , kind: "osc" }` Click the `freq` knob and drag up or down. The value changes in real time but you don't hear anything yet β€” the oscillator is unconnected. Sound flows from your nodes through an **Output** node, which is what writes to the master mixer. > 🟦 **NEW TO DSP?** A node in the Builder represents one Faust expression. The Osc node is essentially `os.osc(freq)` β€” Faust's sine oscillator with a frequency input. The `freq` knob you drag is a Faust `hslider` that gets wired into the compiled source. There's no plugin host, no MIDI mapper, nothing between you and the math. ## Step 3 β€” Add an output Right-click the area to the right of your osc, search `output`, pick **Output**. > πŸ”˜ **MCP**: `builder.graph.node.add { module_id, kind: "output" }` The Output node has paired stereo inputs (`in_l` + `in_r`; a mono signal into `in_l` fans out to both channels) and represents the DSP's audio out β€” it makes the Builder a routable audio **Source**. It does NOT reach the speakers on its own yet: you add the DSP to the mixer in Step 6 (the Builder routes into a MixerTrack, which routes to master). See **FaustWave β€” Mixer & Master** for the tracks + master FX + master meter that sit downstream. ## Step 4 β€” Connect them Click the `out` port on the right side of the Osc node, drag your mouse to the `in_l` port on the left side of the Output node, release. A cyan line (a "cable") appears between them. (Mono into `in_l` reaches both speakers β€” `in_r` is for true-stereo instruments.) > πŸ”˜ **MCP**: `builder.graph.connect { module_id, source_node_id: , target_node_id: , target_port: "in_l" }` β€” ports default to `out` β†’ `in`, but the Output node's inputs are `in_l`/`in_r`, so name the target port. When unsure about a kind's port ids, `builder.kind.describe` lists them. > 🟦 **CABLE COLOURS**: cyan = audio, purple = control / parameter, orange = MIDI trigger. You'll learn to read these at a glance β€” see **FaustWave β€” Builder** Β§ 1 *Cables* for the full palette. ## Step 5 β€” Save it Before it can reach the speakers it has to be a **project file** β€” the mixer's "+ ADD AUDIO" picker lists project files. From the Builder's footer, click the **Save** icon (disk). The DSP saves to your active project's `assets/` folder as `untitled-XXXX.builder` (FaustWave generates a unique suffix). It now appears under **PROJECT** in the Documents panel and auto-saves from here on. > πŸ”˜ **MCP**: `documents.save { module_id }` ## Step 6 β€” Add it to the mixer A Builder's Output node makes it a routable Source, but nothing reaches the master until you put that source on a **MixerTrack**. Open the right-side **Master Control** panel (Sliders icon in the topbar, or via the Outputs rail) and click **"+ ADD AUDIO"**. Pick your just-saved DSP. FaustWave attaches its output to a MixerTrack β€” auto-creating one named after the DSP β€” so its audio finally has a path to the master bus and your speakers. > 🟦 **WHY THE EXTRA STEP?** FaustWave doesn't auto-route every Builder to the master β€” you decide what's in the mix, like adding a track in a DAW. Open a DSP to work on it; **"+ ADD AUDIO"** it when you want to hear it. Once attached, the route is remembered β€” re-opening the DSP restores it. (MCP mirror: `mixer.tracks.input.set`.) ## Step 7 β€” Play it Two ways. Either: **A)** Press **Play** in the master Transport Widget at the top centre β€” the big play arrow next to the BPM display. This starts the global transport, which all transport-following modules (your Builder among them) wake up to. You should hear a steady sine wave at 440 Hz. > πŸ”˜ **MCP**: `transport.play {}` **B)** Press the local **RUN** button in the Builder's footer (bottom right of the Builder panel). This starts only your Builder; the global transport stays stopped. Useful for soloing a single DSP while everything else stays quiet. > πŸ”˜ **MCP**: `dsp.run { module_id }` Stop the same way (Play β†’ Stop, RUN β†’ STOP). Drag the `freq` knob while the DSP is running β€” the pitch follows your finger. **You just wrote, compiled, routed, and played your own DSP code.** ## What you actually did When you clicked Play, FaustWave: 1. **Walked your graph** (`osc β†’ output`) and generated Faust source. Roughly: ```faust process = os.osc(hslider("freq", 440, 20, 20000, 0.01)) <: _, _; ``` A stereo sine. 2. **Sent that source to the Faust compiler** (running in a Web Worker, libfaust under the hood), which produced WebAssembly bytecode. 3. **Loaded the bytecode into an AudioWorklet** running on the audio thread, wired the worklet's output through the MixerTrack you added in Step 6 β†’ the master bus β†’ your output device. You can inspect each step: | What | How | |---|---| | The graph | Visible on the canvas. Click the `` button in the Builder toolbar to copy the live Faust source to your clipboard β€” paste anywhere. | | The compiler | Open the **Faust Compiler** rail panel β€” compile history + time per compile | | The routing | Open the **Patchbay** rail panel β€” see your Builder's output β†’ its MixerTrack β†’ master β†’ speakers in the live audio routing table | ## Troubleshooting **I clicked Play but I hear nothing.** - **Did you "+ ADD AUDIO" the DSP?** This is the #1 cause. A Builder that's open + running is NOT on the mixer until you add it (Step 6). No track = no path to master = silence. Open Master Control and check there's a track for your DSP. - Check the Master Volume on the right-side **Master Control** panel β€” if it's at 0, raise it. - Check your DSP's MixerTrack β€” make sure it's not muted, and that another track isn't soloed (solo wins over mute). - Check your OS audio device. The **Audio Inputs** rail panel (left) and **Audio Outputs** rail panel (right) show the devices FaustWave is using; if you plugged in headphones after launch, re-select the output device in **Audio Outputs**. - Check the master meter at the top of the Master Control panel β€” if it's lit up but you hear nothing, the audio path is fine and the problem is OS-side. **The Run button shows "Run failed".** - Open the **Activity** rail panel (right rail) and look for the failed `dsp.compile` / `dsp.run` entry. The actual compile error or runtime error is in the entry's result. - Most common: a graph with no Output node (nothing to route to), or a poly-mode DSP with multiple `midi_note` nodes (only one allowed). **The `freq` knob doesn't move.** - Audio engine might not be running. Click Play first, then drag. - The knob is bound via `setParamValue` β€” if the worklet isn't compiled yet, changes are staged but not audible until the next run. ## Where to go from here - Add a `gain` node between osc and output and an `adsr` envelope β€” start sculpting the sound. See **FaustWave β€” Builder**. - Add a `midi_note` node + connect a MIDI keyboard or the on-screen Keyboard β†’ your osc plays per note. Continue to Β§ *Your first pattern* in this book. - Tap the DSP into the sequencer to drive it from a step grid. See **FaustWave β€” Sequencer**. --- # Your first pattern (10 minutes) You have a sine wave DSP from Β§ *Your first DSP*. It plays a steady 440 Hz tone. In this chapter we'll make it MIDI-responsive, then drive it from a sequencer pattern so it plays a melodic 4-bar loop instead. ## Step 1 β€” Make the DSP MIDI-responsive Open your Builder from the previous chapter (Documents rail β†’ click your DSP). Right-click the canvas, search `midi_note`, and add it. The `midi_note` node has three outputs: - `freq` β€” Hz, computed from the current note. - `gate` β€” 1 when a note is on, 0 when off. - `vel` β€” note velocity, 0..1. Disconnect the cable between your osc and the Output node (right-click the cable β†’ *Delete connection*). Now wire: - `midi_note.freq` β†’ `osc.freq` (purple cable β€” this drives the oscillator's frequency input). - `osc.out` β†’ a `gain` node β†’ multiply by `midi_note.gate`. - The `gain` output β†’ `Output.in_l`. This is the minimum MIDI-responsive shape: the gate gives a hard on/off, the freq picks the pitch, the velocity is available for later wiring. We're skipping an envelope for now to keep the graph small β€” see **FaustWave β€” Builder** for the full poly-synth recipe. > 🟦 **NEW TO MIDI?** A MIDI note has three properties: a pitch (translated to frequency in Hz), a velocity (how hard the key was struck), and a gate state (key down / key up). The `midi_note` node exposes all three as Faust signals so your DSP can react. You don't need a physical MIDI keyboard β€” FaustWave's on-screen Keyboard counts as a MIDI source. ## Step 2 β€” Test it with the on-screen Keyboard ```yaml actions: - action_id: surface.open input: { surface: keyboard-default } label: "β–Ά Open the Keyboard panel" - action_id: patchbay.toggle input: { open: true } label: "β–Ά Open the Patchbay" ``` Open the **Keyboard** rail panel (left rail, piano-keys icon under the MIDI sources area β€” or the first button above). You see a virtual keyboard. Before clicking keys, route the keyboard to your Builder so it actually receives the notes: 1. Open the **Patchbay** rail panel (cable icon, left rail β€” or the second button above). 2. The Patchbay matrix shows Sources as rows and Sinks as columns, split into Audio + MIDI blocks. 3. Find your Builder's MIDI sink in the MIDI block β€” it's named after the Builder's `module_id`. (Don't see it? MIDI sinks register **while the DSP runs** β€” press Play or the Builder's RUN button once and the row appears.) Find the Keyboard source row β€” it's `keyboard-default`. 4. Click the matrix cell where they intersect. The cell fills purple β€” the connection is live. Now click keys in the Keyboard panel. Each note plays your sine at that pitch as long as the key is held. If you don't hear anything: make sure the master transport is playing (the Play button in the topbar Transport Widget) β€” your Builder is a transport-follower by default and only runs when the master transport runs. > 🟦 **WHY ROUTING IS EXPLICIT**: FaustWave's routing table is the single source of truth for who talks to whom. There's no implicit "all MIDI goes everywhere" β€” each connection is a deliberate cable in the Patchbay. This is what lets you have ten Builders open, each listening to a different sequencer track, without crosstalk. There's no bundled instrument and nothing is pre-routed for you β€” a fresh project starts with no MIDI sinks, so you always draw the line from a source (Keyboard / Sequencer) to your own Builder yourself. See **FaustWave β€” Patchbay** for the model. ## Step 3 β€” Open the Sequencer ```yaml actions: - action_id: surface.open input: { surface: sequencer-default#roll } label: "β–Ά Open the Sequencer panel" ``` Open the **Sequencer** rail panel (grid-with-dot icon on the left rail under the MIDI sources section β€” or the button above). You land on a multi-track piano-roll grid. The top row is a **chip row** β€” one chip per track with name, MIDI channel, and pattern-slot launcher (A / B / C / D). Below is the **grid** itself, with pitch rows on the left (gutter) and step columns running right. A fresh sequencer starts with one track at MIDI channel 1, in C major. Pattern length is 16 steps (1 bar at 4/4). ## Step 4 β€” Pick a key and scale Below the chip row, find the **Key + Scale** picker. Click to open it: - **Key** β€” pick a tonic. Try `A` for a minor-key feel, or stick with `C`. - **Scale** β€” pick a scale. `Aeolian` (= natural minor) is friendly for first-pattern experiments; `Pentatonic Minor` is even friendlier (no clashing intervals). The grid now highlights in-scale rows in cyan. Out-of-scale rows are dimmer. You can still click them, but the visual hint is a real working aid: every note you draw on a highlighted row is "in key" by construction. > 🟦 **NEW TO MUSIC THEORY?** A scale is a set of allowed pitches relative to a tonic β€” for example, A minor uses A, B, C, D, E, F, G (no sharps or flats). If you draw notes only on the highlighted rows, every note you play will sit in that scale. FaustWave's KB has a `theory` corpus with scale + chord references the AI can search; see **FaustWave β€” AI Assistant** for asking it *"what's a good progression in A minor"*. ## Step 5 β€” Draw your first 4 notes The default mode is the **Paint tool** (pencil). Click on a step cell to place a note. Click an existing note to remove it. Drag vertically while painting to set velocity. Place four notes: - Step 1, row A (the tonic). - Step 5, row C (the minor third). - Step 9, row E (the fifth). - Step 13, row A (an octave up if you like, or back to the same A). You've written an arpeggio outlining the A minor chord across 16 steps. > 🟦 **CABLE COLOURS RECAP**: in the sequencer grid the cells you paint aren't cables β€” they're step events. The cables (cyan, purple, orange) are the ones inside the Builder canvas you saw in the previous chapter. ## Step 6 β€” Route the sequencer to your Builder Back to the Patchbay panel. Find `sequencer-default` on the MIDI Source rows (the sequencer's MIDI output) and your Builder's MIDI sink on the MIDI Sink columns. Click the cell where they meet. Now the sequencer's MIDI events flow to your Builder. The Keyboard cable from earlier can stay; the Builder happily takes notes from either source. ## Step 7 β€” Play it Press **Play** in the master Transport Widget (top centre). Three things happen at once: 1. The master transport flips to playing. 2. The shared Faust master clock starts ticking at the current BPM (default 120). You'll see the **Bar.Beat.Sixteenth** display in the widget count 1.1.1 β†’ 1.1.2 β†’ 1.1.3 β†’ 1.1.4 β†’ 1.2.1 β†’ … 3. The sequencer's cursor walks across the steps; every time it hits a note you painted, it dispatches a MIDI event to your Builder, which plays the corresponding pitch. You should hear your arpeggio looping. Drag the BPM display in the widget (click the number β†’ type a new value β†’ Enter) to change the tempo on the fly; the loop follows. > 🟦 **THE FAUST MASTER CLOCK**: every sequencer (and the topbar's Bar.Beat readout) reads from one shared Faust DSP clock running at audio-thread priority. This is why there's no drift between multiple sequencers, no JS-timer jitter, and why the Bar.Beat counter is sample-accurate. See **FaustWave β€” Sequencer** Β§ 1 for the full clock story. ## Step 8 β€” Try the chord palette Stop playback (the same Play button toggles to Stop while playing). To the right of the key/scale picker is the **Chord Palette** β€” a row of diatonic chord buttons. In A minor it shows: `i (Am)`, `iiΒ° (Bdim)`, `III (C)`, `iv (Dm)`, `v (Em)`, `VI (F)`, `VII (G)`. Click any chord; the next click on the grid drops it as a stack of three steps (root + 3rd + 5th) all on the same step column. Place a few chord stabs across the bar and play again. You've got a polychord progression in 30 seconds. ## Step 9 β€” Save the pattern The sequencer auto-saves to your project's `project.json` on every change. There's no Save button β€” close the rail panel, close the app, reopen, and your pattern is exactly where you left it. If you want the pattern as a portable file (to send to a friend or import into another project), use the AI Assistant or the MCP `sequencer.state` action to dump the pattern as JSON β€” community-shareable, also useful for git history. ## What you've built - A Builder DSP that plays MIDI-driven sine tones. - A routed signal flow: sequencer β†’ Builder β†’ master β†’ speakers. - A scale-aware 4-step arpeggio + a few chord stabs in A minor. - A grasp of the global transport, the shared Faust clock, and routing as deliberate cables. ## Where to go from here - Add a second track at a different MIDI channel for a bass line; learn solo/mute. See **FaustWave β€” Sequencer** Β§ 2. - Build a proper poly-synth (envelope, filter, multi-voice) so the notes don't sound like a square wave with no attack. See **FaustWave β€” Builder**. - Capture the loop to a WAV file. Continue to Β§ *Your first recording* in this book. - Ask the AI to suggest a progression in your key, or to mutate the pattern. See **FaustWave β€” AI Assistant**. --- # Your first recording (5 minutes) You have a sequencer pattern driving your Builder (Β§ *Your first pattern*). Now capture it to a WAV file you can share, archive, or re-import as a sample. ## Step 1 β€” Find the Rec button In the master Transport Widget at the top centre, the **Rec** button is the small red dot β€” second from the right, just left of the Panic button (the one that cuts every note; don't reach for that one mid-take). Hover it; the tooltip confirms *"Start recording the master output to a WAV file"*. ## Step 2 β€” Start the recording You can record while the transport is stopped or while it's playing β€” the Rec button toggles independently. The classic workflow: 1. Stop the transport (if it's playing). 2. Click **Rec**. The button pulses red β€” recording is armed. 3. Click **Play** (the leftmost transport button). Playback starts; the recording captures from this point. 4. Let it run for as many bars as you want. 5. Click **Stop**. Playback halts. 6. Click **Rec** again. Recording ends; the WAV file is written to disk. > 🟦 **WHAT GETS RECORDED**: the Recorder taps the master bus output β€” exactly what your speakers hear. That includes every MixerTrack, every send to a master FX slot, after the master volume + softclip stage. It does NOT include muted tracks. Want to record only one source? Solo its mixer track before pressing Rec. ## Step 3 β€” Find your recording ```yaml actions: - action_id: surface.open input: { surface: recorder-default } label: "β–Ά Open the Recorder panel" ``` Open the **Recorder** rail panel (right rail; the recording-dot icon β€” or the button above). The Recorder panel opens, showing every file in your recordings library β€” newest first. Your fresh take is at the top. Each row shows: name (timestamped), duration in seconds, file size, and three actions: **Play** (preview in-app), **Reveal** (open the folder in your OS file manager), **Delete** (hard-delete, no trash). > 🟦 **WHERE THE FILES LIVE**: the recordings folder is OS-dependent. > > - **Windows**: `%APPDATA%\FaustWave IDE\recordings\` > - **macOS**: `~/Library/Application Support/FaustWave IDE/recordings/` > - **Linux**: `~/.config/FaustWave IDE/recordings/` > > Files are named by timestamp + a random suffix, format `_.wav`. Format is lossless 32-bit float WAV at your project's sample rate. Click **Play** on your row. The file plays back in-app via the audio system. (The transport doesn't restart; the Recorder uses its own playback path.) ## Step 4 β€” Re-import as a sample The round-trip β€” record β†’ re-import β€” is one of FaustWave's bread-and-butter moves. The just-recorded WAV is sitting on disk. Bring it into the sample store: 1. Open your Builder canvas. Right-click the pane, search `soundfile`. Add a Soundfile node. 2. Either drag the WAV from your file manager onto the Soundfile node, OR run `samples.import { file_path: "" }` (the assistant can do this for you). 3. The sample's sha256 binds to the Soundfile node; on the next compile your Builder plays back the recording instead of synthesizing. > 🟦 **WHY SHA256?** FaustWave stores samples by content hash, not by path. This means importing the same bytes twice is a no-op (returns the existing entry); and a DSP that references a sample by sha is portable across machines as long as the bytes are available locally. The `.builder` file carries denormalised metadata (file name, channel count) so a recipient who doesn't have the bytes gets a "browse for the file" prompt instead of a silent compile. ## What you've built (Getting Started, complete) - A Faust DSP (osc + midi_note + gain β†’ output). - A MIDI-driven sequencer pattern in A minor with arpeggio + chord stabs. - A lossless WAV recording of the loop on disk. - A sample that re-enters the same Builder via a Soundfile node. You've touched every major subsystem: Builder, Sequencer, Patchbay routing, Mixer, Master, Recorder, and the Documents / sample library. Everything from here is depth. ## Where to go next β€” the rest of the manual Each button opens the book in its own Reader tab; the full library also lives in the **Knowledge** rail panel under *Installed Packs*. ```yaml actions: - action_id: book.open input: { book_id: faustwave-builder } label: "πŸ“– Builder β€” the visual graph editor in depth" - action_id: book.open input: { book_id: faustwave-sequencer } label: "πŸ“– Sequencer β€” grid, slots, scenes" - action_id: book.open input: { book_id: faustwave-mixer-master } label: "πŸ“– Mixer & Master β€” tracks, sends, FX rack" - action_id: book.open input: { book_id: faustwave-reference } label: "πŸ“– Reference β€” shortcuts, MCP catalog, formats" ``` - **FaustWave β€” Builder** β€” the canvas + node-kinds + lifecycle + samples + the `.dsp` workflow. - **FaustWave β€” Sequencer** β€” multi-track grid, slots, scenes, the full action catalog. - **FaustWave β€” Keyboard** β€” chords, hold-mode, channel filtering. - **FaustWave β€” Recorder** β€” the tap model, file format, parameter-sweep capture chains. - **FaustWave β€” Mixer & Master** β€” tracks, sends, the Master FX rack, master volume + softclip. - **FaustWave β€” Patchbay & Routing** β€” the routing model + matrix + the `routing.*` MCP surface. - **FaustWave β€” Hub** β€” publishing instruments, sample-packs, books, projects. - **FaustWave β€” AI Assistant** β€” the in-app AI, the Command Palette, MCP for external clients. - **FaustWave β€” Reference** β€” keyboard shortcuts, MCP catalog, file formats, themes. - **FaustWave β€” Node-Pack Authoring** β€” writing + publishing your own node kinds. --- # The Builder in depth The Builder is FaustWave's visual graph editor. You build node graphs; the Builder generates Faust source from them in real time, compiles to WebAssembly, and runs the resulting worklet at audio-thread priority. It's the centre of gravity for most users. This section is the canvas + node model. The next section covers the workflow (Builder vs `.dsp` source, version control, sharing) and the third covers sample-loading. ## Canvas anatomy A Builder tab is split into: - **The canvas** β€” infinite-pan, zoom-in / zoom-out. Hosts nodes and the cables between them. Right-click empty area opens the node picker. - **The footer** β€” toolbar with Save / Open / Auto-arrange / Compile / Run + Stop / Polyphony / DSP-mode / Inspector buttons. - **Per-node inspector** (right-click node β†’ Inspect, or via the Inspector toggle) β€” fine-grained view of node params + modulation rings. - **Minimap** β€” toggle on for a thumbnail navigator (useful past ~20 nodes). **Default OFF** (0.64.5+) β€” the canvas + Hub-Discovery rail already compete for real estate; flip on via the canvas-controls minimap toggle when you want it. ## Nodes, by category Node kinds come from registered node-packs. The IDE ships **seven bundled packs** today (β‰ˆ 143 kinds total), auto-restored on launch: | Pack | Kinds | What it covers | |---|---:|---| | `faust` | 70 | stdfaust wrappers β€” oscillators, filters, effects, envelopes, math | | `faustwave-conventions` | 6 | Coloration (tape / tube / transistor_drive / bus_glue) + PolyBLEP variants β€” Sound-Quality Welle additions | | `faust-analysis` | 6 | Spectrum, FFT, mid-side goniometer, true-peak, Goertzel | | `faust-reverbs` | 7 | Schroeder / Zita / Dattorro / Spring / Greyhole + variants | | `faust-spatial` | 3 | Paired-port stereo Kinds (mono β†’ stereo, stereo widener, true-stereo reverb) | | `faust-physical-models` | 5 | Struck-instrument `pm.*` wrappers (marimba / bowl / bell Γ— 2 / djembe) | | `faust-vintage` | 46 | 18 tube stages (12AX7/AT7/AU7/6V6/6DJ8/6C16 Γ— T1-T3) + 25 amp tonestacks + 3 wah pedals | Community packs install via `hub.install kind:"node-pack"` (or the Hub module's Browse β†’ Install path). | Category | Examples | What it does | |---|---|---| | **Source** | osc, saw_polyblep, square_polyblep, noise, soundfile, midi_note | Audio + control sources | | **Filter** | lp, hp, bp, moog_vcf, smooth | Filters in the broad sense | | **Effect** | gain, distortion, softclip, reverb, delay, chorus, **tape, tube, transistor_drive, bus_glue** | Includes the Coloration Kinds from the Sound-Quality Welle | | **Stereo** | mono_to_stereo, stereo_widener, lush_reverb_stereo, ping_pong_delay | Paired-port (channels=2) signal processing β€” Phase 4 | | **Vintage** | tube_12ax7_t1..t3, amp_jcm800, amp_bassman, wah_crybaby, … | tubes.lib + tonestacks.lib + vaeffects.lib bundled wrappers | | **Mix** | mixer-2, mixer-4, pan, sum, split | Bus / routing primitives | | **MIDI** | midi_note, midi_cc, midi_pitch_bend | MIDI ingest + processing | | **Sample** | soundfile, sampler-loop | Disk-backed sample playback | | **Output** | output | Channel-aware sink (`in_l`, `in_r` paired ports β€” channels=2) | > 🟦 **DISCOVER FROM CODE**: the live catalog is always one MCP call away β€” `builder.kinds.list {}` returns id / label / category / I/O / param counts for every registered kind (no `module_id` needed; it's a global catalog). `builder.kind.describe { kinds: [...] }` returns the full schema (up to 10 kinds per call). The catalog in this section may drift; the MCP catalog never does. ## A live-catalog chain ```yaml actions: - action_id: builder.kinds.list input: {} save_as: kinds label: "β–Ά 1. List every registered node kind" - action_id: builder.kind.describe input: { kinds: [osc, lp, output] } save_as: details label: "β–Ά 2. Get full schema for osc + lp + output" ``` > 🟦 **HANDS-FREE PATH**: step 1 enumerates the whole catalog; step 2 pulls the port + param schemas for three specific kinds. This is what the assistant does behind the scenes when you ask "add an oscillator and a low-pass filter". ## Adding nodes Four equivalent ways: 1. **Right-click the canvas** at the position you want the node. Picker opens at cursor, searches across name + category + description. Hit Enter on the first match. 2. **Open the Node Library** via the footer's Library button. Same picker, always-visible. 3. **AI ask**: *"add an osc to my Builder"*. The assistant runs `builder.graph.node.add { module_id, kind }` for you. 4. **MCP direct**: `builder.graph.node.add { module_id, kind, x?, y?, params? }` β€” same action all paths route through. ## Editing nodes Each node carries two kinds of properties: - **Params** β€” numeric values (frequency, cutoff, gain, ...). Edit via the on-canvas knob (drag), via the Inspector panel, or via `builder.graph.node.param.set { module_id, node_id, param, value }`. The companion `builder.graph.node.param.get` reads the running engine's live post-modulation value β€” useful for confirming an LFO is actually moving the param. - **Refs** β€” string-typed bindings, currently only used for `soundfile` node-kind sha256 references. Set via `builder.graph.node.ref.set { module_id, node_id, param, value }`. The distinction matters because param changes are typically smooth (the worklet ramps internally), while ref changes are topology-relevant (a new sha means a new file in the worklet's slot map) and trigger a `stop β†’ recompile β†’ start` cycle. ### Duplicate `builder.graph.node.duplicate { module_id, node_id }` clones a node β€” its kind, param values, soundfile refs, bypass state β€” at a small offset. Returns the new node id. Groups are not duplicated; if you duplicate a node inside a group, the clone lands top-level. ### Bypass Effect-kinds and filter-kinds declare a bypass mapping; flipping bypass either re-routes the chain around the node or triggers a recompile (when the worklet's I/O shape changes). Bypass-able kinds today: `gain`, `smooth`, `lp`, `hp`, `moog_vcf`, `distortion`, `softclip`, `reverb`, `delay`. Other kinds ignore the flag. > πŸ”˜ **MCP**: `builder.graph.node.bypassed.set { module_id, node_id, bypassed: true | false }`. Bypass is topology-level: with audio running the Builder transparently stops + recompiles + restarts (brief gap). With audio stopped, the flag stages for the next `dsp.run`. ## Cables Coloured by signal kind so you can read flow at a glance: - **Cyan** β€” audio - **Purple** β€” control / parameter (e.g. envelope β†’ gain) - **Orange** β€” MIDI trigger (gate, velocity) Connection rules are kind-aware: cables only stick between compatible port types. Wrong combos refuse to connect (the canvas highlights illegal targets while you drag). > πŸ”˜ **MCP**: `builder.graph.connect { module_id, source_node_id, target_node_id, source_port?, target_port? }` adds an edge. `builder.graph.disconnect { module_id, edge_id }` removes one. ## Groups A group is a labeled visual frame around N nodes. Purely organisational β€” no audio impact, no source change. Useful past ~15 nodes when "what's the bass section?" stops being instantly obvious. | Action | Purpose | |---|---| | `builder.graph.group.create { module_id, node_ids, label? }` | Wrap selected nodes into a group. Skips ids that are already grouped. | | `builder.graph.group.label.set { module_id, group_id, label }` | Rename. | | `builder.graph.group.ungroup { module_id, group_id }` | Dissolve. Children keep their absolute positions and wirings. | | `builder.graph.groups.list { module_id }` | Snapshot of every group + its members. | ## Mono vs polyphonic compilation By default a Builder compiles as **mono** β€” one voice, no polyphony. A single MIDI key-press steals the previous voice's pitch. Switch to **polyphonic** via the **Polyphony** footer toggle (cycles Off β†’ 4 β†’ 8 β†’ 16 voices β†’ Off). When polyphonic: - The source compiles with `declare options "[nvoices:N]"` β€” the Faust poly generator handles voice allocation. - Incoming MIDI routes per-voice via the worklet's `keyOn` / `keyOff` API. - The graph must contain **at most one** `midi_note` node β€” multiple `midi_note` instances all collapse onto the same shared magic-name sliders (would be ambiguous in poly mode). > πŸ”˜ **MCP**: `dsp.polyphonic.set { module_id, voices: 0 | 4 | 8 | 16 }`. The engine auto-stops if running so the next `dsp.run` compiles cleanly. ## Compile + Run lifecycle The Builder runtime is a 3-state machine: `stopped` β†’ `armed` β†’ `running`. | From | To | Trigger | |---|---|---| | stopped | running | Press RUN (local) or master Play if transport-following β€” **eager-run** | | stopped | armed | Master Play when the DSP is a MIDI consumer + transport-following β€” **pre-armed** quietly, wakes on first MIDI event | | armed | running | First MIDI event arrives (wake-up) | | armed | stopped | Master Stop | | running | stopped | Press STOP / Master Stop | The **eager-run** path is the per-Builder Play (and Builders that don't declare themselves MIDI consumers). The **pre-arm** path lets dozens of MIDI-consumer Builders sit quiet during transport.play without firing N parallel libfaust compiles at once β€” they wake only when the sequencer actually delivers them a note. > πŸ”˜ **MCP**: `dsp.compile { module_id }`, `dsp.run { module_id }`, `dsp.stop { module_id }`. The compile path is autonomous β€” `dsp.run` recompiles if the graph drifted since the last compile. ## Transport-follow toggle Default: every Builder follows the global transport. Toggle off via the Builder footer's **Link** button (or `dsp.transport.follow.set { module_id, enabled: false }`) for a Builder you want isolated β€” e.g. an FX-only utility DSP, or one you're auditioning while the rig plays. The opt-out persists in the `.builder`. ## Paired-port stereo β€” Phase 4 Output + Input kinds expose **paired-port stereo**: each side declares two ports (`in_l` / `in_r` on Output, `out_l` / `out_r` on Input) carrying `channels: 2` metadata. The Builder's wire validator is paired-port-aware (PR #286) β€” connecting one half of a paired port surfaces a hint to wire its partner. The cable's stroke-width doubles when both halves are connected, so a stereo path reads at a glance. A typical stereo chain: `midi_note β†’ adsr β†’ osc β†’ softclip β†’ mono_to_stereo β†’ stereo_widener β†’ output`. The chain stays mono until `mono_to_stereo` (one input, two outputs); from there both sides flow through `stereo_widener` (paired in, paired out) into Output's `in_l` + `in_r`. Stereo-aware Kinds live in **`faust-spatial`** (`mono_to_stereo`, `stereo_widener`, `lush_reverb_stereo`) + a few in `faust-reverbs` / `faust-vocal`. Mono Kinds work unchanged β€” fan-out to stereo happens at Output (`<: _, _`). ## FX as graph Kinds (not built into Output) The Output node-kind is a slim **channel-aware sink** β€” paired `in_l` + `in_r` input ports, the Unison config (voices / detune / stereo-spread), and nothing else. FX (saturation, chorus, reverb, width) live as eigenstΓ€ndige Kinds you place in the graph: `softclip`, `chorus`, `reverb`, `mono_to_stereo`, `stereo_widener`, `lush_reverb_stereo`, `ping_pong_delay`. A common sound-quality chain looks like: instrument β†’ `softclip` β†’ `chorus` β†’ `reverb` β†’ output. This is consistent with the "Effekte = Kinds" Builder convention β€” wire what you want, where you want it. Per-Builder CPU is naturally low because nothing runs unless you wire it. The pre-Phase-4 auto-injected FX-Bus (drive / chorus / reverb defaults baked into every Output compile) was dissolved in #284 β€” its sonic recipe lives on as the upcoming Master-Control rack (see TODOS). ## Inspecting the generated Faust source Click the **``** button in the Builder toolbar β€” the live Faust source copies to your clipboard (icon swaps to βœ“ for ~1.2s as confirmation). Paste into a `.dsp` editor + click **Compile** there to validate, or into any text editor for inspection. Useful for: - Confirming what your knob writes (find the `hslider` declaration in the pasted source). - Lifting a Builder graph into a `.dsp` to keep evolving outside the Builder. - Filing bugs. The source is also available via `builder.source.get { module_id }` for MCP-driven workflows. Per-kind imports (e.g. `import("tubes.lib")` when a Vintage Kind is in the graph) are auto-collected + emitted by `faustGen` after the default `import("stdfaust.lib")` header β€” see **FaustWave β€” Node-Pack Authoring** Β§ 2 for the mechanism. ## Modulation rings In the Inspector view, every editable param shows a **modulation ring** around the knob: it's the visual surface for connecting an LFO / external modulation source to that param. Right-click the ring to set the source + amount. LFOs live in the **Modulators** rail panel (right rail β€” "+ Add" β†’ *Modulators*). See **FaustWave β€” Patchbay** Β§ 3 for the modulation cable semantics (`amount` instead of `gain`, per-param Sink ports). ## MIDI channel filter Every Builder has a MIDI channel filter (0 = Omni, 1..16 = listen on that channel). Sequencer tracks dispatch to a specific channel; matching channels receive the events. > πŸ”˜ **MCP**: `dsp.midi.channel.set { module_id, channel: 0..16 }`. The filter applies to every `midi_note` node in the DSP. ## AI parity Every gesture in the Builder routes through the action registry β€” palette, click, drag, keyboard shortcut. The assistant + the MCP server see the same actions, same input schemas. There's no "limited subset"; what you can do, the AI can do. The hard-required `module_id` on all DSP actions (every `dsp.*` call must specify which Builder) means external MCP clients must thread the Builder id through every call β€” no silent fallback to "the active tab". Discover via `documents.list` or capture the return of `documents.add`. Next section: the workflow side β€” Builder vs `.dsp` source, version control, sharing. --- # Two ways to build a DSP β€” Builder vs `.dsp`, version control, sharing A **DSP** is the thing that makes sound: a compiled Faust worklet, running. Two kinds of document produce one, and FaustWave keeps them apart on purpose β€” a **Builder** (`.builder`) is a node graph you wire, a **Faust DSP** (`.dsp`) is source text you write. Both compile through the same libfaust pipeline and run as the same worklet; what differs is the editing surface and the file that ends up on disk. That distinction runs through the whole product. The two documents are `builder` and `faust-dsp`, and the actions follow: `builder.*` edits a graph, `dsp.*` drives whatever is running β€” either kind. This section covers when to use which, how to evolve one, and how to share it. ## Builder vs `.dsp` β€” when to use which | You're… | Use | |---|---| | Wiring effects + filters in a signal flow you can SEE | Builder (`.builder`) | | Translating a Faust paper / online example into something runnable | `.dsp` | | Iterating on a synth with a few oscillators + filter | Builder | | Writing a custom Master FX slot | `.dsp` source pasted into `master.fx.slot.add` | | Sharing with someone unfamiliar with Faust | Builder β€” they can SEE the structure | | Sharing with a Faust dev who wants to fork the source | `.dsp` β€” they get text they can read + edit | You can move between them: Builder canvas β†’ *Show Faust source* β†’ paste into a `.dsp` file. A `.dsp` with a `process = ...` body β†’ `builder.import` it into an empty Builder (planned; manual paste-and-cleanup works today). ## The canonical Builder loop The short round-trip from idea to running DSP: ```yaml actions: - action_id: documents.add input: { type: builder, title: "My first Builder" } save_as: b label: "β–Ά 1. Spawn a Builder" - action_id: builder.graph.node.add input: { module_id: "{{b.module_id}}", kind: osc } save_as: osc label: "β–Ά 2. Add an oscillator" - action_id: builder.graph.node.add input: { module_id: "{{b.module_id}}", kind: lp } save_as: lp label: "β–Ά 3. Add a low-pass filter" - action_id: builder.graph.node.add input: { module_id: "{{b.module_id}}", kind: output } save_as: out label: "β–Ά 4. Add the output" - action_id: builder.graph.connect input: module_id: "{{b.module_id}}" source_node_id: "{{osc}}" target_node_id: "{{lp}}" label: "β–Ά 5. Wire osc β†’ lp" - action_id: builder.graph.connect input: module_id: "{{b.module_id}}" source_node_id: "{{lp}}" target_node_id: "{{out}}" target_port: in_l label: "β–Ά 6. Wire lp β†’ output" - action_id: builder.graph.node.param.set input: module_id: "{{b.module_id}}" node_id: "{{lp}}" param: cutoff value: 800 label: "β–Ά 7. Cutoff = 800 Hz" - action_id: dsp.run input: { module_id: "{{b.module_id}}" } label: "β–Ά 8. Compile + run" - action_id: documents.save input: { module_id: "{{b.module_id}}" } save_as: saved label: "β–  9. Save to assets/" ``` > 🟦 **HANDS-FREE PATH**: a complete osc β†’ lp β†’ output graph, parametrised, compiled, played, and saved β€” all through MCP. The same eight `dsp.*` calls that drive the canvas drive the assistant here. ## The `.dsp` workflow `.dsp` documents are plain text. Open one via Documents β†’ Open from disk, or: ``` documents.add { ref: "asset:assets/.dsp" } ``` Each `.dsp`: - Has a `process = ...` definition (the audio chain). - Compiles to a worklet on Run (same pipeline the Builder takes). - Exposes Faust sliders / bargraphs as live params, accessible via `dsp.param.set` / `dsp.param.get` and the Inspector panel. You can `import("stdfaust.lib");` at the top to pull in the Faust standard library plus everything mounted under custom `.lib` files. ### Compile vs Run The `.dsp` editor's footer carries **two** buttons when stopped: **Compile** and **Run**. - **Compile** (Hammer icon) β€” calls libfaust only. Reports syntax / semantic errors and stops there. Use when you want to validate a paste without starting audio. - **Run** (Play icon) β€” compile + start the worklet. Requires the source to be a **generator** (`process = …`, no audio input). An FX-shape source (`process(in)` or `process(in_l, in_r)`) compiles cleanly but Run bails at start with a friendly error: *"DSP modules are generators with no audio input. For FX-shape sources use master.fx.slot.add or the Master panel's 'Add FX' picker."* The Compile split lands clean when you paste a Builder-generated source β€” Builder graphs always emit `process(in_l, in_r) = …`, which is FX-shape. Use Compile to confirm the paste survived transit, then mount it into a Master FX slot (or back into an empty Builder via `builder.import` once that's wired) instead of trying to Run it standalone. > πŸ”˜ **MCP**: `dsp.compile`, `documents.list`, `dsp.run`, `dsp.stop`, `dsp.param.set`, `dsp.param.get` work on either kind β€” the Builder's graph and a `.dsp`'s text compile to the same worklet. See `palette.list { category: "DSP" }` for the full set. ## `documents.save` vs `builder.export` Two write-to-disk paths that look similar but differ in one critical way: | Action | What it does | When to use | |---|---|---| | `documents.save { module_id, path? }` | Writes the `.builder` to disk AND **links it to the module** so the project reopens it on next load. Without `path`: auto-place in the active project's `assets/`. | When building content that should persist in the project. | | `builder.export { module_id, path? }` | Writes the `.builder` to disk (or returns the JSON if no `path`). Does NOT link to the module β€” the graph would reopen empty next session. | When sharing a one-off file. | The trap: `builder.export` followed by `hub.publish` works, but next time you open the project the Builder is empty (no linked file). Use `documents.save` for project-resident documents, then either `hub.publish` directly with the saved file, or `builder.export` to a separate share location. ## Importing a `.builder` ``` 1. documents.add { type: "builder", title: "Import target" } β†’ module_id 2. builder.import { module_id, path: "asset:assets/.builder" } (or an absolute OS path for an external file) ``` `builder.import` accepts EITHER `path` (loads from disk + KB re-indexes it) OR `doc` (an in-memory BuilderDocument object / JSON string). Pass exactly one. ## Evolving one β€” version control without the ceremony `.builder` and `.dsp` documents are **text / JSON files in your project's `assets/` folder**. That means git tracks them naturally: ```bash cd ~/your-project-as-git-repo git diff assets/my-saturator-lead.builder ``` Two patterns: - **Per-project git repo** β€” your whole FaustWave project is a git repo. DSPs, samples (gitignore the hashed bytes if you don't want them in repo), sequencer state β€” everything. - **One repo per instrument** β€” share an evolving Builder via a tiny repo + a `.builder` + a `README.md` describing what it does. Other users `builder.import` from disk. Plus the Hub for one-click distribution when git isn't the right granularity. ## Sharing one Three escalating channels: 1. **Send the file** β€” `.builder` or `.dsp`. Recipient opens it; if there are sample refs they don't have, FaustWave prompts on compile. Works for one-off shares. 2. **Push to a git repo** β€” best for "I'm iterating with one collaborator and I want diffs." 3. **Publish to the Hub** β€” `hub.publish { kind: "builder" | "dsp", file_path, slug, title, ... }`. Best for "anybody can install with one click." See **FaustWave β€” Hub** Β§ 3. ## Templates from the Hub Before starting from scratch, check the Hub: ```yaml actions: - action_id: hub.search input: { kind: builder, q: bass } save_as: hits label: "β–Ά Look for bass templates" - action_id: hub.install input: { item_id: "{{hits.data.0.id}}" } label: "β–Ά Install the top hit" ``` Install a starter, fork it, evolve. Faster than learning the Builder from a blank canvas; you see how an existing graph puts the nodes together. ## Where to go from here - **FaustWave β€” Builder** Β§ 1 β€” the canvas + node-kinds + lifecycle. - **FaustWave β€” Builder** Β§ 3 β€” loading samples into a `soundfile` node. - **FaustWave β€” Hub** Β§ 3 β€” publishing them as `hub.publish kind: "builder"` / `kind: "faust-dsp"`. - **FaustWave β€” Mixer & Master** Β§ 2 β€” turning a `.dsp` body into a Master FX insert. - **FaustWave β€” Node-Pack Authoring** β€” the Box-DSL syntax that pack-author `toBox` templates use, per-kind `imports` declaration, and paired-port stereo for community packs. --- # Loading samples You have an audio file (a kick drum, a vocal phrase, a synth bounce) and you want to use it in a Builder β€” in a `soundfile` node, in a sampler DSP. This section is the workflow. The sample-store + the round-trip from recording β†’ sample also touches **FaustWave β€” Recorder** Β§ 2. ## What FaustWave accepts | Format | Notes | |---|---| | **.wav** | Full header parsing β€” channels / sample rate / duration are populated. The default for FaustWave's own recordings. | | **.flac** | Bytes import fine; header parsing for channels / rate / duration is a TODO so those fields land as 0. | | **.aiff** | Same as `.flac` β€” bytes-only for now. | Hard limit: **100 MB per sample**. Bigger files are rejected at import; chop them externally first. ## How samples are addressed FaustWave doesn't reference samples by path. **Every sample lives in the sample store keyed by its sha256 content hash.** Two consequences: 1. **Idempotent imports** β€” importing the same bytes twice is a no-op. The second import returns the existing sha and adds no new file. Drag the same WAV in 50 times; you have one sample. 2. **Portable documents** β€” a `.builder` references its samples by sha. Share the DSP with a friend who already has the sample β†’ it just works. Share with a friend who doesn't β†’ they get a "browse for this sha" prompt at compile, with the original filename + channel/rate metadata as hints. > 🟦 **WHY SHA256?** Path-based references break the moment you rename a folder. Hash-based references survive folder reshuffles + cross-machine handoff. The cost (the sample's filename is no longer in the DSP URL) is borne by the metadata carry β€” the `.builder` records the original filename alongside the sha so the prompt is still recognizable. ## Three ways to import ### A) Drag-and-drop onto a Builder canvas Drop the file onto the Builder canvas. If there's a `soundfile` node selected, the sample binds to it. If not, FaustWave adds a `soundfile` node, binds the sample to it, and places it at the drop position. ### B) The `samples.import` MCP action ``` samples.import { file_path: "" } ``` Returns the SampleEntry β€” `{ sha256, channels, sampleRate, duration, originalFilename, byteSize }`. Idempotent (same bytes twice = same entry). ### C) Hub install of a sample-pack If you don't have the bytes locally but somebody published them to the Hub: ``` hub.install { item_id: "" } ``` The install fetches the bytes + runs the same sha-keyed write path. The sample lands in your store ready to reference. See **FaustWave β€” Hub** Β§ 3 for the publishing side. ## Using the sample in a graph ``` 1. samples.import { file_path: "" } β†’ { sha256, … } 2. documents.add { type: "builder", title: "Sample player" } β†’ module_id 3. builder.graph.node.add { module_id, kind: "soundfile" } β†’ sf node id 4. builder.graph.node.ref.set { module_id, node_id: , param: "sha256", value: } 5. builder.graph.node.add { module_id, kind: "output" } β†’ out node id 6. builder.graph.connect { module_id, source_node_id: , source_port: "audio_l", target_node_id: , target_port: "in_l" } 7. dsp.run { module_id } ``` (Not a click-chain β€” step 1 needs the absolute OS path of YOUR audio file, the one place paths are expected: the sample import gate. Ask the assistant to run the sequence with a real file.) The `soundfile` node's `sha256` ref is the binding point: once `builder.graph.node.ref.set` lands, the Builder's compile includes the sample in its worklet bundle, and `dsp.run` plays it. ## The `soundfile` node is a player Since the sample-hybrid round (2026-08-29) the node is a one-shot **player**, not a tape that runs once at patch start: - **`trig_in`** restarts playback on every rising edge β€” wire `midi_key.gate` (one node per drum voice), `midi_note.gate`, or a `clock.tick`. - **`freq_in` + `root_hz`** make the sample follow the played note like a sampler zone: the read rate is `freq / root_hz`. Measure the root with `analysis.sample` (the `strongestLowBinHz` field of its spectrum block) β€” do not trust the pack's key label. `root_hz` 0 = no tracking, plain `rate`. - **Two read heads.** A retrigger starts the *other* head and crossfades to it in ~5 ms while the old tail runs out; the last 5 ms of the file fade to zero. Both were single-sample jumps before β€” measured as clicks on every retriggered open hat and at the hard end of a distorted 808 sample. See **FaustWave β€” Analysis** Β§ *The click hunt*. - Shape a tail with `adsr x mul` when the sample is longer than the note; nothing else is built (no loop, reverse or start offset β€” nobody asked). The kit recipe is `midi_key β†’ soundfile β†’ mul(velocity) β†’ gain β†’ add …` per voice; the bundled *Around the World* project's `Sample Kit.builder` is seven of those summed. ## Browsing your sample store ```yaml actions: - action_id: samples.list input: {} save_as: store label: "β–Ά Enumerate every sample" ``` Returns an array of SampleEntry β€” sha, original filename, channels, sample rate, duration, byte size. `samples.get { sha256 }` returns one entry's metadata (not the bytes β€” too heavy for MCP). `samples.remove { sha256 }` deletes (destructive β€” confirm before calling; DSPs referencing the sha will prompt-to-locate on next compile). ## Where the bytes live on disk | OS | Path | |---|---| | **Windows** | `%APPDATA%\FaustWave IDE\samples\\.` | | **macOS** | `~/Library/Application Support/FaustWave IDE/samples//.` | | **Linux** | `~/.config/FaustWave IDE/samples//.` | The folder structure (sha-prefixed) is what lets the import be idempotent + content-addressed. ## A common end-to-end task: record β†’ re-import The round-trip from synth performance to playable sample is one chain (full version in **FaustWave β€” Recorder** Β§ 2): 1. Press Rec. 2. Play the synth via the Keyboard or Sequencer. 3. Press Rec to stop β€” `recorder.stop` returns the WAV path. 4. `samples.import { file_path: }` β€” the recording is now sha-keyed in the store. 5. Drop a fresh `soundfile` node + bind by sha as in the chain above. From "synth voice" to "playable sample slice" without leaving FaustWave. ## Common moves - **"Use the kick I dragged in last week"** β€” `samples.list { search: "kick" }` narrows by filename; pick it by `filename` or `durationMs`, copy the sha, bind it to a fresh `soundfile` node via `builder.graph.node.ref.set`. - **"Delete every old WAV I dragged in for testing"** β€” `samples.list`, filter by sha or filename, iterate `samples.remove`. No trash. - **"Re-bind a soundfile in an existing Builder"** β€” `builder.graph.node.ref.set` with a new sha. The change is topology-relevant so the engine recompiles; the new sample plays immediately. ## Where to go from here - **FaustWave β€” Builder** Β§ 1 β€” wiring `soundfile` nodes into the rest of your graph (envelope, filters, FX). - **FaustWave β€” Recorder** Β§ 2 β€” the round-trip from recording β†’ sample-store. - **FaustWave β€” Hub** Β§ 3 β€” publishing samples as `hub.publish kind: "sample-pack"`. - **FaustWave β€” Getting Started** Β§ *Your first recording* β€” the 5-minute starter flow. --- # Faust DSP: the second abstraction FaustWave gives you two ways to build sound, and both end in the same place: a **DSP** β€” a compiled Faust worklet, running. The **Builder** is the visual route β€” nodes and cables, no code in sight. A **Faust DSP** (`.dsp`) is the same engine with the covers off: you write [Faust](https://faust.grame.fr) source in a Monaco editor, hit Run, and the IDE compiles it to WebAssembly and runs it as an AudioWorklet β€” exactly as a Builder does, because under the hood a Builder *generates* Faust source too. The word matters here, so it is worth being exact: **"DSP" is what runs**, and `builder` and `faust-dsp` are the two documents that produce one. The action families follow that split β€” `builder.*` edits a graph, `dsp.*` drives whatever is running, either kind. When to reach for a `.dsp` instead of the Builder: - **The stock nodes can't express it.** Pitch envelopes, wavetable switching, frame-rate steppers, custom feedback topologies β€” anything the node catalog doesn't cover is a few lines of Faust. - **You want the whole `stdfaust.lib` universe.** Every oscillator, filter, envelope, reverb and physical model in the Faust standard library is one `import("stdfaust.lib");` away. - **You think in code.** A DSP is a plain text file β€” diffable, versionable, forkable on the Hub as `kind: "dsp"`. What you do NOT give up: the mixer strip, the Patchbay routing, MIDI, modulation, the sequencer β€” a running `.dsp` is a first-class citizen of the same audio graph. ## Lifecycle in one paragraph `documents.add { type: "faust-dsp" }` creates the module **and its project file** immediately (auto-placed in the project's `assets/`, autosaved on every edit). Run compiles + starts the worklet; Stop disposes it but keeps the mixer strip. Editing while running does NOT hot-swap β€” Stop + Run to hear changes. The file lives with your project and travels with a project export. > 🟦 **DISCOVER FROM CODE**: `documents.list {}` returns every open `.dsp` > module with its runtime state β€” `running`, `status`, `compile_error`, > `polyphonic`, `midi_channel`, and the live param paths. It's the first > call the assistant makes before touching any DSP. --- # Anatomy of a DSP β€” and your first one A minimal generator DSP is three lines: ```faust import("stdfaust.lib"); level = hslider("level", 0.3, 0, 1, 0.001); process = os.osc(440) * level; ``` - `import("stdfaust.lib")` mounts the standard library under its usual two-letter prefixes (`os.` oscillators, `fi.` filters, `en.` envelopes, `no.` noise, `ba.` basics, `ma.` math, …). - Every `hslider` / `nentry` / `button` / `checkbox` becomes a live knob in the module's UI panel **and** a param path the AI can read and write. - `process` is the DSP's signal function. **Generator shape** means no audio inputs: `process = ;`. Mono output is fine β€” split to stereo with `<: _, _` when you want both channels. Two shapes the editor treats differently: | Shape | Example | Where it runs | |---|---|---| | **Generator** | `process = os.osc(f);` | A `.dsp` module (this book) | | **FX** | `process = _ : fi.lowpass(1, fc);` | Master-FX or track-FX slots β€” a standalone `.dsp` module has no input source, so Run refuses it with a friendly error. Validate FX sources with `dsp.compile` instead. | ## Build one hands-free ```yaml actions: - action_id: documents.add input: { type: "faust-dsp", title: "Tutorial Tone" } save_as: mod label: "β–Ά 1. Create a .dsp module (file lands in assets/)" - action_id: editor.source.set input: { module_id: "{{mod.module_id}}", source: "import(\"stdfaust.lib\"); level = hslider(\"level\", 0.2, 0, 1, 0.001); process = os.osc(440) * level <: _, _;" } label: "β–Ά 2. Write a sine-tone source into the buffer" - action_id: dsp.run input: { module_id: "{{mod.module_id}}" } label: "β–Ά 3. Compile + start the worklet" - action_id: mixer.tracks.input.set input: { module_id: "{{mod.module_id}}", source_title: "Tutorial Tone" } label: "β–Ά 4. Attach to a mixer track β€” now it is audible" - action_id: dsp.stop input: { module_id: "{{mod.module_id}}" } label: "β–Ά 5. Stop the tone (strip + file survive)" ``` > 🟦 **RUNNING β‰  AUDIBLE**: step 3 alone plays into silence β€” a module > becomes audible when its output reaches the master, which is what the > attach in step 4 wires up (and it gives you a fader strip for free). > `dsp.run`'s result tells you: it lists `routed_to` targets and warns > when there are none. --- # MIDI & polyphony: nvoices and the magic sliders A mono DSP ignores note events. To make a `.dsp` a playable instrument, declare polyphony **in the source** β€” the source is the single source of truth, there is no separate UI toggle: ```faust declare options "[midi:on][nvoices:8]"; import("stdfaust.lib"); freq = hslider("freq", 440, 20, 8000, 0.01); gate = button("gate"); gain = hslider("gain", 0.8, 0, 1, 0.01); env = en.adsr(0.005, 0.1, 0.7, 0.3, gate); process = os.sawtooth(freq) * env * gain <: _, _; ``` The three **magic-name sliders** are the contract with the poly runtime: | Param | Filled per voice with | |---|---| | `freq` | The note's frequency (from the MIDI note number) | | `gate` | 1 on note-on, 0 on note-off β€” drive your envelope with it | | `gain` | The velocity, normalized 0..1 | On every note-on the runtime allocates one of the N voices and writes these three params **into that voice only**; your `process` runs once per voice and the voices are summed. `[nvoices:N]` is also what registers the DSP as a MIDI consumer at all β€” check `system.state { sections: ["midi_consumers"] }` when notes seem to go nowhere. Wiring: a running DSP exposes a `:midi-in` sink in the routing table (only while running β€” build the graph, run, THEN connect). Connect the on-screen keyboard or a sequencer to it in the Patchbay, or via `routing.connect { kind: "midi" }`. ## Reading params on a poly DSP β€” a trap `dsp.param.get` on `freq` / `gate` / `gain` of an `[nvoices]` DSP returns the GLOBAL param β€” which stays at its init value even while notes are audibly playing, because the real values live per voice. The result carries an explicit note about this. Do not conclude "keyOn is broken" from such a read; verify audibility with your ears or the master meters instead (see the *Hearing is proof* section). > 🟦 **ONE DSP, ONE VOICE BUDGET**: `[nvoices:2]` mirrors chip-era > constraints beautifully β€” voice stealing is real and audible. For lush > pads, 8–16 voices is the comfortable range. --- # The MIDI channel filter: one sequencer, many instruments By default a `.dsp` is **Omni** β€” it reacts to notes on every MIDI channel. That's fine for one instrument; with several DSPs on the bus, every note plays every instrument. The fix is the per-DSP channel filter: ```faust declare options "[midi:chan 2][midi:on][nvoices:8]"; ``` `[midi:chan N]` (1–16) is a FaustWave convention token in the `declare options` string β€” libfaust ignores it; the DSP's MIDI handler filters on it. Absent token = Omni. **What the filter applies to** (one rule everywhere since the pre-arm round): notes, pitch bend, and the two panic CCs (120 All Sound Off, 123 All Notes Off) are channel-scoped β€” they are what a wrong channel would make audible. Everything else passes regardless of channel: system messages, clock, program change, and **ordinary CCs**. A CC that should listen on one channel only says so per control, with `[midi:ctrl N CH]` in the parameter's metadata β€” that is the one per-patch channel scoping Faust itself understands. You rarely write it by hand. The action does it for you, **live on the running DSP, no recompile**: - `dsp.midi.channel.set { module_id, channel }` β€” 0 = Omni (removes the token), 1–16 = accept only that channel. Marks the buffer dirty; `documents.save` persists. - Read the current value from `documents.list` β†’ `midi_channel`. - Builder twin: the `midi_note` node's `channel` param / `dsp.midi.channel.set` β€” same semantics on the visual side. ## The multi-timbral recipe 1. Give every instrument DSP its channel: lead β†’ 1, bass β†’ 2, drums β†’ 3, … 2. Give every sequencer track the matching channel: `sequencer.track.midi-channel.set { track_id, channel }`. 3. Patchbay-connect `sequencer-default:out` to **each** DSP's `:midi-in` sink (the bus is filter-free by design β€” receivers do the filtering). 4. Play. Each track now drives exactly one instrument. Auditioning a single instrument respects the same rule: `midi.note.play { note, channel }` β€” a filtered DSP still lists in `midi_consumers` but silently drops other-channel notes, so **when an audition note stays silent, check the receiver's channel before suspecting broken MIDI**. > 🟦 **CHIP-ERA ALTERNATIVE**: before the channel filter existed, DSPs > separated themselves by note *register* β€” each one gating on a frequency > window (`inRange = (freq > lo) & (freq < hi)`). The 8-Bit/SID book uses > that trick; it's still a legitimate creative constraint, but channels are > the clean default now. --- # Transport, Run/Stop and file lifecycle **Every generator-shaped `.dsp` follows the global transport by default.** Master-Control Play starts its engine, Stop stops it β€” exactly like a Builder. This is usually what you want: press Play, the whole rig comes up. Opting out is source-driven, like everything else about a DSP: ```faust declare options "[transport:off]"; ``` The footer Link-toggle and `dsp.transport.follow.set { module_id, enabled }` edit that token for you. A DSP with the token runs independently β€” drive it via its own Run button or `dsp.run`, and a global Stop leaves it playing. Nuances worth knowing: - **Modules opened after the transport already started do not auto-join** β€” deliberate "open a tab to inspect, not to interrupt" behavior. Hit Run yourself or cycle the transport. - **`dsp.run` on an already-running DSP recompiles cleanly** β€” it stops the prior worklet first. Use it as the "apply my edits" verb. - **A follow-run that errors blocks re-runs until the source changes** β€” no repeated error banners on every Play for a broken DSP. - FX-shape sources never auto-start regardless of the flag (they can't run standalone at all). ## Files, autosave, projects A `.dsp` module created via `documents.add` IS a project file from birth β€” `assets/.dsp`, autosaved on every edit, restored with the project. `documents.save { module_id }` forces a write (or writes to an explicit `path`); `project.file.rename` renames it. A project export (`project.export` β†’ `.fwproject.zip`) bundles every DSP with the project's `project.json`, so mixer strips, routings and sequencer content travel together. > 🟦 **RESTART BEHAVIOR**: after an app restart, DSPs reopen `idle` β€” > running state is not persisted. The global transport is the one-keystroke > way to bring a whole multi-DSP project back to life. --- <!-- https://faustwave.io/docs/faust-dsp/hearing-is-proof β€” book: Faust DSP (.dsp) --> # Hearing is proof: testing sound without fooling yourself "It compiles and runs" is not the same as "it makes sound". FaustWave's house rule β€” for humans AND the assistant β€” is **hearing is the proof**: a DSP counts as working when a test note is audible, confirmed by ears or by the master meters read at the right moment. The *right moment* matters more than it looks. A hard-earned lesson: - `midi.note.play` **blocks until its note-off** β€” the call returns after the note has ended. - The master peak-**hold** decays within a second or two. - Therefore a meter read *after* a `midi.note.play` call always lands in the decay and shows ~zero β€” **fake silence**. An entire evening of bisecting was once spent chasing this artifact across three builds while every "silent" note was audibly playing. The two reliable measurement windows: 1. **A held note** β€” `keyboard.note.on` sustains until you release it, so a meter read in between measures the live signal. 2. **A loop** β€” a playing sequencer pattern gives you an arbitrarily long window; read the meters mid-loop. ## Prove it, hands-free Run this with an instrument DSP running and routed (the previous sections' tutorial DSP works): ```yaml actions: - action_id: keyboard.note.on input: { note: 69, velocity: 100 } label: "β–Ά 1. Hold a note (no note-off yet)" - action_id: system.state input: { sections: ["master"] } save_as: meters label: "β–Ά 2. Read the master meters WHILE it sounds" - action_id: keyboard.notes.clear input: {} label: "β–Ά 3. Release the note" ``` Step 2's `peakL`/`holdL` show the live level β€” that's your objective audibility proof. Values like `1e-5` mean genuinely nothing is sounding (check routing, channel filter, mixer mutes β€” in that order). > 🟦 **SILENCE NEEDS A WITNESS**: before treating silence as a bug, confirm > it by ear β€” yours, or ask the person at the speakers. Meters read at the > wrong moment produce confident, reproducible, completely wrong evidence. --- <!-- https://faustwave.io/docs/faust-dsp/live-params-and-modulation β€” book: Faust DSP (.dsp) --> # Live params, modulation and observability Every Faust UI element in your source is live twice over: as a knob in the module panel, and as a param path for actions. - `dsp.param.set { module_id, path, value }` β€” push a value into the running worklet. Paths are the literal Faust addresses from `documents.list` (e.g. `/MyDsp/cutoff`). No-op when stopped. - `dsp.param.get { module_id, path }` β€” read the **live, post-modulation** value the knob currently shows. (Poly magic params are the exception β€” see the MIDI section.) ## Being modulated A running DSP's params register as modulation **sinks** β€” any LFO or modulation source in the Patchbay can drive them. Modulation is non-destructive: the engine computes `clamp(base + Ξ£ amount Γ— source)`, so removing an edge restores your base value. Depth lives on the edge (`routing.connect { kind: "modulation", amount }` / `routing.set`). ## Being a modulator Tag a bargraph with the `[modout]` metadata and your DSP becomes a modulation **source** β€” DSP A's envelope follower can wobble DSP B's filter: ```faust process = sig <: _, (an.amp_follower(0.05) : hbargraph("env [modout]", 0, 1) : !); ``` Every `[modout]` output appears as a source port in the Patchbay while the DSP runs. ## Watching without eyes - `documents.list` β€” runtime state of every open DSP (status, compile error, params, `polyphonic`, `midi_channel`). - `system.state { sections: ["master"] }` β€” master meters (read them while sound plays β€” see *Hearing is proof*). - `logs.get { level: "error" }` β€” the renderer log; compile and runtime failures the UI shows land here too. > 🟦 **KB BEFORE CODE**: when writing Faust, query the local knowledge base > first β€” `kb.search { corpus: "faust", query: "..." }` returns the actual > stdfaust signatures from YOUR installed version. Library functions drift; > the local corpus doesn't lie. --- <!-- https://faustwave.io/docs/faust-dsp/sharing-and-hub β€” book: Faust DSP (.dsp) --> # Sharing your work: from assets/ to the Hub A `.dsp` is a text file, which makes it the most shareable artifact in FaustWave. **Publish** a saved DSP as a Hub item: - `hub.publish { file_path, kind: "dsp", slug, title, license, visibility, description?, tags? }` β€” reads the file, validates it, uploads it as v1. `file_path` takes the patch's ref (`asset:assets/<name>.dsp`, from `project.files`) or an absolute OS path for a file outside the project. The Hub bundles the source **plus its library dependencies** (an item page shows `stdfaust.lib` and friends), so installs are self-contained. - Start `visibility: "private"` while iterating; flip to `"public"` later with `hub.item.update { item_id, visibility }` β€” no re-upload needed. - New iterations: `hub.version.upload { item_id, file_path, changelog }` posts v2, v3, … with a changelog trail. **Install** someone's DSP: `hub.install { item_id }` on a `dsp`-kind item opens it straight into a `.dsp` editor tab, dependencies mounted. From there it's yours to run, tweak β€” and `hub.fork` if you want your changes to live as your own item with provenance back to the original. Metadata that makes an item useful to strangers (and to the assistant): - A **description** that says what it sounds like and which channel/notes it expects (`"GM drum map, 36=kick …"`, `"expects [midi:chan 2]"`). - **Tags** for discovery β€” instrument family, genre, technique. - A **license** (SPDX id, e.g. `CC0-1.0` for "take it, it is yours", `CC-BY-4.0` if you want credit) β€” required at publish time. > 🟦 **DSP + PROJECT**: for a complete demo β€” instruments, sequencer > patterns, scenes, mixer setup β€” publish the whole project instead: > `project.export` β†’ `hub.publish { kind: "project" }`. The item restores > as a ready-to-play workspace. Companion `dsp` items still make each > instrument individually findable and forkable. --- <!-- https://faustwave.io/docs/node-pack-authoring/overview β€” book: Node-Pack Authoring --> # Authoring a node-pack A **node-pack** is a JSON manifest (`.nodepack.json`) shipping a set of Builder node-kinds. Install via the Hub (`hub.install kind:"node-pack"`) or bundle with the IDE (`packages/app/build-resources/bundled-node-packs/`). Each pack carries `packId`, `title`, `version`, `ownerHandle`, `description`, and an array of `kinds[]`. A **kind** is one node in the picker: id + label + I/O ports + params + a **`toBox`** field describing how to compile the kind into Faust source. The `toBox` is the heart of the authoring surface β€” it's a tiny declarative DSL the Builder evaluates per node-instance, producing a Box AST that the generator emits as Faust DSL. This pack covers the authoring surface end to end: classification (bundled vs Hub), Box-DSL syntax, per-kind library imports, paired-port stereo, the Sound-Quality Conventions Welle (smoothing / velocity / drift), and the publish workflow. ## Pack classification β€” bundled vs Hub Two tiers exist by design β€” see `docs/NODE-PACK-ARCHITECTURE-ZIEL.md` for the formal classification: | Tier | What it is | Examples | |---|---|---| | **Bundled** (auto-restored) | stdfaust-library wrappers β€” generic Building Blocks. Each kind wraps one `library_prefix.function` from libfaust-wasm's bundled libs. | `faust` (70 kinds), `faust-analysis`, `faust-reverbs`, `faust-physical-models`, `faust-spatial`, `faust-vintage` | | **Hub** | Community-created packs that build on top β€” composed Kinds combining multiple stdfaust primitives + opinionated defaults. | `faust-vocal` (Vocoder, Harmonizer, …) | **Exception:** `faustwave-conventions` stays bundled despite being composed β€” the Coloration Kinds (tape / tube / transistor_drive / bus_glue) + PolyBLEP variants are the **Sound-Quality Welle DNA** of FaustWave, not third-party additions. The exception is the *only* one β€” every other community pack is Hub. The classification matters because it shapes the author's mental model: - **Bundled-pack author**: you're picking a stdfaust function (`os.osc`, `re.jpverb`, `fi.lowpass`) and exposing its parameters as Faust sliders. Minimal `toBox`, generally one or two `lib()` calls. - **Hub-pack author**: you're orchestrating multiple primitives into a domain-meaningful Kind (formant filter from three resonant bandpasses, vocoder from a bank of envelope-followed gains, etc.). Richer `toBox`, often with `defs` for shared subexpressions. ## The seven bundled packs at a glance | Pack | Version | Kinds | What it wraps | |---|---|---:|---| | `faust` | v29 | 70 | The big stdfaust wrapper β€” oscillators (os.*), filters (fi.*), effects (ef.*), envelopes (en.*), math (ma.*, ba.*, si.*), instruments (pm.*) | | `faustwave-conventions` | v3 | 6 | Coloration (`tape`, `tube`, `transistor_drive`, `bus_glue`) + PolyBLEP variants (`saw_polyblep`, `square_polyblep`). The composed exception β€” see above. | | `faust-analysis` | v4 | 6 | `analyzers.lib` (`an.*`) β€” spectrum, FFT, mid-side goniometer, true-peak, Goertzel | | `faust-reverbs` | v3 | 7 | `reverbs.lib` (`re.*`) β€” Schroeder, Zita-Rev1, Dattorro Plate, Spring-Tank, Greyhole + variants | | `faust-spatial` | v2 | 3 | Paired-port stereo Kinds (`mono_to_stereo`, `stereo_widener`, `lush_reverb_stereo`) β€” Phase 4 / channels=2 case study | | `faust-physical-models` | v2 | 5 | `physmodels.lib` (`pm.*`) β€” struck instruments (marimba, bowl, bells, djembe) | | `faust-vintage` | v5 | 46 | `tubes.lib` (18 tube stages) + `tonestacks.lib` (25 amp tonestacks) + `vaeffects.lib` (3 wah pedals) β€” case study for per-kind imports | If you're authoring a Hub pack, **`faust-vocal`** (4 composed vocal kinds) is the reference for the consume-multiple-primitives-into-one-Kind shape; it's not bundled but published on Hub. ## Minimal kind anatomy The smallest kind that compiles + audible is roughly: ```json { "kind": "my_passthrough", "label": "My Passthrough", "category": "Effects", "description": "Audio pass-through. Replace toBox to do something real.", "bypass": { "input": "in", "output": "out" }, "inputs": [{ "id": "in", "label": "in", "kind": "audio" }], "outputs": [{ "id": "out", "label": "out", "kind": "audio" }], "params": [], "toBox": { "outputs": { "out": "input('in')" } } } ``` Drop that kind into a pack, install via `hub.install` (or write to `userData/node-packs/<packId>/manifest.json` and `packs.node.restore` for a bundled pack), and it shows up in the picker under the **Effects** category with a bypass toggle, coloured as an audio node because its output port carries audio. Wire `audio β†’ my_passthrough β†’ output` and you'll hear the input unchanged. Every other field on a kind β€” `params`, `defs`, `imports`, `drift_param`, `bodyStyle`, paired-port `channels: 2` β€” is additive on top of this minimum. The next section walks the Box-DSL syntax that turns `toBox` from a passthrough into anything Faust can express. --- <!-- https://faustwave.io/docs/node-pack-authoring/box-dsl β€” book: Node-Pack Authoring --> # The Box-DSL β€” `toBox` syntax The Box-DSL is a tiny declarative expression language pack authors write inside `toBox.outputs[port]` (and optionally `toBox.defs[name]`) strings. The Builder parses each expression at compile time, evaluates it against a substitution env (params + inputs + defs), and produces a **Box AST** that the Faust generator emits as DSL source. Same primitive the Builder uses internally β€” your pack composes Box ASTs the same way `faustGen` composes graphs. ## Why Box-DSL The pre-Phase-4 surface used `{{params.X}}` / `{{inputs.Y}}` template strings β€” pure substitution into hand-written Faust strings. That's gone. Box-DSL replaced it because: - **Box ASTs compose**. Wrapping a kind's expression in another transform (Sound-Quality Welle's drift / multi-voice / smoothing) means walking + rewriting structure, not regexing template strings. - **The same primitives the graph-level generator uses**. `faustGen.ts` itself emits Box ASTs (`seq`, `par`, `withDefs`, `parLoop`) for FX-shape graphs + multi-voice unison. Pack authors and generator authors speak the same DSL. - **Arity is checkable**. Every Box carries `inputs` / `outputs` counts β€” wire mismatches surface as build-time errors instead of opaque libfaust diagnostics. Migration is complete: no remaining string-template Kinds in the bundled packs as of v0.64.x. ## The constructors A `toBox` expression is a tree of these constructors. Whitespace + newlines are ignored β€” write them inline or pretty-print across lines. ### Leaves | Constructor | Emits | Arity | Notes | |---|---|---|---| | `input('id')` | The input port's upstream wire expression, or `0` if unconnected | (0, 1) | Match the `id` field of an entry in `inputs[]` | | `param('id')` | The param's resolved Faust source (hslider with smoothing / velocity / drift wraps applied) | (0, 1) | Match the `id` field of an entry in `params[]` | | `def('name')` | A previously-declared `defs[name]` Box, in declaration order | (0, 1+) | Forward refs are an error | | `prim('symbol', args...)` | A bare Faust function call: `symbol(a, b, …)` | depends | For primitives not aliased in stdfaust (e.g. `T1_12AX7` from tubes.lib) | | `real(0.5)` / `int(2)` | A literal number | (0, 1) | Use `int` for indices, `real` for everything else | | `wire()` | The identity wire `_` | (1, 1) | For `par(wire(), …)` route-passthrough patterns | | `cut()` | The cut wire `!` | (1, 0) | For dropping unused parallel branches | | `mem` and `mem(box)` | Faust `mem` (one-sample memory) | (1, 1) | Used in feedback loops; see ping_pong_delay | ### Composers | Constructor | Emits | Notes | |---|---|---| | `seq(a, b, …)` | `a : b : …` (sequential composition) | Output arity of left must match input arity of right | | `par(a, b, …)` | `a, b, …` (parallel composition) | Independent inputs + outputs | | `parLoop('i', N, body)` | `par(i, N, body)` β€” bind `i` to 0..N-1 | Used by faustGen for unison multi-voice | | `withDefs([{name, body}], outer)` | `outer with { name = body; … };` | Local definitions inside an expression | | `merge(a, b)` | `a :> b` (multi-source merge) | Sum N-out into M-in | | `split(a, b)` | `a <: b` (split + fan-out) | Fan one signal to many | ### Library calls | Constructor | Emits | Notes | |---|---|---| | `lib('ns', 'fn', args...)` | `ns.fn(args)` | `ns` is a stdfaust alias (`os`, `fi`, `re`, `ef`, `pm`, `an`, `ma`, `ba`, `si`, `de`, `en`, `no`) β€” they're declared in stdfaust.lib | | `prim('fn', args...)` | `fn(args)` (bare name) | For libraries NOT auto-imported by stdfaust β€” declare `imports: ["xyz.lib"]` on the kind so faustGen emits the explicit `import("xyz.lib")` (see Β§ 3) | ### Arithmetic + comparators Available as constructors for cleaner reading vs `prim('+')` / `prim('*')`: `plus(a, b)`, `minus(a, b)`, `times(a, b)`, `divide(a, b)`, `over(a, b)` (same as divide), `intCast(box)`, `realCast(box)`. These compose like any other Box and respect arity. `times(param('mix'), input('in'))` reads as `mix * in`. ## Worked examples β€” from the bundled packs ### 1. Lowpass filter β€” one library call, one param ```json { "kind": "lp", "inputs": [ { "id": "in", "label": "in", "kind": "audio" }, { "id": "cutoff_in", "label": "cutoff", "kind": "control" } ], "outputs": [{ "id": "out", "label": "out", "kind": "audio" }], "params": [ { "type": "hslider", "id": "cutoff", "label": "Cutoff", "init": 1000, "min": 20, "max": 20000, "step": 1, "unit": "Hz", "scale": "log", "style": "knob", "smoothing_class": "cutoff", "velocity_responds": 0.3 } ], "toBox": { "outputs": { "out": "lib('fi', 'lowpass', int(2), param('cutoff'), input('in'))" } } } ``` Emits roughly: `fi.lowpass(2, (cutoff_hslider : si.smooth(...) : <velocity-wrap>), in)`. The `smoothing_class: "cutoff"` + `velocity_responds: 0.3` wraps automatically β€” see Β§ 4. ### 2. Vintage tube stage β€” bare-name primitive + imports ```json { "kind": "tube_12ax7_t1", "imports": ["tubes.lib"], "inputs": [{ "id": "in", "label": "in", "kind": "audio" }], "outputs": [{ "id": "out", "label": "out", "kind": "audio" }], "params": [], "toBox": { "outputs": { "out": "prim('T1_12AX7', input('in'))" } } } ``` `prim` (not `lib`) because `T1_12AX7` lives in `tubes.lib`, which isn't aliased by stdfaust. The `imports: ["tubes.lib"]` field tells `faustGen` to emit `import("tubes.lib");` in the DSP header so the bare name resolves. See Β§ 3 for the plumbing. ### 3. Ping-pong delay β€” shared `defs` + per-channel `wet_l` / `wet_r` ```json { "kind": "ping_pong_delay", "inputs": [ { "id": "in_l", "label": "L", "kind": "audio", "channels": 2 }, { "id": "in_r", "label": "R", "kind": "audio", "channels": 2 } ], "outputs": [ { "id": "out_l", "label": "L", "kind": "audio", "channels": 2 }, { "id": "out_r", "label": "R", "kind": "audio", "channels": 2 } ], "params": [ { "type": "hslider", "id": "time_ms", "label": "Time", "init": 250, "min": 1, "max": 2000, "step": 0.1, "unit": "ms", "style": "knob" }, { "type": "hslider", "id": "feedback", "label": "Feedback", "init": 0.4, "min": 0, "max": 0.95, "step": 0.001, "style": "knob", "smoothing_class": "mix" }, { "type": "hslider", "id": "mix", "label": "Mix", "init": 0.4, "min": 0, "max": 1, "step": 0.001, "style": "knob", "smoothing_class": "mix" } ], "toBox": { "defs": { "samples": "intCast(over(times(param('time_ms'), lib('ma', 'SR')), int(1000)))", "wet_l": "seq(plus(input('in_l'), times(mem(input('in_r')), param('feedback'))), lib('de', 'delay', int(96000), def('samples')))", "wet_r": "seq(plus(input('in_r'), times(mem(input('in_l')), param('feedback'))), lib('de', 'delay', int(96000), def('samples')))" }, "outputs": { "out_l": "plus(times(input('in_l'), minus(real(1), param('mix'))), times(def('wet_l'), param('mix')))", "out_r": "plus(times(input('in_r'), minus(real(1), param('mix'))), times(def('wet_r'), param('mix')))" } } } ``` `defs.samples` resolves first (referenced by both `wet_l` + `wet_r`), then the two wet paths, then the dry+wet mix per output. Notice `mem(input('in_r'))` in `wet_l` β€” that's the ping-pong feedback: L's delay reads from the previous-sample R, R reads from previous L. The `channels: 2` on every port + every paired pair flags this as a stereo-bundle Kind (see Β§ 3). ## Substitution semantics β€” what fires when When the Builder compiles a kind instance, the adapter (`packKindAdapter.ts`) walks `toBox`: 1. Resolve `params` β€” each `ParamDef` becomes a Faust source string with the Sound-Quality wraps applied (smoothing β†’ velocity β†’ drift in that order). Wrapped in a `raw` Box. 2. Resolve `inputs` β€” each input port becomes either the upstream's emitted expression (if wired) or the literal `0` (if unconnected). 3. Walk `defs` in declaration order β€” each def's expression parses against `{ params, inputs, defs-so-far, literals, faustId }`. Later defs can reference earlier ones via `def('name')`. 4. Walk `outputs` β€” same env, plus all defs in scope. 5. Each output's Box becomes the value for its port id in the kind's `toBox(ctx)` return. The Builder's `faustGen` then plugs the per-kind output Boxes into the graph's wire structure, wraps multi-voice / FX-shape / vgroup as needed, and emits the final Faust source. ## Common patterns - **Mono effect**: `seq(input('in'), lib(...))`. The classic shape β€” take input, transform, emit. - **Param-controlled lib call**: `lib('fi', 'lowpass', int(2), param('cutoff'), input('in'))`. Standard 2nd-order Butterworth. - **Mix dry/wet**: `plus(times(input('in'), minus(real(1), param('mix'))), times(def('wet'), param('mix')))`. The 1βˆ’mix on dry pattern, with the wet path in a `def` for reuse. - **Feedback line**: `mem(...)` wraps a one-sample memory; combine with `plus` inside a `seq` for delay lines + comb filters. - **Stereo splitter**: `par(input('in_l'), input('in_r'))`. Brings paired ports into a single `(2, 2)` Box for chaining through a stereo lib like `re.jpverb`. > πŸ”˜ **Tip**: when stuck, look at how the bundled packs do it. `packages/app/build-resources/bundled-node-packs/*.nodepack.json` are all Box-DSL β€” every category of `toBox` you'd want to write has a precedent there. --- <!-- https://faustwave.io/docs/node-pack-authoring/imports-and-paired-port β€” book: Node-Pack Authoring --> # Per-kind imports + paired-port stereo Two declarative metadata fields on a kind extend `toBox` with the surrounding-environment bookkeeping the generator needs: - **`imports?: string[]`** β€” Faust libraries the kind's `toBox` template depends on, beyond `stdfaust.lib`. - **`channels: 2`** on ports β€” marks paired-port stereo (one L + one R port forming a single stereo signal). Both shipped post-Phase-4 (PR #303 + #284 respectively). Together they let packs use libraries beyond stdfaust + carry stereo signals through the graph without folding to mono at every hop. ## Per-kind imports Some libfaust-wasm bundled libraries aren't aliased by `stdfaust.lib`. The big three packs hit on: - **`tubes.lib`** β€” vacuum-tube triode/pentode stage emulations (`T1_12AX7`, `T2_12AX7`, etc. β€” symbols bare, no namespace prefix) - **`tonestacks.lib`** β€” guitar/bass amp tonestacks (`bassman`, `jcm800`, `vox_ac30`, etc.) - **`vaeffects.lib`** β€” VA-modelled effects (`crybaby`, `autowah`) `faust-vintage` ships 46 kinds across all three. Without imports plumbing, the only resolution path was to add `import("tubes.lib");` etc. to every DSP by hand β€” fragile + per-DSP. The **`imports` field** lets a kind declare its dependency: ```json { "kind": "tube_12ax7_t1", "imports": ["tubes.lib"], "toBox": { "outputs": { "out": "prim('T1_12AX7', input('in'))" } } } ``` The Builder's `faustGen` walks all node-kinds in the active graph, collects the **union** of every visited kind's `imports`, dedupes + sorts, and emits one `import("<name>");` line per name in the DSP header right after the default `import("stdfaust.lib");`: ```faust // Generated from graph β€” do not edit by hand for now. import("stdfaust.lib"); import("tonestacks.lib"); import("tubes.lib"); import("vaeffects.lib"); process(in_l, in_r) = … ``` (Sorted alphabetically for diff-stable output across runs and snapshot tests.) The bare names `T1_12AX7`, `jcm800`, `crybaby` resolve at the DSP's symbol scope. ### Rules - Each entry must end in `.lib`. - Names must be resolvable by libfaust β€” either bundled in libfaust-wasm (the three above + `analyzers.lib` / `physmodels.lib` / `quantizers.lib` / etc., all stdfaust-aliased so they don't need `imports`) or mounted via `library.mount` (user-shipped `.lib` files). - `stdfaust.lib` is always emitted by the header β€” listing it explicitly is a no-op. - Skip the field entirely (or `[]`) for kinds whose `toBox` only touches stdfaust-aliased symbols. Most kinds in the `faust` / `faustwave-conventions` / `faust-analysis` / `faust-reverbs` / `faust-physical-models` / `faust-spatial` packs have no `imports` field. ### Shipping your own `.lib` files Two paths: 1. **`faustLibs` on the pack manifest** β€” ships inline `.lib` source bytes that the install flow auto-mounts into libfaust's VFS. Use when your pack needs a library NOT already in libfaust-wasm: ```json { "packId": "my-pack", "faustLibs": [{ "name": "mycustom.lib", "source": "<full .lib source as string>" }], "kinds": [ … ] } ``` The pack install flow calls `library.mount("mycustom.lib", source)` automatically. Subsequent compiles resolve `import("mycustom.lib")` against your inline source. 2. **User-mounted `.lib`** β€” for libs the user owns directly (Hub Library install / FaustLibrariesPanel mount). Same effect; the pack's `imports` field works the same way against any name in the VFS, regardless of how it got mounted. ## Paired-port stereo Phase 4 (PR #284) introduced the **paired-port stereo** pattern. Stereo signals carry as two adjacent ports flagged with `channels: 2` (a marker, not a literal count β€” the L + R partner is implicit from the next port in declaration order). ### The shape ```json { "inputs": [ { "id": "in_l", "label": "L", "kind": "audio", "channels": 2 }, { "id": "in_r", "label": "R", "kind": "audio", "channels": 2 } ], "outputs": [ { "id": "out_l", "label": "L", "kind": "audio", "channels": 2 }, { "id": "out_r", "label": "R", "kind": "audio", "channels": 2 } ] } ``` Each port keeps its own `id` (so `input('in_l')` + `input('in_r')` in the Box-DSL works exactly like the mono case), but the `channels: 2` marker tells the rest of the system: - **Builder UI**: the dot rendering switches to the white-ring variant (same diameter, thin outer ring) and the cable stroke-width doubles when both halves are wired. - **Wire validator**: `isPairedHalfPort()` lets the validator surface a "wire the partner too" hint when only one half of a paired pair is connected, vs. plain mono cable. - **`faustGen`**: dead-port-def gating already skips emit for ports nothing consumes; for paired ports it tracks both halves independently so a stereo path that only uses L stays mono in the emitted source. ### When to use paired-port vs two separate mono ports | Use paired-port (`channels: 2`) | Use two mono ports | |---|---| | The signal is logically **one stereo entity** that should travel as a unit (e.g. a reverb tail, a panned synth's output) | The kind has two **independent mono inputs** (e.g. a mixer with two unrelated sources, a sidechain compressor's main + sidechain) | | Wrong to use only one half | Each half is meaningful on its own | | The kind's `toBox` likely calls a `(2, 2)` lib function (`re.jpverb`, `dattorro_rev_default`, …) | Each port goes to a different leaf in the Box AST | `faust-spatial`'s `mono_to_stereo` is the canonical bridge: ONE mono input, TWO `channels: 2` paired outputs. Use it (or a custom variant) to enter the stereo regime from a mono chain. ### Worked example β€” Lush Reverb (Stereo) ```json { "kind": "lush_reverb_stereo", "inputs": [ { "id": "in_l", "label": "L", "kind": "audio", "channels": 2 }, { "id": "in_r", "label": "R", "kind": "audio", "channels": 2 } ], "outputs": [ { "id": "out_l", "label": "L", "kind": "audio", "channels": 2 }, { "id": "out_r", "label": "R", "kind": "audio", "channels": 2 } ], "toBox": { "defs": { "jp_pair": "seq(par(input('in_l'), input('in_r')), lib('re', 'jpverb', param('t60'), param('damp'), param('size'), real(0.6), real(0.1), real(2.0), real(1.0), real(1.0), real(1.0), int(200), int(6000)))", "jp_l": "seq(def('jp_pair'), par(wire(), cut()))", "jp_r": "seq(def('jp_pair'), par(cut(), wire()))" }, "outputs": { "out_l": "plus(times(input('in_l'), minus(real(1), param('mix'))), times(def('jp_l'), param('mix')))", "out_r": "plus(times(input('in_r'), minus(real(1), param('mix'))), times(def('jp_r'), param('mix')))" } } } ``` `re.jpverb` is intrinsically `(2, 2)` β€” it takes paired stereo in + emits paired stereo out. `par(input('in_l'), input('in_r'))` collects the two mono Boxes into one `(0, 2)` Box; `seq(…, lib('re', 'jpverb', …))` chains them into the reverb. Then `par(wire(), cut())` peels off only L, `par(cut(), wire())` peels off only R, so the per-output dry/wet mix can route per-channel. ## Cross-cutting consequences Once you mark paired ports with `channels: 2`, the surrounding system honours them: - The **picker** + Inspector show paired-port kinds with the stereo-cable hint. - The **wire validator** refuses cable patterns that violate paired-port semantics (e.g. crossing L into R mid-pair). - The **`faust-spatial` pack** is the bundled case study β€” three kinds (`mono_to_stereo`, `stereo_widener`, `stereo_panner` etc.) that demonstrate the pattern end-to-end. Read it as a template. A kind that's stereo internally but exposes a `(1, 2)` shape (one mono in β†’ two mono outs) is **NOT** paired-port. Use `channels: 2` only when both halves are logically a unit. --- <!-- https://faustwave.io/docs/node-pack-authoring/sound-quality-conventions β€” book: Node-Pack Authoring --> # Sound-Quality Conventions for pack authors The Sound-Quality Conventions Welle (PR #196, v0.51.0) shipped a set of orthogonal Faust DSP conventions: param-level smoothing, velocity-responsive scaling, multi-voice unison with per-voice drift, and kind-level Coloration (tape / tube / transistor_drive / bus_glue). For graph authors they're knobs; for **pack authors** they're declarative metadata fields on a kind that auto-wrap your `toBox`. This section is the auto-wrap reference. Most kinds use 0–2 of these conventions and skip the rest. ## Per-param smoothing β€” `smoothing_class` Add `smoothing_class: 'volume' | 'cutoff' | 'pitch' | 'drive' | 'mix'` to any `ParamDef`. The param's resolved expression gets wrapped in `si.smooth(ba.tau2pole(Ο„))` with a class-appropriate Ο„: | Class | Ο„ | When to use | |---|---|---| | `volume` | 0.005 s | Per-cycle gain changes β€” Pan, Gain, Send level | | `cutoff` | 0.03 s | Filter cutoffs, env-mod amounts β€” bigger jumps | | `pitch` | 0.001 s | Detune, transpose β€” perceptually quick | | `drive` | 0.05 s | Saturator drive, distortion drive β€” perceptually slow | | `mix` | 0.03 s | Dry/wet mix, feedback amount | The wrap is invisible to your `toBox` β€” `param('cutoff')` in a smoothing-classed kind already emits the smoothed value. You don't write the `si.smooth(...)` call yourself. Example: the `lp` kind sets `smoothing_class: "cutoff"` on its cutoff slider; the picker preview + the engine both apply 0.03 s tau without further configuration. ## Velocity-responsive params β€” `velocity_responds` Add `velocity_responds: <0..1>` to any `ParamDef`. The param's resolved expression gets multiplied by `1 + (velocity - 1) * velocity_responds`, where `velocity` is the per-voice MIDI velocity (1.0 = no scaling, 127/127 in normalised form). - `velocity_responds: 0` (default) β€” no velocity scaling. The param is independent of how hard the key was struck. - `velocity_responds: 1` β€” fully velocity-controlled. Hard hit = full param value, soft hit = nothing. - `velocity_responds: 0.3` (typical) β€” softer hits darken / dampen the param by ~30%, hard hits hit full value. Common on filter cutoffs and effect mix amounts. The wrap sits **inside** the smoothing wrap if both are set (smoothing applies after velocity-scaling, so quick velocity-driven ramps still smooth). Example: the `lp` kind sets `velocity_responds: 0.3` on its cutoff so a soft note lands at ~70% cutoff, a hard note at full β€” a classic "darken at low velocity" feel. ## Drift wrap β€” `drift_param` Add `drift_param: 'detune' | 'cutoff' | 'none'` at the **kind level** (not per-param). When the Output node enables Unison (voices > 1) AND its `drift_amount` is non-zero, the named param gets a slow per-voice random offset injected β€” different per voice, slow rate (~5 Hz LFO range), no audible chorus but breaks the "all voices identical" sterility. - `'detune'` β€” the wrap shifts the param by Β±N cents based on the drift LFO. Useful on `osc`, `saw_polyblep`, etc. - `'cutoff'` β€” the wrap shifts the param by Β±N% based on the drift LFO. Useful on `lp`, `hp`, `moog_vcf`. - `'none'` (default) β€” no drift wrap. Kind plays identical across voices. The wrap only activates when the DSP is poly + the Output node's drift_amount > 0 β€” single-voice DSPs see no effect. ## Multi-voice unison β€” kind-level support The Output node's Unison config (voices / detune_cents / stereo_spread) is consumed at the graph-level by `faustGen` (it wraps the whole DSP in `par(i, N, voice(i))`). Individual kinds don't usually need to participate β€” but if your kind cares about per-voice replication (e.g. you want different RNG seeds per voice), the substitution env exposes: - `polyMode: boolean` β€” true when the DSP is poly. - `voiceIndex: Box` β€” the loop iteration variable, a `(0, 1)` Box that emits `i` literally. Use via the `defs` clause to make the i-reference explicit: ```json "defs": { "voice_seed": "param('seed') * (voiceIndex + real(1))" }, "outputs": { "out": "lib('no', 'noise', def('voice_seed'))" } ``` (Hypothetical β€” most kinds don't need this. Look at `midi_note` in the `faust` pack for the canonical real example: detune-shift via the par-bound `i` symbol.) ## Coloration Kinds β€” the `faustwave-conventions` pack The Coloration suite (`tape`, `tube`, `transistor_drive`, `bus_glue`) lives in `faustwave-conventions` rather than the `faust` pack because they're **opinionated** β€” they're not stdfaust wrappers, they're FaustWave's specific recipes for warmth + cohesion. If your pack wants to surface a custom saturator, follow this template: - Single audio in, single audio out (`(1, 1)` shape). - One or two params, all with `smoothing_class: 'drive'` or `'mix'`. - A clear sonic identity β€” don't ship "yet another tanh". - Pack id starts with your handle prefix if Hub (`mypack-`); the `faustwave-` prefix is reserved for FaustWave-team packs. Coloration Kinds ARE composed (multiple stdfaust primitives combined), but they're the **exception** to the bundled-vs-Hub rule β€” see Β§ 1 on classification. ## Picker bucket β€” `category` The `category` field maps your kind to a picker section. Canonical buckets that the picker shows top-row Quick-Buttons for: `Sources`, `Oscillators`, `Instruments`, `Modulation`, `Filters`, `Effects`, `Math`, `Time`, `Output`, `Analyzers`, `Spatial`, `Triggers`, `Vintage`, `Vocal`. Anything outside that list still works β€” it lands alphabetically after the canonical set. But if your kind fits a canonical bucket, use it; if it's genuinely a new domain, mint a new category + own it as a pack convention. ## Visual accent β€” nothing to declare There is no `accent` field. A kind's colour is **derived from its ports**: the node's dot and frame take the signal kind of its first output β€” or, for a sink with no outputs, its first input. | Port signal | Node colour | |---|---| | `audio` | the audio signal colour β€” the same one its cables are drawn in | | `control` | the control signal colour | | `trigger` | the trigger signal colour | That is the whole rule, and it exists because the old one could not hold. `accent` was a six-colour palette you picked by hand, documented right here as "also the colour of any cable plugged into the kind's audio out" β€” two facts kept in sync by convention. They drifted apart the moment audio got a signal colour of its own: cables moved, hand-declared accents did not, and a node's dot said one thing while the cable leaving it said another. Declaring the colour separately from the signal is now impossible, so it cannot drift again. Packs that still carry an `accent` field keep loading β€” the value is ignored. ## Putting it together β€” a saturated lowpass ```json { "kind": "saturated_lp", "label": "Saturated Lowpass", "category": "Filters", "description": "2nd-order lowpass with a tanh drive stage in front β€” vintage tube preamp + tone-shaping in one node.", "drift_param": "cutoff", "bypass": { "input": "in", "output": "out" }, "inputs": [ { "id": "in", "label": "in", "kind": "audio" }, { "id": "cutoff_in", "label": "cutoff", "kind": "control" } ], "outputs": [{ "id": "out", "label": "out", "kind": "audio" }], "params": [ { "type": "hslider", "id": "drive", "label": "Drive", "init": 1.0, "min": 1, "max": 4, "step": 0.01, "style": "knob", "smoothing_class": "drive" }, { "type": "hslider", "id": "cutoff", "label": "Cutoff", "init": 1000, "min": 20, "max": 20000, "step": 1, "unit": "Hz", "scale": "log", "style": "knob", "smoothing_class": "cutoff", "velocity_responds": 0.3 } ], "toBox": { "defs": { "driven": "lib('ma', 'tanh', times(input('in'), param('drive')))" }, "outputs": { "out": "lib('fi', 'lowpass', int(2), param('cutoff'), def('driven'))" } } } ``` Reads top-down: drive is smoothed (5 ms drive class), cutoff is smoothed + velocity-scaled + drift-wrapped (in poly + unison), input runs through tanh-drive, then through 2nd-order lowpass at the smoothed cutoff. All five Sound-Quality conventions composed without a single `si.smooth()` or velocity-multiply in the `toBox` β€” the metadata fields do the work. --- <!-- https://faustwave.io/docs/node-pack-authoring/publishing β€” book: Node-Pack Authoring --> # Publishing a node-pack Once your `.nodepack.json` validates locally (every kind shows in the picker + compiles cleanly), publish to the Hub for others to install. Two paths β€” local-test then publish, or smoke-test in a fresh project first. ## Local test loop The fastest iteration loop bypasses the Hub entirely: ``` 1. packs.node.install { manifest: <your full .nodepack.json object> } 2. packs.list { kind: "node" } β†’ confirm packId, isBundled:false 3. documents.add { type: "builder", title: "Pack test" } β†’ module_id 4. builder.graph.node.add { module_id, kind: "<your_kind_id>" } β†’ node id 5. builder.graph.node.add { module_id, kind: "output" } β†’ out id 6. builder.graph.connect { module_id, source_node_id: <yours>, target_node_id: <out>, target_port: "in_l" } 7. dsp.compile { module_id } 8. builder.source.get { module_id } β†’ verify your toBox emits what you expect ``` (Not a click-chain β€” steps 1, 4 and 6 need YOUR pack's manifest + kind ids; ask the assistant to run the loop against your file.) If compile fails, the error string includes the libfaust diagnostic β€” usually a missing param ref, an arity mismatch, or a typo in a `lib()` call. Fix the `.nodepack.json`, `packs.node.install` again (writes overwrite atomic), retry. ## Publishing to the Hub When the local loop is happy: ``` hub.publish { kind: "node-pack", file_path: "asset:assets/my-pack.nodepack.json", slug: "my-pack", title: "My Pack β€” short tagline", description: "Longer description for the Hub item page." } ``` The Hub backend's `NodePackInspector` validates the manifest server-side + extracts derived metadata (kindCount, packId, ownerHandle). On success you get back the item's `id` β€” record it for follow-up version uploads. ### Subsequent versions ``` hub.version.upload { kind: "node-pack", item_id: "<id from the publish response>", file_path: "asset:assets/my-pack-v2.nodepack.json" } ``` The Hub computes the new version number automatically (existing-version + 1). The local pack store refuses to downgrade (incoming version must be β‰₯ installed) β€” bump the `version` field in the manifest on every upload. ### Forking + iterating Hub items can be forked: `hub.fork { item_id }` creates a copy in your namespace. Useful for "I want to base my pack on `faust-vocal` but add a new vocal effect" β€” fork, edit, publish under your handle. ## Kb-pack-style packs as a doc surface If your node-pack covers a meaningful domain (a vocal effects suite, a granular synth toolkit, a vintage gear bundle), pair it with a **book** documenting the kinds. The book lives separately on the Hub (`hub.publish kind:"book"`) but Users find both when searching for the domain. The Book Reader module's tutorial blocks can link to your kinds with `companionPatches` references + assistant hints. See **FaustWave β€” Hub** Β§3 for the publish flow. ## Bundling for FaustWave team packs If you're shipping a FaustWave-team pack that should auto-restore on launch: 1. Drop the `.nodepack.json` in `packages/app/build-resources/bundled-node-packs/`. 2. Add the packId to `BUNDLED_PACK_IDS` in `packages/app/electron/node-pack-store.ts`. 3. Bump the `version` field β€” `restoreBundledPacks` compares against installed and only re-writes when the bundled file is newer. Community packs DON'T go in `BUNDLED_PACK_IDS`. They stay Hub-only β€” users install them on demand. ## Common rejection paths The validator + adapter at install time check for the most common authoring traps. If any fail, the install rejects + emits a per-line error in Logs: - **Unknown port `id` referenced from `input('xyz')`** β€” the input port `xyz` doesn't exist in `inputs[]`. - **Unknown param `id` referenced from `param('xyz')`** β€” same for params. - **Forward def ref** β€” `def('A')` referenced before `defs.A` is declared (defs resolve in declaration order). - **Arity mismatch in `seq(a, b)`** β€” `a`'s output arity != `b`'s input arity. - **Missing `toBox.outputs.<port>`** β€” every output port needs a key. Sinks (zero outputs) use the reserved `__process` key. - **Bare-name kind not in scope** β€” `prim('XYZ', …)` where `XYZ` isn't in stdfaust + the kind doesn't list the relevant library in `imports`. Add the lib to the kind's `imports` field. - **`imports` entry not ending in `.lib`** β€” the validator rejects entries that don't end in the `.lib` suffix. ## Where to read more - **Source of truth** β€” `packages/extension-dsp-builder/src/lib/packKindData.ts` carries the `NodeKindData` validator + the `validateNodeKindData()` function that runs at install time. The validator's error strings are precise; read it when in doubt. - **Adapter** β€” `packKindAdapter.ts` walks `toBox` and produces the runtime `NodeKindDef`. Read it to understand the evaluation order + the substitution env. - **Examples** β€” every file under `packages/app/build-resources/bundled-node-packs/` is real production code. Look at the simpler ones first (`faust-physical-models`, `faust-spatial`) for clean templates. - **Architecture** β€” `docs/NODE-PACK-ARCHITECTURE-ZIEL.md` for the bundled-vs-Hub classification and the long-term direction. --- <!-- https://faustwave.io/docs/sequencer/sequencer β€” book: Sequencer --> # The grid + the master clock The Sequencer is a **multi-track step grid** β€” piano-roll horizontally, one row per pitch, one column per step β€” driven by a shared **Faust master clock** so every track + every other sequencer + the topbar Bar.Beat readout all advance in audio-sample lockstep. No drift, no jitter, no JS timers. ```yaml actions: - action_id: surface.open input: { surface: sequencer-default#roll } label: "β–Ά Open the Sequencer panel" ``` This section is the foundation: how the grid is laid out, what makes the clock special, and how the scale-aware highlight works. The next section covers actually painting notes; the one after that covers patterns + scenes; the last covers driving everything from MCP. ## Anatomy of the panel Top to bottom: - **Chip row** β€” one chip per track. Each chip shows the track name, MIDI channel, mute / solo, pattern launcher (A / B / C / …), visibility toggle. Right-click a chip for the track context menu (rename, mute, solo, suggest progression, delete). - **Grid** β€” the multi-track unified piano-roll. Pitch rows on the left gutter, step columns running right. All tracks render simultaneously; the active track's notes appear filled, foreign tracks appear as outlined rings, colour-coded by track index via the `--seq-track-N` CSS variables. - **Footer** β€” edit-tool toggle (Paint / Select), Suggest-Progression button, Chord Palette, Loop toggle, Play / Stop. The chip row + grid are the **Sequencer** surface (the piano-roll). Launching lives on its own **Patterns** surface β€” track cards, enlarged pattern pads, direct mute/solo, scenes. They are two views of the same device, so you can have both open at once (roll in the centre, pads in the narrow column) instead of switching. Open one with `surface.open { surface: "faustwave-bundled/sequencer#patterns" }`, or the **PATTERNS** button in the roll's header. There is no `perform` mode anymore β€” it existed because the old rail could show a single panel at a time. ## The shared Faust master clock Critical conceptual point: **there is exactly one clock for every sequencer instance, the topbar Bar.Beat display, and every Builder `clock` node with transport-link ON**. It's a Faust DSP worklet (`seq-clock.dsp`) running at audio-thread priority. The clock emits a global 16th-step index `0..63` on every step boundary. Each sequencer maps that to its own pattern via `step64 % stepsPerPattern` β€” since 8 / 16 / 32 / 64 all divide 64, every supported pattern length stays phase-aligned to the same grid. Sample-locked, drift-free, no JS timers anywhere. The clock runs ⇔ the global transport is playing. A stop β†’ play transition resets to step 0; a sequencer instantiated mid-play joins the live phase without restarting. The clock's BPM source is the global transport β€” and the transport itself can **follow an external MIDI clock** (hardware sequencer, another DAW): route the clock-sending MIDI input onto the **Transport Clock In** sink in the Patchbay and the whole grid rides the external tempo, with an **EXT** tag next to the topbar BPM while it's active. See **FaustWave β€” Patchbay & Routing** Β§ *External clock sync*. > 🟦 **WHY THE FAUST CLOCK?** Renderer-thread JS clocks accumulate jitter under DevTools load, GC pauses, and OS scheduling. The audio thread doesn't. By driving everything from one Faust counter, multi-track patterns can't drift relative to each other, the topbar Bar.Beat counter is sample-accurate, and the cursor + audio dispatch share ONE callback (no two-clocks-diverging problem). The rule is: WASM-first for any timing-sensitive work; the JS renderer is the second choice. ## Pattern length A pattern is **8 / 16 / 32 / 64 steps**. Default 16 (one bar at 4/4 with 16th-note resolution). Set via the footer pattern-length button or: > πŸ”˜ **MCP**: `sequencer.pattern.length.set { length: 8 | 16 | 32 | 64 }`. - **Grow** (e.g. 16 β†’ 32): existing content duplicates cyclically into the new range β€” your 16-step phrase becomes a 32-step phrase that repeats twice, ready for you to vary the second half. - **Shrink** (e.g. 32 β†’ 16): trailing steps are hidden, NOT deleted β€” re-growing brings them back. The wrap point updates live; mid-play changes take effect at the next pattern boundary, so a length switch never strands a partial step. ## Multi-track grid Add a track via the chip row's `+ Track` button or: > πŸ”˜ **MCP**: `sequencer.track.add { name? }`. Returns `{ ok, track_id }`. Each track has: - A **MIDI channel** (0 = Omni, 1–16 = specific) β€” routes notes to receivers that listen on that channel. Set with `sequencer.track.midi-channel.set { track_id, channel }`. - A **colour** β€” auto-assigned from the `--seq-track-N` palette, wraps after 6. - An **enabled** flag (the visibility eye on the chip) β€” see `sequencer.track.enabled.set`. - A **soloed** flag β€” see `sequencer.track.soloed.set`. Solo wins over un-muted: any soloed track silences all un-soloed. - A **bank of patterns** (A / B / … up to 8) β€” covered in Β§ 3. All tracks render simultaneously in the grid. **Keyboard 1..6** switches the active track; clicks land on the active track. The active track is also the disambiguation target for legacy single-track step commands. ## Scale-aware highlighting Pick a **key** (tonic + scale) via the key/scale picker. Available scale modes: - Ionian (= Major), Dorian, Phrygian, Lydian, Mixolydian, Aeolian (= Natural Minor), Locrian. - Harmonic Minor, Melodic Minor. - Pentatonic Major, Pentatonic Minor. - Blues. In-scale rows highlight in cyan; out-of-scale rows dim. The tonic row carries the strongest anchor edge. You can still paint on out-of-scale rows β€” the highlight is a visual hint, not a hard constraint. > πŸ”˜ **MCP**: `sequencer.key.set { tonic_name: "A" | "A#" | ... | "G#", scale_id: "aeolian" | "ionian" | ... }`. You can also pass `tonic_pc` (0..11) instead of the name. Omit both to keep the current tonic and only change the scale. > 🟦 **NEW TO SCALES?** A scale is a set of notes relative to a tonic. C major = C D E F G A B (the white keys). A minor = A B C D E F G (also white keys, but the tonic is A). Painting only on highlighted rows is the safest way to write "in key" β€” every step you place sits musically inside the scale. The key picker is what flips that highlight; nothing forces you to obey it. The KB's `theory` corpus carries scale + chord references the assistant can search; ask the Assistant *"what's a good progression in A minor"* and you'll get suggestions grounded in the theory pack. ## Cursor + per-track pulse While playing, the grid shows two layers of motion: - **Cursor** β€” single vertical overlay tracking the current step. Moves via `translateX` (no per-cell re-render). - **Pulse** β€” when a step fires, its cell briefly glows. The pulse reads from a non-reactive buffer that the audio-block JS task writes to, flushed to Svelte reactivity at `requestAnimationFrame` cadence β€” so audio-thread work never triggers UI re-renders. Cursor + audio dispatch share a single `setOutputParamHandler` callback. There is no possibility of "cursor and audio drift apart" because they're the same event. ## Multiple sequencer instances Every sequencer is a routing-table Source named `sequencer-<id>` (the default one is `sequencer-default`). Multiple instances run independently and concurrently: - Drum sequencer + harmony sequencer + bass sequencer, each with its own pattern system, all driven by the SAME master clock so they never drift. - Each instance has its own active track / active pattern / undo stack. - Every MCP action takes an optional `instance_id` β€” omit to target the active sequencer; pass a specific one to address it directly. Spawning new instances is normally via the rail-pattern picker (left rail β†’ MIDI sources area β†’ `+ Add` β†’ Sequencer). Once added each instance shows up in `routing.list` as its own Source. Next section: actually composing on the grid β€” painting notes, velocity-drag, hold (gate length), the chord palette, and the Suggest Progression flow. --- <!-- https://faustwave.io/docs/sequencer/composing-patterns β€” book: Sequencer --> # Composing on the grid You have a grid, a key, a clock. Time to fill it with notes. The Sequencer's edit surface has two tools (Paint and Select), per-step velocity, per-step hold (gate length), a Chord Palette, and the Suggest Progression flow. Plus undo / redo so you can experiment freely. ## Two tools, one toggle Flip between Paint and Select with the footer toggle (or the keyboard shortcut shown in the tooltip). ### Paint - **Click a cell** β†’ add a note at that pitch + step. - **Click a filled cell** β†’ remove it. - **Vertical drag while painting** β†’ set velocity. The cell tints based on velocity (faint = low, saturated = high). - **Cmd/Ctrl-drag horizontally** β†’ **smear**. Copies the source cell into every REST cell across the drag range. Already-filled cells are left alone (smear never overwrites β€” clear explicitly first if needed). > πŸ”˜ **MCP**: `sequencer.step.set { step, note? | chord? | rest: true | clear: true, velocity?, track_id?, instance_id? }` for a single cell. `sequencer.step.smear { from, to, track_id? }` for the smear gesture. `sequencer.step.duplicate { step, track_id? }` for "same as previous step". ### Select - **Click a note** β†’ select it. - **Shift-click** β†’ extend the selection. - **Arrow keys** β€” move selection up / down (Β±1 semitone), Shift+Up / Down (Β± octave). - **Escape** β€” clear selection. The Suggest-Progression flow auto-selects the inserted notes so the next gesture (e.g. arrow-down) shifts them all together. Same for chord-palette drops. > πŸ”˜ **MCP**: `sequencer.notes.transpose { semitones: -12..12, steps?: [indices], track_id? }`. Pass `steps` to limit the move to specific step indices (e.g. just the steps a progression filled); omit to transpose every note in the track. The move is clamped to the C0..C8 grid range so chords keep their shape and just stop at the edge instead of collapsing voices. ## Per-step velocity Every step carries a velocity 1..127, defaulting to 100. Set via: - **Paint-drag**: vertical drag while painting. - **Right-click β†’ Velocity** preset (Quiet / Medium / Loud / Accent / Custom). - **MCP**: `sequencer.step.set { velocity }` or `sequencer.step.gate.set` (see below) for already-placed steps. Velocity rides through the dispatch path to the receiver's `[midi:keyon]` callback, so a velocity-sensitive Faust DSP (`gain = hslider("gain[midi:keyon]", 0.5, 0, 1, 0.01)`) responds correctly. ## Per-step hold (gate length) Each painted step has an optional **hold multiplier** β€” how long the note rings relative to the step duration: | Hold | Behaviour | |---|---| | `0.25` | Staccato β€” very short. | | `1.0` | Default β€” release at the next step boundary. | | `2.0` | Tied β€” rings into the next step. | | `4.0` | Held one beat (4 Γ— 16th). | | `16.0` | Held a full bar. | | `> pattern length` | Legato across the wrap. Same pitch on the next loop extends the held note instead of retriggering. | Set via right-click β†’ Hold preset (Staccato / Default / Tied / Beat / Half-bar / Whole bar / Custom) or: > πŸ”˜ **MCP**: `sequencer.step.gate.set { step, hold, track_id? }` for an already-placed step. To author hold inline during step creation, use `sequencer.step.set { note, hold }`. The per-step length / hold flows through the seq-clock to the dispatch path so the gate-off event lands at the right sample. ## Per-step **length** (pattern-level) Distinct from hold: the **step's WALL-CLOCK duration**. Defaults to 16th-note (one tick of the master clock). Can be overridden per-step, but the override is **pattern-level** β€” every track's column-N advances on the SAME wall-clock, by construction of the single seq-clock. > πŸ”˜ **MCP**: `sequencer.step.length.set { step, ms: 250 }` to override step `step`'s duration; `ms: null` reverts to default. `sequencer.step.length.clear-all` resets every step's length override in one shot. Useful for: triplet feels (set every third step to a longer ms), swing (alternate ms on odd / even steps), polymeter trickery. ## The diatonic Chord Palette To the right of the key/scale picker, the footer shows a row of chord buttons β€” the diatonic chords for the current key + scale. For A minor: `i (Am)`, `iiΒ° (Bdim)`, `III (C)`, `iv (Dm)`, `v (Em)`, `VI (F)`, `VII (G)`. Click a chord button β†’ the next grid click drops a 3-note stack (root + 3rd + 5th) on that step column. Velocity carries; drag-velocity works on the whole stack at once. > πŸ”˜ **MCP**: chord stacks land via `sequencer.step.set { ..., chord: [<midi>, <midi>, <midi>] }`. The diatonic palette is purely UI sugar over that surface β€” from MCP you'd hand-build the chord array. ## Suggest Progression Click the lightbulb on a track chip (or the footer's Suggest button). A KB-search menu opens against the curated 8-progression theory corpus: - `axis-major`, `axis-minor` β€” the Hooktheory "axis". - `i-iv-v-major`, `i-vi-iv-v` β€” pop classics. - `ii-v-i` β€” jazz turnaround. - `minor-pop-loop`, `folk-minor`. - `twelve-bar-blues`. Free-text search is German + English aliased: "Pop-Schleife", "Sad Axis", "12 Bar Blues" all land. Pick a progression + a **distribution**: - `bar` (default) β€” 1 chord per 4 steps. Max 4 chords on a 16-step pattern; longer progressions truncate with a note. - `half-bar` β€” 1 chord per 2 steps. Max 8 chords. Hitting a result drops the progression onto the active track. Existing steps at the target indices are replaced; other steps are left alone. ```yaml actions: - action_id: surface.open input: { surface: sequencer-default#roll } label: "β–Ά 0. Open the Sequencer (watch the grid fill)" - action_id: sequencer.progressions.list input: {} save_as: progs label: "β–Ά 1. List documented progressions" - action_id: sequencer.key.set input: { tonic_name: A, scale_id: aeolian } label: "β–Ά 2. Set key to A minor" - action_id: sequencer.track.add input: { name: "Progression demo" } save_as: t label: "β–Ά 3. Add a demo track (leaves your tracks alone)" - action_id: sequencer.progression.insert input: progression_id: axis-minor distribution: bar track_id: "{{t.track_id}}" label: "β–Ά 4. Drop the axis-minor progression on it" ``` > 🟦 **HANDS-FREE PATH**: step 1 enumerates the corpus; step 2 sets the key the progression transposes to; steps 3–4 drop the chords onto a fresh demo track. With more than one track present, `sequencer.progression.insert` REQUIRES an explicit `track_id` (targeting convention β€” no silent "whatever is active" writes), which is why the chain creates its own target. > πŸ”˜ **MCP**: `sequencer.progression.insert { progression_id, distribution, key?, track_id? }`. `key` optionally transposes to a chromatic tonic (`"C" | "C#" | ... | "B"`); omit to keep the canonical voicing from the progression's example key (axis-major is in C, axis-minor in A, etc.). ## Clearing the grid - **Right-click chip β†’ Clear** for one track. - **MCP** `sequencer.clear { track_id?, instance_id? }` β€” resets every step on the active (or specified) track to rest. ## Undo / Redo Snapshot-based. Every meaningful edit (step set, track add, track delete, pattern-length change) commits a snapshot. Cmd/Ctrl+Z = undo, Cmd/Ctrl+Shift+Z = redo. Stack capped at ~100 entries per session. > πŸ”˜ **MCP**: `sequencer.undo {}` and `sequencer.redo {}`. Both return `"No history"` when the stack is empty. Optional `instance_id` β€” undo is per-instance. NOT captured: cursor position (not a state mutation), playback transitions (transient), velocity-drag in-progress (commits on release). Next section: patterns + scenes β€” the live-arrangement model that lets you launch sections without composing inline. --- <!-- https://faustwave.io/docs/sequencer/patterns-and-scenes β€” book: Sequencer --> # Patterns + scenes β€” the live-arrangement model Everything you've done so far in the grid lives in ONE pattern per track. The pattern model lets each track carry up to **8 patterns** (labelled A..H by default, renameable), and **scenes** capture whole-sequencer pattern combinations β€” the same multi-track "chorus / verse / bridge" mechanic familiar from Ableton's Session view, with FaustWave's own quantize + launch semantics. This is the live-performance layer of the Sequencer. Once you understand it, you stop "writing a long song" and start "writing a few short patterns and launching them in real time". ## Patterns β€” per-track variation Each track has a **pattern bank**. A new track starts with 4 patterns; you can add up to 8 with `sequencer.pattern.add`. On the track's chip there's a pattern launcher β€” A / B / … pads. Each pattern has three states the launcher distinguishes: - **Active** β€” the pattern the engine is playing. One per track at a time. - **Edit** β€” the pattern the piano-roll editor is showing. Independent from active β€” you can edit pattern B while the engine plays pattern A. - **Pending** β€” a pattern that was launched while playing, but is still waiting for its quantize point. Visual pulse until the swap commits. Click a pattern pad to **launch** it. The behaviour depends on transport state: - **Stopped**: launch is **immediate**. Next `sequencer.play` starts in the launched pattern. - **Playing**: launch is **queued** and commits at the pattern's launch-quantize boundary (default: next bar). > πŸ”˜ **MCP**: `sequencer.pattern.active.set { pattern_id, track_id?, instance_id? }` is the launch. Returns immediately even if the actual swap is queued. Independently from launching, you can change which pattern the editor is showing without affecting playback: > πŸ”˜ **MCP**: `sequencer.pattern.edit.set { pattern_id, track_id? }` is a **view-only** change. Useful when you want to fill in pattern D while the engine plays pattern A β€” set the edit pattern to D, paint, set it back to A (or just launch D). ## Per-pattern launch quantize Each pattern carries its own launch quantize β€” the boundary the engine waits for before committing the launch. Five values: | Quantize | Commits at | |---|---| | `instant` | Immediately (no quantize). Use for fills. | | `next-beat` | Next beat boundary (every 4 Γ— 16ths). | | `next-bar` (default) | Next pattern wrap. | | `next-2-bars` | Every second pattern wrap. | | `next-4-bars` | Every fourth pattern wrap. | Stopped sequencer always commits immediately regardless of this value. > πŸ”˜ **MCP**: `sequencer.pattern.quantize.set { pattern_id, quantize, track_id? }`. Mixing quantizes inside one sequencer is fine β€” a fill pattern at `instant`, your main verse / chorus at `next-bar`, a long-form variation at `next-4-bars`. The launcher visuals reflect the quantize so you can tell at a glance. ## Renaming patterns Default labels are A..H (positional). Rename via right-click β†’ Rename, or: > πŸ”˜ **MCP**: `sequencer.pattern.rename { pattern_id, label, track_id? }`. The pattern id is positional and unchanged β€” only the display label moves. A / B / C / D works for sketches; "verse / chorus / bridge / fill" works for live sets. ## Removing patterns > πŸ”˜ **MCP**: `sequencer.pattern.remove { pattern_id, track_id? }`. Refuses on the last pattern (a track always keeps β‰₯ 1). If the removed pattern was active, edit, or pending, those references fall back to the first remaining pattern. ## Scenes β€” whole-sequencer combinations A scene captures "which pattern is active on each track" as a single named handle. Launch a scene β†’ every assigned track queues its scene's pattern (using each track's own per-pattern quantize). The combination commits at each track's next quantize point, so you can swap from "verse combo" to "chorus combo" with one launch. ### Building a scene The usual flow: dial up a combination by launching patterns track-by-track, then save it: > πŸ”˜ **MCP**: `sequencer.scene.add { from_current: true (default), label? }` snapshots the current `activePatternId` of every track as the scene's assignments. Returns `{ ok, scene_id }`. Default label = `Scene N`. Rename via: > πŸ”˜ **MCP**: `sequencer.scene.rename { scene_id, label }`. ### Launching a scene > πŸ”˜ **MCP**: `sequencer.scene.launch { scene_id }`. Queues each assigned track's pattern via the same pendingPatternId mechanism as `sequencer.pattern.active.set` (next-bar when playing, immediate when stopped). Tracks NOT in the scene's assignments are left untouched. Sets `active_scene_id` so the panel highlights the active scene. ### Removing a scene > πŸ”˜ **MCP**: `sequencer.scene.remove { scene_id }`. If it was the active scene, `active_scene_id` clears. ## A live-perf chain ```yaml actions: - action_id: sequencer.track.add input: { name: "Patterns demo" } save_as: t label: "β–Ά 0. Add a demo track (leaves your tracks alone)" - action_id: sequencer.pattern.add input: { track_id: "{{t.track_id}}" } save_as: chorus label: "β–Ά 1. Add a 'chorus' pattern to it" - action_id: sequencer.pattern.rename input: pattern_id: "{{chorus.pattern_id}}" label: "Chorus" track_id: "{{t.track_id}}" label: "β–Ά 2. Rename it 'Chorus'" - action_id: sequencer.pattern.quantize.set input: pattern_id: "{{chorus.pattern_id}}" quantize: next-2-bars track_id: "{{t.track_id}}" label: "β–Ά 3. Launch on a 2-bar boundary" - action_id: sequencer.pattern.active.set input: pattern_id: "{{chorus.pattern_id}}" track_id: "{{t.track_id}}" label: "β–Ά 4. Launch the chorus" - action_id: sequencer.scene.add input: { from_current: true, label: "All-Chorus" } label: "β–  5. Snapshot the current combo as a scene" ``` > 🟦 **HANDS-FREE PATH**: step 1 adds a new pattern; step 2 renames it; step 3 sets a 2-bar launch quantize so the user has enough time to react; step 4 queues the launch (it commits at the next 2-bar wrap when the engine is playing); step 5 snapshots the resulting state as a scene named "All-Chorus". Adapt to your own track/pattern ids from `sequencer.state`. ## When to scene vs launch directly - **Just one or two tracks change at a section boundary**: per-track pattern launch is simpler. Fewer concepts in play. - **Three or more tracks change at the same boundary**: scenes save the gesture. One launch β†’ every track moves to its assigned pattern. - **You need to A/B different combinations live**: scenes win β€” name them, launch them, return to neutral. The Edit Pattern mechanic lets you fill in upcoming sections without disturbing what's playing. The combination + scene model lets you stage transitions cleanly. ## Perform mode β€” the layout for live > πŸ”˜ **MCP**: `surface.open { surface: "faustwave-bundled/sequencer#patterns" }`. UI: the **PATTERNS** button in the Sequencer header, or the Patterns row in the launcher. Track cards, enlarged pattern pads, direct mute/solo, scenes β€” bigger touch targets and less visual noise. If you operate the Sequencer with one finger on a touchscreen or one hand on a controller, this is the surface you keep open; the roll can stay open next to it, or not. Flip back to `edit` whenever you need to compose; the pattern bank survives unchanged. Next section: the full MCP surface, with a multi-track build-from-scratch chain. --- <!-- https://faustwave.io/docs/sequencer/scripting-via-mcp β€” book: Sequencer --> # Scripting the Sequencer via MCP The full Sequencer surface is MCP-callable β€” every UI gesture has an action behind it, plus a few that only show up here (`sequencer.state`, `sequencer.notes.transpose`, the scene actions). Together they're β‰ˆ30 actions covering grid, patterns, scenes, transport, undo, panel layout, and progression insertion. This section is the catalog + a playable "build a 2-track loop from scratch" chain. ## Multi-instance: `instance_id` Every action takes an optional `instance_id`. Omit to target the **active** sequencer (the one with UI focus / the one a fresh project lands on, `sequencer-default`). Pass a specific id to address it directly. Discover instances via `routing.list` β€” each sequencer is a Source with `kind: "midi"` and a stable `sequencer-<id>` shape. Multiple instances play independently and concurrently (drum-seq + harmony-seq + bass-seq is a normal setup). ## The full catalog by area ### Transport + state | Action | Purpose | |---|---| | `sequencer.play` | Start the sequencer against the master clock. Per-instance. Returns once a single pass completes in `single` loop mode, or after the first pass with a "looping in background" message in `infinite`. | | `sequencer.stop` | Cancel at the next step boundary. "No sequence playing" when nothing is in flight. | | `sequencer.state { instance_id? }` | Snapshot of everything: `{ instance_id, steps_per_pattern, steps_ms, loop_mode, playing, key, active_track_id, tracks, scenes, active_scene_id }`. Steps are sparse β€” only occupied steps are listed, each with its index `i`. | | `sequencer.loop.set { loop: true \| false }` | Set / clear loop mode (mirrors the panel's Repeat toggle). | | `surface.open { surface: "faustwave-bundled/sequencer#roll" \| "…#patterns" }` | Show the roll or the launching surface (no audio impact). | UI layout switch (no audio impact). | ### Tracks | Action | Purpose | |---|---| | `sequencer.track.add { name? }` | Append. Returns `{ ok, track_id }`. | | `sequencer.track.remove { track_id }` | Delete. Refuses the last track. | | `sequencer.track.rename { track_id, name }` | Rename. | | `sequencer.track.enabled.set { track_id, enabled }` | Mute (cursor still advances). | | `sequencer.track.soloed.set { track_id, soloed }` | Solo wins over un-muted. | | `sequencer.track.midi-channel.set { track_id, channel }` | 0 = Omni, 1–16 = specific. | | `sequencer.active-track.set { track_id }` | Change which track has UI chrome focus. | ### Steps | Action | Purpose | |---|---| | `sequencer.step.set { step, note? \| chord? \| rest? \| clear?, velocity?, track_id? }` | Set or clear a cell. Pass exactly one of note / chord / rest / clear. Velocity default 100. | | `sequencer.step.duplicate { step, track_id? }` | Copy previous step (index-1) onto step. Errors on step 0 or rest source. | | `sequencer.step.smear { from, to, track_id? }` | Copy `from` into every REST cell in `[from..to]`. Never overwrites. | | `sequencer.step.gate.set { step, hold, track_id? }` | Gate length on an existing note/chord step. | | `sequencer.step.length.set { step, ms }` | Pattern-level step duration override; `ms: null` reverts. | | `sequencer.step.length.clear-all { instance_id? }` | Reset every step's length override. | | `sequencer.notes.transpose { semitones, steps?, track_id? }` | Pitch shift. Clamped to C0..C8 as a whole. | | `sequencer.clear { track_id? }` | Reset every step on the track to rest. | | `sequencer.pattern.length.set { length: 8 \| 16 \| 32 \| 64 }` | Change pattern length live. | | `sequencer.key.set { tonic_name? \| tonic_pc?, scale_id }` | Set the musical key (drives grid highlight + progression transposition). | ### Patterns | Action | Purpose | |---|---| | `sequencer.pattern.add { track_id? }` | Append a pattern to a track. Up to 8. Returns `{ ok, pattern_id }`. | | `sequencer.pattern.remove { pattern_id, track_id? }` | Delete. Refuses the last pattern. | | `sequencer.pattern.rename { pattern_id, label, track_id? }` | Rename. | | `sequencer.pattern.active.set { pattern_id, track_id? }` | Launch β€” queues at quantize boundary when playing, immediate when stopped. | | `sequencer.pattern.edit.set { pattern_id, track_id? }` | View-only β€” switches which pattern the piano-roll shows. | | `sequencer.pattern.quantize.set { pattern_id, quantize, track_id? }` | Per-pattern launch quantize. Five values: `instant`, `next-beat`, `next-bar` (default), `next-2-bars`, `next-4-bars`. | ### Scenes | Action | Purpose | |---|---| | `sequencer.scene.add { from_current?, label? }` | Snapshot current activePatternId per track as a scene. Returns `{ ok, scene_id }`. | | `sequencer.scene.remove { scene_id }` | Delete. | | `sequencer.scene.rename { scene_id, label }` | Rename. | | `sequencer.scene.launch { scene_id }` | Queue each track's assigned pattern via per-pattern quantize. | ### Progressions | Action | Purpose | |---|---| | `sequencer.progressions.list` | Enumerate the 8 documented progressions: axis-major, axis-minor, i-iv-v-major, ii-v-i, i-vi-iv-v, minor-pop-loop, folk-minor, twelve-bar-blues. | | `sequencer.progression.insert { progression_id, distribution, key?, track_id? }` | Drop onto the track. `distribution: "bar"` (default, 4 chords/16 steps) or `"half-bar"` (8 chords). `key` optionally transposes; omit to use the progression's canonical key. | ### Undo | Action | Purpose | |---|---| | `sequencer.undo` | Pop one edit. "No history" when empty. | | `sequencer.redo` | Repush. Clears whenever a fresh edit is made. | ## A build-from-scratch chain ```yaml actions: - action_id: sequencer.track.add input: { name: "Chords" } save_as: chords label: "β–Ά 1. Add a Chords track (self-contained β€” your tracks stay untouched)" - action_id: sequencer.key.set input: { tonic_name: A, scale_id: aeolian } label: "β–Ά 2. Key = A minor" - action_id: sequencer.progression.insert input: progression_id: axis-minor distribution: bar track_id: "{{chords.track_id}}" label: "β–Ά 3. Drop axis-minor chords on bars" - action_id: sequencer.track.add input: { name: Bass } save_as: bass label: "β–Ά 4. Add a Bass track" - action_id: sequencer.active-track.set input: track_id: "{{bass.track_id}}" label: "β–Ά 5. Focus Bass" - action_id: sequencer.step.set input: track_id: "{{bass.track_id}}" index: 0 note: 33 velocity: 110 label: "β–Ά 6. Root note on step 0 (A1, accent)" - action_id: sequencer.step.smear input: track_id: "{{bass.track_id}}" from: 0 to: 15 label: "β–Ά 7. Smear the root across the bar" - action_id: sequencer.loop.set input: { loop: true } label: "β–Ά 8. Loop mode = infinite" - action_id: sequencer.play input: {} label: "β–Ά 9. Play" - action_id: sequencer.stop input: {} label: "β–  10. Stop" ``` > 🟦 **HANDS-FREE PATH**: clears the active track, sets the key, drops a chord progression, adds a Bass track, focuses it, paints a root note with accent velocity, smears it across the bar (because the smear is REST-only it auto-fills the empty cells), arms infinite loop, plays, stops. Run it once to hear the result, hit **Reset** between runs. ## Driving multi-instance from MCP For a setup with two sequencers (e.g. drum sequencer + harmony sequencer), pass `instance_id` explicitly: ```json { "action_id": "sequencer.play", "input": { "instance_id": "sequencer-drum" } } ``` A combined `sequencer.play` for every instance is just N parallel calls; they're rate-limited by the master clock so they'll start in lockstep anyway. ## A look at `sequencer.state` The "what's on the grid right now" call. One call returns the whole sequencer β€” every track, every pattern, the scene catalog: ```json { "instance_id": "sequencer-default", "steps_per_pattern": 16, "steps_ms": [null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null], "loop_mode": "infinite", "playing": true, "key": { "tonic_pc": 9, "scale_id": "aeolian" }, "active_track_id": "track-1", "tracks": [ { "id": "track-1", "name": "Track 1", "midiChannel": 1, "enabled": true, "soloed": false, "activePatternId": "pattern-a", "editPatternId": "pattern-a", "pendingPatternId": null, "patterns": [ { "id": "pattern-a", "label": "A", "steps": [ { "i": 0, "kind": "chord", "notes": [57, 60, 64] }, { "i": 8, "kind": "note", "note": 57, "velocity": 90 } ] }, { "id": "pattern-b", "label": "B", "steps": [] } ] } ], "scenes": [], "active_scene_id": null } ``` **Steps are sparse.** A grid is mostly rests, so `steps` lists only the occupied steps, each with its 0-based index `i` β€” the same index `sequencer.step.set` takes. Every index below `steps_per_pattern` that is not listed is a rest; an empty `steps` array is an empty pattern. `activePatternId` is the pattern that plays, `editPatternId` the one step writes land in. Useful when you want to render the grid into prose ("track 1 plays an A minor stab on step 0 and rests for the rest of the bar") or when the assistant needs to know the current state before deciding the next move. ## Where to go from here - **FaustWave β€” Patchbay** β€” wire the sequencer to multiple instruments per channel. - **FaustWave β€” Mixer & Master** β€” mix each instrument the sequencer drives. - **FaustWave β€” AI Assistant** β€” ask "fill the active track with a chord progression that fits the bass line" and watch the chain run. --- <!-- https://faustwave.io/docs/keyboard/keyboard β€” book: Keyboard --> # The Keyboard ```yaml actions: - action_id: surface.open input: { surface: keyboard-default } label: "β–Ά 0. Open the Keyboard panel" - action_id: keyboard.channel.set input: { channel: 1 } label: "β–Ά 1. Send on MIDI channel 1" - action_id: keyboard.octave.set input: { base: 60 } label: "β–Ά 2. Octave base = C4 (middle C)" - action_id: keyboard.note.on input: { note: 60, velocity: 100 } label: "β–Ά 3. Play C4 (loud)" - action_id: keyboard.note.on input: { note: 64, velocity: 100 } label: "β–Ά 4. Add E4 (major third)" - action_id: keyboard.note.on input: { note: 67, velocity: 100 } label: "β–Ά 5. Add G4 (C major chord)" - action_id: keyboard.notes.clear input: {} label: "β–  Release all" ``` The Keyboard is FaustWave's virtual MIDI input β€” a clickable on-screen keyboard registered as a routing-table source (`keyboard-default`). Useful for testing a DSP ("does my Builder make a sound when I press a key?"), for performing without a hardware controller, and β€” as the action chain above shows β€” for driving notes programmatically from the assistant. > 🟦 **HANDS-FREE PATH**: the buttons above send a real chord. Step 1 picks channel 1, step 2 plants the octave at middle C, steps 3–5 stack a C major triad, the last button releases everything. The chord runs through the same source-layer wrapper a click on the on-screen keys would β€” receivers can't tell the difference. ## Opening + anatomy Open the **Keyboard** rail panel from the left rail (piano-keys icon under the MIDI sources area). The panel shows: - A two-octave keyboard layout (default: leftmost key = MIDI 60 / C4 / middle C). - Octave shift buttons β€” move the visible range up / down by one octave. - A MIDI channel-picker chip (Omni / 1–16). - A hold-mode toggle (see below). - An **ALL OFF** button for the stuck-notes recovery. ## Playing notes Click a key to send a `note-on` MIDI event; release (or pointer-out) sends `note-off`. The event flows through the routing table to every connected MIDI sink β€” instruments, Builders, sequencer record-armed tracks, hardware MIDI outs, etc. Holding multiple pointers (or stacking with hold-mode) plays a chord. The Keyboard itself has no polyphony cap; the receiving consumer decides what it can voice. > 🟦 **NEW TO MIDI?** A MIDI keyboard sends an event per gesture: `note-on { pitch, velocity }` when you press, `note-off { pitch }` when you release. The keyboard carries no sound β€” it just describes the gesture. The receiving instrument (your Builder, or any instrument you've installed) decides what that gesture sounds like. ## Hold mode β€” stacking chords with one pointer The hold toggle changes the click semantics: - **OFF (default, physical-key feel)**: pointer-down plays, pointer-up releases. Mono unless you have multi-touch. - **ON (toggle / latch feel)**: click ONCE to start a note, click AGAIN to release. Stack chords by clicking multiple keys one after another, without holding the pointer down. Hold-mode does NOT auto-clear when flipped β€” if you turn it OFF with notes still latched, they stay on until you hit ALL OFF or play and release them with a fresh pointer press. > πŸ”˜ **MCP**: `keyboard.hold.set { enabled: true | false }`. ## MIDI channel The Keyboard sends on **channel 1** by default. Change it via the panel's channel selector or: > πŸ”˜ **MCP**: `keyboard.channel.set { channel: 0..16 }`. `0` = **Omni** broadcast (status-byte channel bits = 0, equivalent to channel 1 on the wire; receivers configured Omni accept any channel). `1..16` = specific channel. Receiving consumers (Builder `midi_note` nodes, sequencer tracks) filter by channel β€” if they listen on a different channel than you send, they ignore the event. **Heads-up**: changing channel mid-held-note strands the note on the OLD channel. Call `keyboard.notes.clear` before the channel switch if any notes are held. ## Octave shift The Keyboard's visible range moves in semitones by setting the **octave base** β€” the MIDI number of the leftmost visible key. Default is 60 (C4). Range is clamped to 0–103 so the topmost visible key stays inside MIDI 0–127. > πŸ”˜ **MCP**: `keyboard.octave.set { base: 0..103 }`. Examples: 48 = C3 (one octave down), 72 = C5 (one octave up). The panel's octave buttons step in 12-semitone increments and call `keyboard.notes.clear` first to avoid hung notes β€” do the same when scripting if any notes are held. ## Stuck notes β€” ALL OFF / `keyboard.notes.clear` Long sessions can leave notes hanging β€” a channel switch mid-held-note, a hold-mode flip, an MCP call that fired `keyboard.note.on` without a matching `keyboard.note.off`. The **ALL OFF** button (and its MCP twin `keyboard.notes.clear`) is the recovery: it sends a `note-off` for every entry in the held-set on the current channel, then clears the set. Idempotent β€” calling it with nothing held is a no-op. Always safe to fire. > πŸ”˜ **MCP**: `keyboard.notes.clear {}` β€” releases what THIS keyboard holds; useful as an action chain's epilogue. It is not the panic: for a note stuck on an instrument or on external hardware, use `midi.panic {}` (or the Panic button at the right end of the transport strip), which tells every MIDI sink and every hardware output to let go. ## Routing The Keyboard registers as the source `keyboard-default`. A fresh project has **no seeded MIDI connections** β€” there's no bundled instrument to wire to, so you draw the Keyboard's routing yourself. Wire it via the **Patchbay** rail panel (click the cell at the row for `keyboard-default` and the column of any compatible MIDI sink). Common targets: your own Builders' `midi_note` nodes, sequencer record-armed tracks, hardware MIDI-out devices. > πŸ”˜ **MCP**: connection management lives under `routing.connect` / `routing.disconnect` / `routing.list`. The Keyboard's source id is the stable `keyboard-default`. ## Physical MIDI integration Plug in a hardware MIDI controller (USB / Bluetooth / DIN-USB adapter). FaustWave's MIDI subsystem picks it up via WebMIDI β€” the OS prompts for permission on first use. Physical controllers appear as separate routing sources (e.g. `midi-input-input-0`) alongside `keyboard-default`. Wire them into sinks via the Patchbay the same way you'd wire any source. The on-screen Keyboard and physical controllers can coexist β€” both feed the same sinks if both are wired up. > πŸ”˜ **MCP**: list everything currently MIDI-audible with `system.state { sections: ["midi_consumers"] }`. For full topology hit `routing.list` and filter by `kind: "midi"`. ## The audible-consumer hint A quality-of-life affordance in the Mixer: if you press a key and NOTHING is currently set to be audibly driven by MIDI, the Mixer panel shows a **Smart Mute Hint** β€” a soft "hey, your input isn't reaching anything that makes sound". Common causes: nothing is wired from the Keyboard to a sound source yet, every MIDI consumer is muted, or the Builder you're driving isn't on a MixerTrack (not "+ ADD AUDIO"-ed). The hint clears as soon as an audible MIDI consumer becomes reachable again. ## MCP surface β€” the real catalog | Action | Purpose | |---|---| | `keyboard.note.on` | Trigger `note-on` (note, velocity). Velocity defaults to 100. | | `keyboard.note.off` | Trigger `note-off`. Idempotent re: stuck-note recovery. | | `keyboard.channel.set` | Set output channel (0 = Omni, 1–16 specific). | | `keyboard.octave.set` | Set the leftmost visible MIDI number (0–103). | | `keyboard.hold.set` | Toggle hold-mode (latch vs press-and-release). | | `keyboard.notes.clear` | Release every note THIS keyboard holds. Not the global panic β€” that is `midi.panic`. | | `keyboard.state` | Snapshot: held notes, channel, hold flag, octave base. | All Keyboard MCP actions are `palette.run`-only (no hot-path tools) β€” they're low-frequency compared to the Sequencer's surface, which earns dedicated tools. See **FaustWave β€” AI Assistant** Β§ *Why some actions are hot-path and others aren't* for the discipline. ## Where to go from here - **FaustWave β€” Sequencer** for the other big MIDI source. Both route through the Patchbay; both share the master transport. - **FaustWave β€” Builder** for `midi_note` nodes so your DSP responds to Keyboard input. - **FaustWave β€” Getting Started** Β§ *Your first pattern* β€” a Keyboard-driven walk-through. --- <!-- https://faustwave.io/docs/pads/pads β€” book: Pads --> # Pads β€” the composable controller surface The Pads panel is an on-screen MIDI controller you can **compose yourself**: a list of widgets β€” pads, knobs, faders, buttons β€” each with its own MIDI mapping. It is a **sender, nothing else**: it produces exactly the bytes a hardware controller would, and everything it "does" is decided by whatever you route it to in the Patchbay. Master the virtual device and you have mastered the real one: when hardware arrives, re-route the same Patchbay edges from the `pads-default` source to the hardware input and nothing downstream changes. Add it to the left rail via the **+** in the MIDI-sources area (same picker as Sequencer and Keyboard). ## The widget types - **Pad** β€” sends a **note**: press = note-on (velocity 110), release = note-off. For drums, triggers, and controller bindings. - **Knob** β€” sends a **CC** as you turn it (a rotary knob in byte form: "controller *N* is now at *value*"). Rendered as the same audio knob every FaustWave parameter uses β€” drag vertically to turn it. - **Fader** β€” same as a knob, rendered as a slider. - **Button** β€” a momentary **CC** switch: press sends its `max`, release sends its `min`. For sustain-style switches and CC-triggered bindings. Each widget carries its own mapping: note/CC **number**, a **channel** (0 = follow the device channel set in the header chip; 1–16 = per-widget override, the way multi-engine controllers send per-section channels), a **min..max** range for CC widgets (limit a sweep to e.g. 20..100), and an optional **label**. ## The default surface A fresh Pads panel is pre-composed as a BeatStep-class device: **16 pads on notes 36–51** β€” the GM drum anchor (kick 36, snare 38, closed hat 42, open hat 46) that the bundled Drum Kit Electro and the C64 Drum Machine gate on β€” plus **8 knobs on CC 71–78** (the classic sound-controller block; CC 74 is the conventional filter cutoff/brightness). Route `Pads β†’ <drum instrument>:midi-in` and the grid plays drums with zero configuration. ## EDIT mode β€” composing your surface The **EDIT** button in the panel **footer** opens the authoring layer: - **Add** widgets with the per-type footer buttons (max 64). - **Click a widget** to select it (amber outline) β€” its configuration opens as a **popover right at the click point**: number, channel, min/max for CC widgets, label. - For CC widgets the popover has a **MAP** button β€” the project's **CC map**: which CC numbers already have a listener (a `midi_cc` node in a Builder DSP, a `[midi:ctrl]`-tagged slider in a .dsp source, a controller binding), what the standard name is (CC 1 Mod Wheel, 64 Sustain, 74 Brightness…), and the last value the bus saw on that CC. Click a row to assign it β€” no more guessing numbers. - **Reorder** with the popover's arrow buttons, **remove** with its trash button. - Leave EDIT mode (or press Esc to dismiss the popover) and the surface is playable again. The whole configuration persists with the **project** β€” your live-set surface travels in the project file, and a saved surface is the future device template (share a "my BeatStep layout" via the Hub β€” planned). ## What a widget does NOT do A widget only *sends*. It does nothing audible until a receiver listens: 1. **Notes** β†’ an instrument's `:midi-in` (drums, synths) or the **Controller Actions** sink (`controller.bind.add` maps notes to IDE commands β€” scenes, transport). 2. **CCs** β†’ a **`midi_cc` node** in a Builder DSP or a **`[midi:ctrl N]`-tagged slider** in a .dsp source β€” see **FaustWave β€” Patchbay & Routing** Β§ *External clock sync & controller actions*. Silence from a knob means "no receiver has that CC number", not "broken" β€” the MIDI Devices panel's **MONITOR** shows every byte the surface sends (`[Pads] ch1 CC CC 74 = 96`) and settles the question in seconds. ## Scripting (palette.run) `pads.state` (read FIRST β€” widget ids) Β· `pads.widget.add { type, number?, channel?, label?, min?, max? }` Β· `pads.widget.set { widget_id, … }` Β· `pads.widget.remove` / `pads.widget.move` Β· `pads.widget.value.set { widget_id, value }` (turn a knob = send its CC) Β· `pads.press { widget_id, velocity? }` (press gesture with timed release) Β· `pads.channel.set { channel }` (releases held pads on the old channel first β€” no stuck notes) Β· `midi.cc.map` (the project CC map as text β€” who listens to which CC; read it before choosing a CC number). --- <!-- https://faustwave.io/docs/modulation-live/modulators-are-files β€” book: Modulation & Live Control --> # A modulator is a file FaustWave has no "LFO feature". A modulator is an ordinary `.dsp` document with a bargraph tagged `[modout]`; while it runs, that output is a **modulation source** in the Patchbay, and every live parameter of every running instrument, Builder and FX slot is a **sink**. Wiring is a cable with an amount, like audio is a cable with a gain. This has three consequences you will feel immediately: - **You can read it.** Open `Mod LFO Breath.dsp` in the demo and the whole modulator is twenty lines of Faust. Change the shape, add a second output, tempo-sync it β€” it is your code. - **It persists like a file.** The modulator is in `assets/`, its edges are in the project's routing table, the values it swings around are in the project. Reopen the project and the breathing is back. - **It is a source like any other.** An envelope follower on the drum bus, a random walk, a macro knob β€” anything with `[modout]` can drive anything with a slider. ## The corpus templates You rarely write one from scratch. `modulation.source.add { type }` creates a modulator document from a template and runs it: | Template | What it is | Tempo sync | |---|---|---| | `lfo` | Four shapes (sine, triangle, saw, square) from one resettable phasor, `rate` in Hz | yes β€” the `sync` menu (1/1 … 1/16) | | `sample-hold` | A new random value on every clock tick, deliberately unsmoothed | yes | | `random` | Band-limited drift | no | | `macro` | One knob, no life of its own β€” the stage performer's source | β€” | ```yaml actions: - action_id: modulation.source.add input: { type: lfo } save_as: lfo label: "β–Ά Create an LFO document and run it" - action_id: modulation.source.list input: {} label: "β–Ά Every source that is running, with its ports and edge count" ``` Tempo sync works through two metadata tags a modulator (or any DSP) can carry: a slider tagged `[bpm]` receives the live transport tempo, a button tagged `[transportreset]` is pulsed on stop β†’ play so the phase re-aligns with the downbeat. The demo's `Mod SH Bell` steps on every eighth because of exactly those two lines. ## Writing your own ```faust declare name "Breath"; import("stdfaust.lib"); rate = hslider("rate[unit:Hz][scale:log]", 0.135, 0.01, 20, 0.001); depth = hslider("depth", 1, 0, 1, 0.001); process = attach(0, os.osc(rate) * depth : hbargraph("out[modout]", -1, 1)); ``` Three rules: the output range is what the bargraph declares (-1..1 or 0..1 β€” the amount maths below uses it); `attach(0, …)` keeps the DSP audio-silent while forcing the bargraph to compute; the modulator must **run** (`dsp.run`) β€” a source registers only while its patch runs, which is also why `routing.list` shows modulation sinks only for running instruments. ## Where you see it The **Modulators** pane (shipped in Perform) lists every source with its live value and edges. The **Inspector** colours every modulatable widget: arm a source with `modulation.assign.set` (or the panel's assign toggle) and a drag on any widget creates the edge and sets its depth β€” the gesture; the next chapter is the arithmetic behind it. - **FaustWave β€” Faust DSP** Β§ *Live params, modulation and observability* β€” the `[modout]` tag from the instrument's side. - **FaustWave β€” Patchbay & Routing** Β§ *Cable kinds* β€” modulation is the third cable kind beside audio and MIDI. --- <!-- https://faustwave.io/docs/modulation-live/three-writes-and-amounts β€” book: Modulation & Live Control --> # The three writes, and what an amount means A parameter that is modulated has three values that look like one knob, and FaustWave gives each its own verb. Getting this wrong is the single most common way to "set a value that does not stick". | Write | Action | What it changes | Survives | |---|---|---|---| | **live** | `dsp.param.set { instance_id, path, value }` | The number in the running worklet, right now β€” what `dsp.param.get` reads back, post-modulation | until the next modulation tick overwrites it, or the next compile | | **base** | `modulation.base.set { sink_id, path, value }` | The centre the modulation swings around: `clamp(base + sum amount x source)` | the project (`paramBases` for .dsp, the `.builder` file for Builder nodes, the mixer slice for slots) | | **slot** | `master.fx.slot.param.set { slot_id, param_name, value }` / `mixer.tracks.fx.set { params }` | What an FX slot stores and re-applies when it next compiles | the project | The rule of thumb: **a modulated parameter is set through its base.** Writing it live is overwritten on the next tick; writing an unmodulated `.dsp` parameter live is fine and also lands in its base. For a slot, write the slot. ```yaml actions: - action_id: modulation.base.set input: { sink_id: "<module_id>", path: "/MyDsp/cutoff", value: 2400 } label: "β–Ά Set the base a modulator swings around (edit the ids first)" ``` ## Amount is a fraction of the range An edge's `amount` is **not** in the parameter's units. It is a fraction of the sink parameter's declared range, multiplied by the source's output: ``` live = clamp( base + amount x source x (max - min), min, max ) ``` So a macro at 0.6 on `weight_dB` (0..12) with amount 0.2 adds 0.6 x 0.2 x 12 = **1.4 dB**; an LFO at -1..1 on a 400..8000 Hz cutoff with amount 0.12 breathes +/-912 Hz. Two helpers for thinking in *min/max* instead: set `base = (min + max) / 2` and `amount = (max - min) / 2 / range`. ```yaml actions: - action_id: routing.connect input: { from_source: "<source_id>", from_port: "/LFO/out", to_sink: "<module_id>", to_port: "/MyDsp/cutoff", kind: modulation, amount: 0.12 } label: "β–Ά Wire an LFO into a cutoff at 12 % of its range (edit the ids first)" - action_id: routing.list input: {} label: "β–Ά See the edge (sinks appear only while the instrument runs)" ``` Source ports are the `[modout]` addresses from `modulation.source.list` (`/LFO/out`); sink ports are the parameter paths from `dsp.params.list`. Re-connecting the same pair replaces the amount; `routing.disconnect` removes the edge and the base is what remains β€” modulation is non-destructive by construction. ## The demo's three edges `Around the World` wires exactly three, and they show the three kinds of use: - **Breath** β€” `Mod LFO Breath` (0.135 Hz ~ four bars) β†’ Hook Lead `cutoff`, amount 0.12. A slow drift nobody notices until it is gone. - **Step** β€” `Mod SH Bell` (sample-and-hold, sync 1/8) β†’ Bell Hook `width`, amount 0.18. The bell is somewhere else on every eighth. - **Macro** β€” `Mod Macro Erdbeben` β†’ 808 `weight_dB` 0.2, `boom` 0.3, `dirt_level` 0.25. One knob, three targets, no motion of its own β€” the next chapter puts a controller on it. > 🟦 **THE DIAGNOSTIC:** a value "does not stick" β†’ `dsp.param.get` shows the live number; `modulation.source.list` shows whether an edge is on that path; if yes, you wanted `modulation.base.set`. A base "does not apply" after a restart β†’ the sink registers only while the instrument runs; play first. --- <!-- https://faustwave.io/docs/modulation-live/controller-bindings-and-live-fx β€” book: Modulation & Live Control --> # Controller bindings, pads, and live effects Modulation moves a parameter from inside the machine. A **controller binding** moves it from a hand β€” a pad, a knob, a hardware controller β€” and the two compose: the hand writes the **base**, the modulator swings around it. ## Two kinds of binding `controller.bind.add` binds one MIDI trigger to ONE target: | Target | Input | Fires | |---|---|---| | **Action** | `action_id` + `input` | a note-on (`type: "note"`) or a CC crossing 64 upwards (`type: "cc"`) runs the action β€” `sequencer.scene.launch { scene_id }`, `transport.stop`, anything in the palette | | **Parameter** | `sink_id` + `path` (+ `min`, `max`), CC only | the CC is followed continuously, 0..127 mapped into `min..max` (default: the parameter's own range), and written to the parameter's **base** | `channel` is required β€” 1..16, or 0 = any channel. The demo puts its whole control map on channel 16 so it never collides with the instruments on 1–5; use 0 only when nothing else on that port sends the same number, since a hardware sequencer sharing the port would fire the binding on every step. The trigger must reach the **Controller Actions** sink (`controller-actions:midi-in`) β€” route your controller (or the Pads) onto it in the Patchbay; the cable is the enable switch. ```yaml actions: - action_id: controller.bind.list input: {} label: "β–Ά Every binding in this project β€” action or parameter" - action_id: midi.cc.map input: {} label: "β–Ά Who already listens to which CC (before you pick a number)" ``` ## Pads are an instrument The **Pads** surface is a virtual controller on the bus β€” pads send notes, knobs and faders send CCs, buttons send a momentary max/min. It is a MIDI *source* like a hardware device, so everything above applies to it, and a hardware controller that sends the same numbers plays the same map. The demo's surface, 21 widgets: - pads on channel 1/2 that play the Sample Kit and the 808 directly (a note into an instrument's `:midi-in`); - knobs on channel 16 bound to **parameters**: CC 71 β†’ the Earthquake macro's `value`, CC 74 β†’ the Hook Lead `cutoff` (base β€” the LFO keeps breathing around wherever the knob is); - buttons on channel 16 bound to **parameters of a master insert**: CC 80/81/82 β†’ `stutter`, `brake`, `throw` of the Live Pads slot; - pads on channel 16 bound to **actions**: five scenes and STOP. ```yaml actions: - action_id: surface.open input: { surface: faustwave-bundled/pads } label: "β–Ά Open the Pads" - action_id: pads.state input: {} label: "β–Ά The widget list with ids, numbers and channels" ``` ## The live-effects pattern: parameters, not MIDI `FX Live Pads.dsp` in the demo is a master insert with a DJ filter, a beat-repeat stutter, a tape-stop brake, a bit crusher and a reverb throw β€” and it contains **no MIDI at all**. Every control is a plain slider: ```faust filter = hslider("[0]filter", 0.5, 0, 1, 0.001) : si.smooth(ba.tau2pole(0.02)); stutter = hslider("[1]stutter", 0, 0, 1, 1); brake = hslider("[2]brake", 0, 0, 1, 1); bpm = hslider("bpm[bpm]", 130, 30, 300, 0.1); // the live tempo, for a stutter of exactly one eighth ``` Because they are parameters, the same DSP works from the Inspector, from a pads button through a binding, from a hardware controller through the same binding, and from a modulator through an edge β€” four hands on one knob, no code per hand. The binding for a button is one call: ```yaml actions: - action_id: master.fx.slots.list input: {} label: "β–Ά Find the slot id and its parameter names" ``` then `controller.bind.add { type: "cc", number: 80, channel: 16, sink_id: "pattern-live-pads", path: "/Live_Pads/stutter" }` β€” the sink id is the slot's `pattern-<slot_id>`, the path the full Faust address (`controller.bind.list` shows the demo's thirteen). Three details that make it click-free: every gate inside the DSP is ramped (`si.smooth`) so a button press is not a step; the brake releases through a crossfade instead of snapping the delay line back; and the filter is a bit-exact bypass at its centre so "hands off" costs nothing. ## What to try 1. Open Perform, launch TRAP, hold **Stutter** on the pads for a beat, let go on the one. 2. Turn **Erdbeben** to 0.6 β€” three 808 parameters move together; look at the Modulators pane while you do. 3. Turn **Hook Cutoff** while the LFO breathes β€” you moved the base, the breath continues around it. That is the whole model. - **FaustWave β€” Pads** β€” the widget types and EDIT mode. - **FaustWave β€” Patchbay & Routing** Β§ *External clock sync* and Β§ *MIDI Monitor* β€” when a controller "does nothing". - **FaustWave β€” Mixer & Master** Β§ *The Master FX rack* β€” where an insert like Live Pads lives. --- <!-- https://faustwave.io/docs/mixer-master/mixer-and-master β€” book: Mixer & Master --> # Mixer tracks The Mixer is the strip section of the right-side **Master Control** panel. One **MixerTrack** per channel β€” gain / mute / solo / sends + its own FX chain β€” just like a DAW mixer. The key idea: a MixerTrack is an **audio-side entity decoupled from the source that feeds it** (the Ableton / Logic / Bitwig pattern). Sources (a Builder, a Faust `.dsp`) route INTO a track; the track owns the fader, the inserts, the sends, and routes on to `master`. > 🟦 **WHY tracks, not "one strip per module"?** A MixerTrack survives the lifecycle of whatever feeds it. Delete the Builder and the track keeps its fader position, FX chain, and sends β€” re-attach a new source and your mix investment is intact. (Earlier versions tied a strip to a `moduleId`; that's gone.) There is also no bundled "Default Instrument" anymore β€” the master bus starts empty until you add a source. ## Adding audio β€” the "+ ADD AUDIO" picker Master Control starts with **no tracks**. You populate it explicitly: - **"+ ADD AUDIO"** β€” lists the audio-shaped `.builder` DSPs in your **project** (Documents β†’ PROJECT / `assets/`). Pick one; the IDE opens/mounts it as a Source and attaches its output to a MixerTrack. (FX-shaped DSPs β€” those with an `Input` node β€” appear under **"+ ADD FX"** instead and go to the Master-FX rack; see Β§ 2.) Attaching runs a **3-stage Auto-Create-mit-Reconnect resolver** to decide which track the source lands on: 1. **Persisted edge** β€” if this source was attached to a track before, that routing is restored. 2. **Name match** β€” an existing track whose name equals the source's title. 3. **Auto-create** β€” otherwise a fresh track is created (named after the source) and the source routes into it. > 🟦 Merely running a Builder no longer auto-creates a track β€” `registerModuleOutput` just registers the source as routable. Only the **"+ ADD AUDIO"** flow (or its MCP mirror `mixer.tracks.input.set`) creates/attaches a track. This keeps the mixer from filling with strips you didn't ask for. ## Anatomy of a track strip Top-to-bottom: - **Label** β€” the track name (defaults to the attached source's title; rename via `mixer.tracks.rename`). - **FX chain** β€” per-track insert slots (see *Per-track FX* below). Distinct from the global Master-FX rack in Β§ 2. - **Send sliders** β€” one per Master-FX *send* slot, a level into that send bus (default 0). - **Mute / Solo** β€” standard mixer semantics. **Solo wins**: when ANY track is soloed, only soloed tracks pass; un-soloed tracks are silenced regardless of their own mute. Multiple solos allowed. - **Gain fader** β€” linear 0..1 (unity = 1.0), 5 ms ramp so live moves don't click. Display dB via `20 Β· log10(gain)`. Per-track L/R can be split (`ganged: false`) for balance. - **Per-track meter** β€” post-gain peak of this track's contribution to master. - **Γ— delete** β€” removes the track (its inbound sources become unrouted, audio keeps running but silent until re-attached). Track state (`gain` / `muted` / `soloed` / `fxSlots` / `sendLevels` / `routesTo`) persists in the project's `mixerTracks` slice of project.json, so your mix comes back on reload. ## Per-track FX Each track has its own insert chain, independent of the global Master-FX rack. Append a Faust-DSP slot to a track via `mixer.tracks.fx.add` (compiled asynchronously β€” the slot appears immediately, wires in once compile succeeds), toggle bypass via `enabled`, set params via `mixer.tracks.fx.set`. Use per-track FX for channel-strip processing (EQ/comp on one source); use the Master-FX rack (Β§ 2) for bus-wide processing. ## Driving the mixer from MCP Precondition for the chain: at least one MixerTrack exists (add one via **"+ ADD AUDIO"** or `mixer.tracks.input.set` β€” a fresh project has none, and the template below targets track 0). ```yaml actions: - action_id: surface.open input: { surface: faustwave-bundled/mixer } label: "β–Ά 0. Show Master Control (watch the faders move)" - action_id: system.state input: { sections: ["mixer_tracks"] } save_as: m label: "β–Ά 1. Inspect current tracks" - action_id: mixer.tracks.set input: track_id: "{{m.mixer_tracks.tracks.0.id}}" gain: 0.5 label: "β–Ά 2. Drop the first track to βˆ’6 dB" - action_id: mixer.tracks.set input: track_id: "{{m.mixer_tracks.tracks.0.id}}" muted: true label: "β–Ά 3. Mute it" - action_id: mixer.tracks.clear input: { field: mutes } label: "β–  Clear all mutes" ``` > 🟦 **HANDS-FREE PATH**: step 1 fetches the track list; step 2 sets a track's gain (5 ms ramp); step 3 mutes it; the last button un-mutes EVERYTHING via `mixer.tracks.clear { field: mutes }`. The chord-of-MUTES idiom beats iterating track-by-track when the user says "go back to normal". ### The MCP actions | Action | Purpose | |---|---| | `mixer.tracks.create { name?, routesTo? }` | New track (name β†’ `Track N`, routesTo β†’ `master:in`). Returns its id. | | `mixer.tracks.set { track_id, gain? / gain_l? / gain_r? / ganged? / muted? / soloed? / sends? }` | Update fields. `gain` ramps 5 ms; `sends` REPLACES the whole send-set. Solo wins over mute. | | `mixer.tracks.rename { track_id, name }` | Rename (also used to keep track-name = source-title in sync). | | `mixer.tracks.delete { track_id }` | Remove a track; inbound sources become unrouted. | | `mixer.tracks.reorder { ids }` | Reorder the strip list to the given permutation. | | `mixer.tracks.input.set { ... }` | MCP mirror of "+ ADD AUDIO" β€” attach a Source output to a track via the 3-stage resolver. | | `mixer.tracks.fx.add / remove / set` | Per-track FX-chain slot CRUD. | | `mixer.tracks.clear { field: mutes \| solos }` | Reset every track's mute or solo flag in one shot. | | `system.state { sections: ["mixer_tracks"] }` | Snapshot: array of `{ id, name, gainL, gainR, ganged, muted, soloed, fxSlots, sendLevels, routesTo }`. | > πŸ”˜ A legacy `mixer.set { module_id, … }` + `system.state { sections: ["mixer"] }` slice still exist for per-Source-module Faust-param persistence (`slotParams`), but the **visible faders are MixerTracks** β€” reach for `mixer.tracks.*`. ## Sends β€” feeding a track into a Master-FX send slot The Master-FX rack supports **inserts** (serial chain) and **sends** (parallel bus). Each track carries one **send level** per send slot β€” how much of THIS track to feed into that send bus: ``` 1. master.fx.slots.list {} β†’ slot ids (send slots included) 2. mixer.tracks.set { track_id: "<track id from system.state>", sends: { "<send slot id>": 0.4 } } ``` (Not a click-chain β€” the track id and send-slot id are yours to pick from step 1 and `system.state { sections: ["mixer_tracks"] }`.) The `sends` map is **set as a whole** β€” omitting a slot removes its send. To keep a send wired but silent (for a smooth ramp-back-up), pass `0` for that slot id rather than omitting it. ## Common moves - **"Hear only one track, mute the rest"** β€” `mixer.tracks.set { track_id, soloed: true }`. Solo wins. - **"Add my Hub snare to the mix"** β€” install it (it lands in the project), then "+ ADD AUDIO" β†’ pick it; a track auto-creates and the source routes in. - **"Disarm all solos and mutes"** β€” `mixer.tracks.clear { field: mutes }` then `{ field: solos }`. - **"Audition just one source"** β€” set it `soloed: true`, listen, then `mixer.tracks.clear { field: solos }`. Next section: the Master-FX rack β€” inserts vs sends, slot management, per-param control. --- <!-- https://faustwave.io/docs/mixer-master/master-fx β€” book: Mixer & Master --> # The Master FX rack The Master Control panel's middle section is the **FX rack** β€” a chain of effect slots applied to the summed master bus. Each slot is a Faust DSP worklet you can author inline, parameterise live, enable / disable, and reorder. Two slot kinds: - **Insert** β€” in the serial chain. Audio goes through every enabled insert in order. Use for EQ, compression, saturation β€” anything where the wet signal IS the new dry. - **Send** β€” a parallel bus. Each mixer track taps in at its own send level (see Β§ 1 *Sends*). Use for reverb, delay, modulated tape echoes β€” anything you'd want partially wet, per-source. > 🟦 **Master-FX rack vs per-track FX**: this section is the **global** rack on the summed master bus. Each MixerTrack ALSO has its own insert chain (Β§ 1 *Per-track FX*, `mixer.tracks.fx.*`) for channel-strip processing. Same Faust-slot mechanism, different scope. The rack ships with three built-in convenience slots (reverb / delay / filter) so you have something to fiddle with on first open. They're not magic β€” they're regular slots, removable + replaceable like anything you add yourself. ## The slot lifecycle A slot moves through three states: 1. **Added** β€” you've called `master.fx.slot.add` (or the UI's *Add Slot* button). The slot row appears with its label + params. `available: false` while the worklet is still compiling. 2. **Available** β€” the Faust source has compiled successfully and the AudioWorklet is live. `available: true`; the slot is now in the audio path. UI lights the slot green. 3. **Removed** β€” `master.fx.slot.remove`. Worklet disposed, chain rewires, the slot vanishes from the panel. The `available` flag matters because `master.fx.slot.add` returns immediately but the libfaust compile is asynchronous (Web Worker round-trip). If you add a slot then immediately fire `master.fx.slot.param.set`, the param call queues until the worklet binds β€” graceful but worth noting. ## Authoring a custom slot A slot's `source` is a tiny self-contained Faust program. Canonical shape β€” **stereo in, stereo out** (paired ports): ```faust import("stdfaust.lib"); process(in_l, in_r) = (in_l : <your processing>), (in_r : <your processing>) ; ``` Use any libfaust function (`fi.lowpass`, `re.zita_rev1`, `ef.cubicnl`, etc.). Every `hslider("<label>", ...)` you declare becomes a callable param via `master.fx.slot.param.set`. Since params are shared across both channels, define the per-channel processing once as a named function and apply it to `in_l` and `in_r`. > 🟦 **MONO LEGACY**: an old-style `process(in)` source still compiles β€” the engine downmixes the stereo bus into the single input β€” but the slot strip flags it ("mono on input") and it collapses the stereo image of everything running through it. Author the stereo signature. A Builder FX-shaped graph (one with an `input` node) already emits `process(in_l, in_r)` and drops straight into a slot, no folding needed. Validate unfamiliar sources via the `.dsp` editor's **Compile** button before mounting (see **FaustWave β€” Builder** Β§ 2). Example β€” a resonant lowpass with cutoff + resonance: ```faust import("stdfaust.lib"); cutoff = hslider("cutoff", 1000, 50, 12000, 1) : si.smoo; q = hslider("q", 1.0, 0.1, 20, 0.01) : si.smoo; lp(x) = x : fi.resonlp(cutoff, q, 1.0); process(in_l, in_r) = lp(in_l), lp(in_r); ``` After adding this as a slot, `master.fx.slot.param.set { slot_id, param_name: "cutoff", value: 2000 }` sets cutoff to 2000 Hz live. The `si.smoo` on input avoids zipper noise from MCP-driven param changes. ## MCP surface ```yaml actions: - action_id: master.fx.slots.list input: {} save_as: slots label: "β–Ά 1. Snapshot current slots" - action_id: master.fx.slot.add input: kind: insert label: "Saturator" source: | import("stdfaust.lib"); drive = hslider("drive", 1.0, 1.0, 20.0, 0.1) : si.smoo; sat(x) = x * drive : ef.cubicnl(0.3, 0.0); process(in_l, in_r) = sat(in_l), sat(in_r); save_as: sat label: "β–Ά 2. Add a saturator insert" - action_id: master.fx.slot.param.set input: slot_id: "{{sat.id}}" param_name: drive value: 4.0 label: "β–Ά 3. Crank drive to 4" - action_id: master.fx.slot.enabled.set input: slot_id: "{{sat.id}}" enabled: false label: "β–Ά 4. Bypass it" - action_id: master.fx.slot.remove input: slot_id: "{{sat.id}}" label: "β–  5. Remove it" ``` > 🟦 **HANDS-FREE PATH**: step 1 reads the current rack so you can see what's there; step 2 inserts a cubic-saturator (`ef.cubicnl` from libfaust) at the end of the chain; step 3 drives it harder live; step 4 bypasses it (audio routes around the slot without dropping the worklet); step 5 removes it entirely. Hit **Reset** between runs to clear saved chain state. ### The actions | Action | Purpose | |---|---| | `master.fx.slots.list` | Every slot, in routing order: `{ id, label, kind, enabled, params, source, available }`. | | `master.fx.slot.add { kind, label, source, params? }` | Compile + insert. Sends append to the parallel bus; inserts append to the serial chain end. | | `master.fx.slot.remove { slot_id }` | Drop the slot, rewire the chain. Built-in slots can also be removed. | | `master.fx.slot.enabled.set { slot_id, enabled }` | Bypass / unbypass. Sends ramp their return gain; inserts get bypassed via a chain rebuild. | | `master.fx.slot.param.set { slot_id, param_name, value }` | Set one param. `param_name` is the literal Faust slider label. Special name `returnLevel` rides the JS-side return gain on sends. | ## Send slots in depth A send slot looks identical to an insert source-wise (same stereo `process(in_l, in_r)` shape) but its routing is different: - Inserts: `master_dry β†’ insert₁ β†’ insertβ‚‚ β†’ … β†’ output`. The whole bus goes through. Disabling routes around. - Sends: `master_dry β†’ output` AND `(track₁ send₁ + trackβ‚‚ send₁ + …) β†’ send₁ worklet β†’ returnLevel β†’ output`. Tracks contribute parallel taps; the send worklet runs once on the sum; the wet returns at `returnLevel` and sums to the output. Two controls on a send slot: - The Faust params you declared (`hslider` labels). - `returnLevel` β€” the JS-side gain on the send's return path. Set via the same `master.fx.slot.param.set` action with `param_name: "returnLevel"`. Ramps smoothly; no recompile. V1 caveat (see Β§ 1): per-track send taps are wired at the track side but the engine-side tap isn't fully live yet. Insert slots are the load-bearing path today; send slots compile and own their bus. ## Reordering inserts The chain order matters for inserts (a saturator before EQ sounds different to EQ before saturator). V1 doesn't expose a reorder action β€” the order is the order of `add` calls. To reorder, remove and re-add in the desired sequence. The next iteration of the rack UI will expose drag-to-reorder. ## Common moves - **"Bypass the whole FX chain to A/B with the dry mix"** β€” iterate `master.fx.slot.enabled.set { enabled: false }` over every slot; revert with `enabled: true` per slot. (A single "bypass all" action is on the TODO list.) - **"Audition a built-in reverb"** β€” the rack ships with a `reverb` slot pre-added; `master.fx.slot.enabled.set { slot_id: "reverb", enabled: true }` and mixer-strip send levels do the rest. - **"Try a Hub-installed FX DSP as a master insert"** β€” install the FX-shaped `.builder` (it lands in the project), then use the panel's **"+ ADD FX"** picker to drop it into the rack. (Scripted equivalent: `builder.export` the rendered Faust source, feed it into `master.fx.slot.add`.) Next section: master volume, the meter, softclip, and clip recovery. --- <!-- https://faustwave.io/docs/mixer-master/master-volume-meter β€” book: Mixer & Master --> # Master volume, meter, softclip The bottom of the Master Control panel is the final stage: the **master volume** (last gain before the output device), the **stereo peak meter**, the **CLIP indicator**, and the **softclip safety**. These are the four controls between your mix and your speakers. ## Master volume Linear 0..1, unity = 1.0. The meter, softclip, and clip indicator all sit AFTER the master volume in the signal chain β€” so they reflect what's actually reaching the device, not what the mix bus looks like internally. **Above-unity boost is intentionally not allowed.** If you're constantly wishing for it, the right move is to bring strip gains UP rather than pushing master past 1.0 β€” you have a strict per-strip 0..1 and a strict master 0..1, so the mix and the master ride the same scale. The fader applies a 5 ms ramp on changes (no clicks). The setting persists per project. > πŸ”˜ **MCP**: `master.volume.set { volume: 0..1 }`. Same 5 ms ramp. ## The meter Stereo peak meter, decaying over ~1 s after a peak. Each channel shows: - **Bar** β€” current peak (linear; the bar's visual scale is dB-spaced so the upper half feels finer-grained than the lower half). - **CLIP indicator** β€” the red box at the top of each channel. Sticky once lit β€” stays on until you clear it manually, even after the offending peak has decayed. The peak value is also exposed in `system.state { sections: ["master"] }` as `peak` (per-channel) plus a `clip` Boolean (true after any clip until cleared). Useful when the AI wants to know "did that change clip the master?" without polling the UI. > πŸ”˜ **MCP**: `system.state { sections: ["master"] }` returns `{ volume, softclip, peak: [L, R], clip, sampleRate, latencyMs }`. ## CLIP indicator + clip recovery A single output sample reaching β‰₯1.0 trips the clip indicator. It's sticky so a transient clip you missed visually still shows up; you have to acknowledge it explicitly. Clear via the panel's red **CLIP** button or: > πŸ”˜ **MCP**: `master.clip.clear`. Returns the indicator to neutral; the next clip lights it fresh. Why sticky: clipping in a long working session is the kind of thing you want to know about even if you weren't watching the meter at the moment it happened. Auto-clearing every sample would hide intermittent clips. ## Softclip A gentle tanh-based saturation **at master output**, enabled by default. Catches digital overs at no audible cost for typical mix levels β€” if your mix is well-leveled, you won't hear softclip; if you push hot, it absorbs +0 to +3 dB worth of headroom with a musical roll-off rather than a hard digital crack. Turn off only when: - Monitoring at low levels and you want a perfectly linear output (rare). - Auditioning unprocessed master output (e.g. comparing against a reference mix). - Measuring something that needs a strictly linear output stage (calibration / metering tests). Setting persists across sessions β€” you don't have to re-flip it every project. > πŸ”˜ **MCP**: `master.softclip.set { enabled: true | false }`. ## The four-control end-to-end demo ```yaml actions: - action_id: system.state input: { sections: ["master"] } save_as: m label: "β–Ά 1. Snapshot master state" - action_id: master.volume.set input: { volume: 0.7 } label: "β–Ά 2. Drop master to 0.7" - action_id: master.softclip.set input: { enabled: false } label: "β–Ά 3. Bypass softclip (audition unprocessed)" - action_id: master.softclip.set input: { enabled: true } label: "β–Ά 4. Re-enable softclip" - action_id: master.clip.clear input: {} label: "β–  Clear any clip indicator" ``` ## Hiding the panel without stopping audio The Master Control panel is a UI surface, not the audio engine. `surface.open { surface: "faustwave-bundled/mixer" }` puts it on screen; audio keeps running whatever the panel state is. Useful when you want screen real estate for the Builder canvas during a sound-design session. ## Common moves - **"Why am I clipping?"** β€” `system.state { sections: ["mixer_tracks", "master"] }`, find the track with the highest per-track meter, pull its gain down. If softclip is bypassed, re-enable it as a safety net. - **"Calibrate output level"** β€” `master.softclip.set { enabled: false }` for a linear path, then set every strip gain to 1.0 and master to your reference value; compare against an external meter. - **"Set up a mix-down session"** β€” lower master to ~0.7 to leave headroom for tweaks; trust the per-strip meters + softclip; reach for the CLIP indicator to confirm the mix sits under digital full-scale. ## What this panel doesn't expose A few master-bus features live elsewhere in the IDE: - **The master bus as a Source** β€” the master is also a routing-table Source (`master` of kind `audio`), so you can wire it into another Sink (e.g. tap into the Recorder for capture, or feed back into a Builder for feedback experiments). See **FaustWave β€” Patchbay** Β§ 1. - **The master FX rack** β€” separate section, see Β§ 2. - **Per-track gain / mute / solo / sends + per-track FX** β€” separate section, see Β§ 1. ## Where to go from here - **FaustWave β€” Recorder** for capturing the master output (the Recorder taps the same post-volume signal you hear). - **FaustWave β€” Patchbay** for understanding why master is BOTH a Source and a Sink in the routing table. - **FaustWave β€” AI Assistant** for driving the four controls (volume / softclip / clip / state) from a chat conversation. --- <!-- https://faustwave.io/docs/patchbay/routing-model β€” book: Patchbay & Routing --> # The routing model FaustWave's audio + MIDI topology is a **single declarative routing table**, JACK-style. Every signal-producing entity is a **Source**, every signal-consuming entity is a **Sink**, and a **Connection** wires one Source's output port to one Sink's input port. The Patchbay is the matrix editor for that table; the rest of this book is about what you can do with it. > 🟦 **NEW TO THIS?** "JACK-style" means the routing graph is **explicit, addressable, and observable**: every port has a stable id, every wire is a first-class object, and you can list / connect / disconnect with one call. The DAW you came from probably had implicit routing ("the synth's output goes to the master" β€” invisible). Here, every wire is in the Patchbay matrix and in `routing.list`. ## Three entity types ### Sources β€” things that PRODUCE Anything that emits an audio buffer, a MIDI event, or a control value is a Source. From a fresh project you have: - `keyboard-default` (MIDI) β€” the on-screen Keyboard. - `sequencer-default` (MIDI) β€” the step grid. - Hardware MIDI inputs (one Source per device, e.g. `midi-input-input-0`). - `master` (audio) β€” the master bus's pre-fade tap, so you can route the mix into the Recorder or back into a Builder. - Every Builder / Faust-DSP module you open registers itself: `builder-<id>` (audio). - Every running instrument you've installed exposes its audio out (`<instrument-id>`, audio). - Each **MixerTrack** is an audio Source too (`<track-id>`) β€” it routes the track's post-fader signal on to `master`. Each Source has one or more **output ports**. Most have a single port called `out`; multi-output entities (a few specialised modules) carry more. ### Sinks β€” things that CONSUME Anything that wants to receive audio, MIDI, or modulation values is a Sink: - `master` (audio) β€” the master bus input. - Each **MixerTrack** as an audio Sink (`<track-id>:in`) β€” Sources route INTO a track here; the track applies its fader / mute / solo / FX / sends, then routes on to `master`. - `recorder-default` (audio) β€” the bundled Recorder. - Hardware MIDI outputs (one Sink per device, e.g. `midi-output-output-1`). - Every MIDI-accepting module's MIDI side β€” a Builder with a `midi_note` node (appears once the node binds), or an installed instrument's `<instrument-id>:midi-in`. - An instrument's modulation side as `<instrument-id>` (kind `modulation`) β€” one **input port per exposed Faust param** (e.g. `<instrument-id>/cutoff`). The modulation Sink is the special one: a single entity exposes a whole **bank of per-parameter ports**, and you wire modulator Sources (LFOs, envelope followers) to specific params. ### Connections β€” the wires A Connection has: - A `from` { sourceId, portId } pair (the producer side). - A `to` { sinkId, portId } pair (the consumer side). - A `kind` β€” `"audio"`, `"midi"`, or `"modulation"`. - A `gain` (audio) or `amount` (modulation) or just an `enabled` flag (MIDI). - An auto-generated `id` of the form `conn:<sourceId>:<sourcePort>-><sinkId>:<sinkPort>`. The id is **deterministic** β€” calling `routing.connect` twice for the same endpoints doesn't create two wires, it returns the same id (idempotent). ## The three cable kinds | Kind | Carries | Extra control | UI cell | |---|---|---|---| | `audio` | Sample buffers (stereo by default, channel count from the port). | `gain` (linear 0..1, 5 ms ramp on changes; `enabled` suspends with a ramp to 0). | Cyan cell in the matrix. | | `midi` | MIDI events (note-on / note-off / CC / clock / sysex). | `enabled` only β€” a Boolean dispatch gate. No gain ("50 % MIDI" makes no sense). | Purple cell in the matrix. | | `modulation` | Per-param control values (LFO output, envelope follower, slow CCs converted to param-rate). | `amount` (signed scalar; modulator output scaled before reaching the param). | Reachable via `routing.connect` + the Modulators panel; not in the matrix V1. | **Kind-mismatch is rejected**: you can't wire an audio Source to a MIDI Sink. The matrix greys those cells out; `routing.connect` returns an error if you try via MCP. ## The audio path A fresh project ships with **nothing pre-wired** β€” there's no bundled instrument and no seeded connections. You build the topology explicitly. The audio path for a DSP reads: ``` builder-<id> β†’ <track>:in (audio β€” created by the mixer's "+ ADD AUDIO") <track> β†’ master (audio β€” the MixerTrack's own output) ``` i.e. a Source routes INTO a **MixerTrack** (which carries the fader / mute / solo / FX / sends), and the track routes on to `master`. Opening + running a Builder registers `builder-<id>` as a Source but does NOT wire it anywhere on its own β€” you add it to a track via the Master panel's **"+ ADD AUDIO"** picker (or `mixer.tracks.input.set`). The route then persists, so re-opening the DSP restores it. MIDI is wired the same explicit way β€” `keyboard-default` / `sequencer-default β†’ <your Builder's midi-in>`. Multiple Sources can route into one track, and multiple tracks sum into `master`. The right-panel mixer shows one strip per **MixerTrack**, with the track's gain as the fader. ## Permanent vs ephemeral Every entry in `routing.list` carries a `permanent` flag: - `permanent: true` β€” the entity is built into FaustWave's posture (master, Keyboard, Sequencer, Recorder, hardware MIDI devices). Removing the underlying module is either impossible or auto-respawns. - `permanent: false` β€” user-created entities (Builders + Faust DSPs you opened, MixerTracks you added). Closing the module / deleting the track unregisters its Source / Sink and disconnects its wires. When you delete a Builder, its wires don't "leak" β€” the routing engine reconciles on the next diff cycle and tears down anything the closed module owned. ## What this gets you Because routing is observable + addressable + MCP-driven, every routing decision is: - **Visible** β€” the Patchbay matrix + the Activity log show every connect / disconnect. - **Scriptable** β€” the AI assistant (or your own MCP client) can build complex wirings end-to-end. See Β§ 4 in this pack. - **Survives restart** β€” connections persist in the project. Re-opening the project re-binds them once each Source / Sink rebinds. Next section: opening the Patchbay matrix and using it. --- <!-- https://faustwave.io/docs/patchbay/matrix-editor β€” book: Patchbay & Routing --> # The Patchbay matrix The routing table from Β§ 1 has a UI: the **Patchbay** rail panel renders the routing table as a JACK-style **Sources Γ— Sinks matrix**. One row per Source, one column per Sink, and a cell at every intersection where a connection is possible. ## Opening it ```yaml actions: - action_id: patchbay.toggle input: { open: true } label: "β–Ά Open the Patchbay" ``` Click the **Patchbay** icon in the left rail (the cable icon) β€” or the button above. The panel opens at the right edge of the rail. The footer shows the current connection count (e.g. `7 connections`), so you can tell at a glance whether your project is sparsely wired or full of routing. ## Reading the matrix The matrix splits cleanly into two zones: - **Audio block** (top-left) β€” audio Sources Γ— audio Sinks. - **MIDI block** (bottom-right) β€” MIDI Sources Γ— MIDI Sinks. - The off-diagonal quadrants (audio Source Γ— MIDI Sink and vice versa) are **greyed out** β€” kind-mismatched cells can't connect, by construction. A cell has three possible states: | State | Meaning | Click does | |---|---|---| | **Greyed out** | Kind-mismatched (audio Γ— MIDI). | No-op. | | **Empty hollow** | Compatible kinds, NOT currently connected. | Creates the connection. | | **Filled (cyan for audio, purple for MIDI)** | Currently connected. | Disconnects. | Row + column headers use the entity's **label** (e.g. *"Builder: bass-acid-303.builder"*, *"Track: Bass"*) so you don't need to read raw module ids. The kind icon (audio waveform vs music note) sits next to each header for a quick scan. ## V1 takes the first port Most Sources expose a single `out` port; most Sinks a single `in`. The matrix V1 takes the **first declared port** of each entity as the canonical one, so each Source / Sink gets exactly one row / column: - Builders / Faust DSPs use `out`. - Master + Recorder use `in` on the Sink side, `out` on the Source side. - Send slots (when present) use `send-in`. - An installed instrument exposes one MIDI port (`in`) AND a modulation port per param β€” the matrix V1 doesn't render the per-param modulation column (use `routing.connect` from the MCP side, or the Modulators panel UI). **Multi-port matrices** (one column per port for entities that expose several) are a future polish; today the matrix is one row / one column per entity. ## Making + breaking a connection Three real-world tasks, one click each: ### Sending a Builder to the mixer The normal way is the Master panel's **"+ ADD AUDIO"** picker β€” it creates a MixerTrack and wires the Builder into it in one step. In the matrix you can do the same by hand: open the Builder, wait for its source to appear, then click the cell at `(Builder row, <track>:in column)` in the audio block (a track must exist first). The cell fills cyan; the Builder now feeds that track, which sums into master. > A Builder does **not** auto-wire to master on its own anymore β€” opening/running it only registers the Source. You attach it to a MixerTrack explicitly (via "+ ADD AUDIO" or the matrix). Once attached the route persists across re-opens. ### Routing the Keyboard to your own Builder Your Builder uses a `midi_note` node (so it accepts MIDI in). Find the Builder in the MIDI block as a Sink β€” it appears once the `midi_note` node binds. Click the cell at `(keyboard-default row, your-Builder column)`. The cell fills purple. Pressing a key on the Keyboard now drives your Builder, alongside any other sink `keyboard-default` is wired to. ### Disconnecting a hung wire A filled cell stays connected even when its underlying entity is gone (e.g. if you closed the project before disconnecting). Re-open the project, click the still-filled cell, the wire's gone. Routing-engine reconciliation handles this without ceremony. ## What the matrix DOESN'T show A few routing-table features live outside the V1 matrix: - **Per-cable gain** β€” the matrix is a Boolean on/off editor. Adjust audio connection gain via the **Mixer** panel (one fader per Source-to-master connection) or via `routing.set { connection_id, gain }` from MCP. - **Modulation cables** β€” the per-param modulation Sinks aren't columns in V1. Use the **Modulators** panel (LFO bind UI) or `routing.connect { kind: "modulation", amount: ... }`. - **Cable suspend (enabled flag)** β€” no UI hook in V1. `routing.set { connection_id, enabled: false }` to mute a wire without removing it; useful for A/B comparisons. If you find yourself wanting any of these as a click, file an issue on the project β€” the V2 matrix is on the roadmap once the V1 UX has soaked. ## The audible-consumer hint, revisited When the matrix has zero audio paths reaching `master` (or every reaching MixerTrack is muted), the **Mixer** panel surfaces a **Smart Mute Hint** badge. It's the matrix's quiet way of telling you "you're playing but no path will be heard". Common cause: you haven't added the source to a MixerTrack yet ("+ ADD AUDIO"), or every track is muted. ## Activity gets every edit Every click in the matrix routes through `api.actions.run('routing.connect' | 'routing.disconnect')`, so the **Activity** rail panel (right side) shows each connect / disconnect as a discrete log entry. Useful for: - **Auditing what just happened** β€” if a colleague (or the AI) was wiring and you want to see the trail. - **Spotting a misclick** β€” if you accidentally disconnected something, Activity shows the previous connect + the rogue disconnect, ready to recreate. Next section: the differences between audio, MIDI, and modulation cables β€” gain, amount, the enabled flag, and which one to reach for. --- <!-- https://faustwave.io/docs/patchbay/cable-kinds β€” book: Patchbay & Routing --> # Cable kinds: audio, MIDI, modulation The routing table carries three kinds of cable. They share the same `from` / `to` / `id` shape but their per-cable controls and their behaviour under change are different. Knowing which knob to reach for saves you from "why is this MIDI cable's gain ignored?" debugging. ## Audio cables **Carry**: sample buffers. The port's `channels` field declares the channel count (1 = mono, 2 = stereo, more for multi-channel outs). The routing engine fans channels out as needed when a Source's port count and the Sink's port count differ β€” a mono Source feeding a stereo Sink duplicates to both channels. ### Stereo cables on the Builder canvas Inside a Builder graph (one zoom level deeper than the routing table), paired-port stereo Kinds carry their `channels: 2` marker into the visual: the Builder's wire-validator treats paired-port halves (Output's `in_l` / `in_r`, mono_to_stereo's `out_l` / `out_r`, etc.) as siblings + the cable stroke-width doubles when both halves are wired. Connecting only one half surfaces a "wire the partner too" hint. Same data model as the routing-table audio cable (this is all rendered in libfaust later) β€” just made visible at the Builder graph level. See **FaustWave β€” Builder** Β§ 1 *Paired-port stereo* and **FaustWave β€” Node-Pack Authoring** Β§ 3 for the underlying `channels: 2` semantics. **Per-cable controls**: - `gain` β€” linear 0..1, default 1.0 (unity). Display this as dB via `20 Β· log10(gain)`. Setting `gain: 0.5` is roughly βˆ’6 dB. - `enabled` β€” Boolean. `false` suspends the cable: the routing engine ramps the per-connection GainNode to 0 over 5 ms. Re-enabling ramps back to the declared gain. Both controls are **rendered as a 5 ms time-constant ramp**, so changing gain while audio is flowing doesn't click. You can therefore wire `routing.set { gain }` into an MCP action chain without click-prevention guards. **Mixer fader == per-cable gain**: when you move a track fader in the Mixer panel, the change writes through to `routing.set { connection_id: <cable>, gain }` for that MixerTrack's connection to `master`. The Mixer is the per-track UI for the audio side of the routing table. ## MIDI cables **Carry**: MIDI events β€” note-on / note-off, CC, program change, clock, sysex. Events dispatch when the Source emits them; there's no continuous sample-rate buffer to ramp. **Per-cable controls**: - `enabled` β€” Boolean. `false` makes the dispatch loop skip the Sink for this cable's events. No gain (it would be meaningless: 50 % of a note-on is just a note-on). - **No gain field.** `routing.set { connection_id: <midi cable>, gain: 0.5 }` returns an error. **Per-Sink channel filtering** is independent of the cable's enabled flag: a Builder's `midi_note` node listens on its own configured channel; a MIDI cable fan-out delivers events to every wired Sink, each Sink filters by channel locally. So muting a MIDI cable is the routing-table answer to "stop these events from reaching this Sink"; channel filtering is the per-Sink answer to "stop reacting to these specific channels". ## Modulation cables **Carry**: scalar control values from a modulator (LFO, envelope follower, slow CCs converted to param-rate) to a specific Faust param on an instrument. **Per-cable controls**: - `amount` β€” signed scalar (typically βˆ’1..1, but the routing engine doesn't clamp; the Sink param's own range absorbs the scaling). - `enabled` β€” Boolean. Suspends modulation; the underlying param stays at its last set value, no ramp needed because the modulator simply stops contributing. **No `gain`** β€” use `amount` for scaling. **The Sink is per-param**: a single instrument exposes one modulation Sink entity, with a separate **input port per exposed param**. Wiring an LFO to `cutoff` and a second LFO to `resonance` is **two separate cables** to the SAME Sink entity, distinguished by `to.portId` = `<instrument-id>/cutoff` vs `<instrument-id>/resonance`. Discover the exposed param ports with `routing.list` and filter `sinks` for `kind === "modulation"`. Each entry's `inputs[]` is the bank of param ports. ## When to use which | You want to… | Reach for | |---|---| | Send the Builder's output to a MixerTrack | Audio cable | | Make the Keyboard play your DSP | MIDI cable | | Sidechain-duck the Builder from the bass | Modulation cable from envelope follower β†’ DSP gain param | | LFO-modulate the filter cutoff | Modulation cable from LFO β†’ `cutoff` param port | | Route an external MIDI controller into the Sequencer | MIDI cable from hardware-input Source β†’ Sequencer's record-armed track | | Sync FaustWave's tempo to a hardware sequencer / another DAW | MIDI cable from the clock-sending input β†’ **Transport Clock In** (Β§ 5) | | Tap the master mix into the Recorder | Audio cable from `master` Source β†’ `recorder-default` Sink | | A/B mute a cable without losing the wire | Set `enabled: false` (works on all three kinds) | ## Common gotchas - **Audio cable looks dead but everything checks out**: confirm `gain > 0` AND `enabled: true` AND the Source module is running (Builder's RUN button is on, not just open). The cable can be wired and the Source not producing audio. - **MIDI cable wired but Sink doesn't react**: the Sink might be filtering on a different channel than the Source sends. Match the Source's `keyboard.channel.set` / sequencer track channel to the Sink's expected channel (or set the Source to Omni / channel 0). - **Modulation cable wired but the param doesn't move**: confirm `amount β‰  0`. Also confirm the modulator Source is actually emitting β€” an LFO with rate 0 produces a constant value, and a freshly-added LFO might not be running until you trigger it. - **β€œMy channel switch stranded a note”**: see the Keyboard section Β§ *Stuck notes*. The fix is `keyboard.notes.clear` before the channel change. ## Multiple cables to the same Sink - Multiple Audio cables into `master` β†’ typically one per MixerTrack (sources feed tracks, tracks feed master); they sum. Several sources can also feed one MixerTrack's `:in`, summing before the track fader. - Multiple MIDI cables into one `*:midi-in` Sink β†’ fine, every cable's events interleave at the dispatch layer (the Sink processes them in arrival order). - Multiple Modulation cables to the same param port (e.g. two LFOs both wired to `cutoff`) β†’ the param sees their **sum** Γ— their per-cable `amount`. Useful for layered modulation; mind the headroom. Next section: doing all of this from MCP β€” routing.list, routing.connect, routing.disconnect, routing.set. --- <!-- https://faustwave.io/docs/patchbay/scripting-via-mcp β€” book: Patchbay & Routing --> # Scripting routing via MCP Every click in the matrix routes through an action. Every action is also MCP-callable. So the assistant (or a headless MCP client like Claude Desktop) can read the routing table, build wirings, swap them in and out, and tear them down β€” same engine, no hidden side door. ```yaml actions: - action_id: patchbay.toggle input: { open: true } label: "β–Ά 0. Open the Patchbay (watch the matrix)" - action_id: routing.list input: {} save_as: rt label: "β–Ά 1. Snapshot the current routing table" - action_id: documents.add input: { type: builder, title: "Patchbay demo" } save_as: b label: "β–Ά 2. Spawn a demo Builder" - action_id: builder.graph.node.add input: module_id: "{{b.module_id}}" kind: midi_note save_as: mn label: "β–Ά 3. Add a midi_note node" - action_id: builder.graph.node.add input: module_id: "{{b.module_id}}" kind: osc save_as: osc label: "β–Ά 4. Add an osc" - action_id: builder.graph.node.add input: module_id: "{{b.module_id}}" kind: output save_as: out label: "β–Ά 5. Add an output" - action_id: builder.graph.connect input: module_id: "{{b.module_id}}" source_node_id: "{{mn}}" source_port: freq target_node_id: "{{osc}}" target_port: freq_in label: "β–Ά 6. Wire midi_note.freq β†’ osc" - action_id: builder.graph.connect input: module_id: "{{b.module_id}}" source_node_id: "{{osc}}" target_node_id: "{{out}}" target_port: in_l label: "β–Ά 7. Wire osc β†’ output" - action_id: dsp.run input: { module_id: "{{b.module_id}}" } label: "β–Ά 8. Run it β€” THIS registers its MIDI sink in the matrix" - action_id: routing.connect input: from_source: keyboard-default from_port: out to_sink: "{{b.module_id}}:midi-in" to_port: in kind: midi save_as: kbd_wire label: "β–Ά 9. Wire Keyboard β†’ the demo Builder's MIDI in" - action_id: routing.set input: connection_id: "{{kbd_wire.id}}" enabled: false label: "β–Ά 10. Suspend the wire (notes won't pass)" - action_id: routing.set input: connection_id: "{{kbd_wire.id}}" enabled: true label: "β–Ά 11. Resume the wire" - action_id: routing.disconnect input: connection_id: "{{kbd_wire.id}}" label: "β–Ά 12. Drop the wire entirely" - action_id: dsp.stop input: { module_id: "{{b.module_id}}" } label: "β–  13. Stop the demo DSP" ``` > 🟦 **HANDS-FREE PATH**: the chain shows the full lifecycle β€” read, connect, suspend, resume, disconnect β€” and it's self-contained: a fresh project has no bundled instrument, so steps 2–7 build a throwaway MIDI-responsive Builder. The load-bearing detail is step 8: **a Builder's `<module_id>:midi-in` sink only appears in the routing table while the DSP RUNS** β€” the worklet's MIDI subscription is the binding, not the node on the canvas. (That's also why a silent Patchbay row for your own DSP usually means: not running.) Step 9 wires the Keyboard to it and saves the connection as `kbd_wire`; steps 10–11 suspend + resume it by id; 12–13 tear down. To wire YOUR OWN DSP instead, read its id from step 1's `routing.list` and use `<module-id>:midi-in`. Close the demo Builder tab afterwards β€” it was never saved. Hit the **Reset** button (top-right of the actions block) to clear the saved chain state before re-running. ## The four actions All under the `ROUTING` category in `palette.list`. ### `routing.list` Returns the snapshot: `{ sources, sinks, connections }`. Lightweight (no audio buffers, no event history), safe to call as often as you want. Identical payload to `system.state { sections: ["routing"] }` β€” use `routing.list` for routing-only sweeps so the Activity log reads cleanly. The full table grows with the project. Two optional filters narrow it: `kind` (`"audio"`, `"midi"` or `"modulation"`) keeps only that kind in all three sections; `module_id` keeps that module's own sources and sinks plus every connection touching them. ```json { "kind": "audio", "module_id": "builder-mp91" } ``` ### `routing.connect` Add (or replace) a connection. ```json { "from_source": "<source id>", "from_port": "<port id on the Source>", "to_sink": "<sink id>", "to_port": "<port id on the Sink>", "kind": "audio" | "midi" | "modulation", "gain": 1.0, // audio only, default 1 "amount": 1.0, // modulation only, default 1 "enabled": true // default true } ``` Returns the created connection with its auto-generated id. Idempotent on the deterministic id `conn:<from_source>:<from_port>-><to_sink>:<to_port>` β€” re-calling with the same endpoints **replaces** rather than duplicates. ### `routing.disconnect` ```json { "connection_id": "<id from routing.list or routing.connect>" } ``` Idempotent β€” disconnecting an unknown id is a silent no-op. ### `routing.set` Update `gain` and/or `enabled` on an existing connection without dropping + recreating it. ```json { "connection_id": "<id>", "gain": 0.5, // audio cables only; MIDI rejects this field "enabled": false } ``` 5 ms ramp applies to audio gain + enable/disable. Use `enabled: false` for an A/B mute that preserves the wire (the matrix's click toggle is connect/disconnect; `routing.set { enabled }` is the surgical option for keeping the wire in place). ## Discovering ids before connecting The JACK-style addresses (`<id>:<port>`) are stable per entity but you usually need to discover them. Two patterns: ### From routing.list ```yaml # 1. routing.list β†’ inspect `sources[]` and `sinks[]` for the ones you want # 2. read each entry's `id` and its first `outputs[0].id` / `inputs[0].id` # 3. plug into routing.connect ``` For permanent entities the ids are stable across sessions: - `keyboard-default`, `sequencer-default` (MIDI Sources, port `out`) - `master` (audio Sink, port `in`; also an audio Source, port `out`) - `recorder-default` (audio Sink, port `in`) - `midi-input-<deviceId>` / `midi-output-<deviceId>` (hardware, one per device). There is no bundled instrument. User-created entities β€” Builders + Faust DSPs (`builder-<short hash>`, generated), MixerTracks (`mtrack-<…>`, as both an audio Sink `:in` and an audio Source), and an installed instrument's `:midi-in` / modulation ports β€” have generated ids; pull the current ones from `routing.list`. ### From documents.list `documents.list` lists the currently-open modules and their `module_id`s. The routing-table id for a Builder is the same as its `module_id`. Useful when you opened the Builder yourself and want to wire it without a `routing.list` round-trip. ## A common end-to-end task: wire your Builder to master 1. `documents.add { type: "builder" }` β†’ you have a `module_id`. 2. `builder.graph.node.add { module_id, kind: "osc" }` and `builder.graph.node.add { module_id, kind: "output" }` to build the DSP. 3. `builder.graph.connect { module_id, source_node_id, target_node_id }` for the wire. 4. `dsp.run { module_id }` to compile + start. 5. The auto-routing seed has already wired `<module_id> β†’ master` for you. If you want to verify: `routing.list` and look for the connection with `from.sourceId === module_id`. 6. If you want to tap the same DSP into the Recorder ALSO: `routing.connect { from_source: module_id, from_port: "out", to_sink: "recorder-default", to_port: "in", kind: "audio" }`. The Builder now feeds both master and Recorder. ## Watching every edit `activity.recent` returns the rolling log of every action the registry has dispatched, including the ones the matrix clicks fire. When you're scripting a routing change, you can verify it landed by inspecting Activity's tail (your call lands as one entry; the engine's downstream reconciliation does NOT add separate rows because it's not an action). The Activity rail panel on the right shows the same stream live, filterable by `mcp` vs `palette` (UI clicks) so you can tell which side fired what. ## Where to go from here - **FaustWave β€” Mixer & Master** for the per-strip fader ↔ audio-cable-gain mapping. - **FaustWave β€” AI Assistant** for the discipline behind "hot-path" tools (`dsp.*`, `routing.*` are dedicated) vs `palette.run`-only actions, and how to drive routing from a chat conversation. - **FaustWave β€” Reference** for the full MCP action catalog with input schemas. --- <!-- https://faustwave.io/docs/patchbay/external-clock-sync β€” book: Patchbay & Routing --> # External clock sync & controller actions FaustWave can follow an **external MIDI clock** β€” a hardware sequencer, groovebox, or another DAW acts as the tempo master, and FaustWave's whole transport (BPM, play/stop, every sequencer, every transport-linked Builder clock) rides along. This is how FaustWave joins a live set. ## Wiring it up β€” routing IS the switch The routing table has a permanent MIDI Sink called **Transport Clock In** (`transport-clock:midi-in`). There is no settings toggle and no mode switch: **connecting a MIDI input to this Sink enables clock follow; disconnecting it stops it.** Same doctrine as everything else in the Patchbay. 1. Connect your clock-sending device (USB-MIDI, or a virtual port like loopMIDI on Windows / an IAC bus on macOS when the master is software). > ⚠️ **Windows: create the VIRTUAL port before you launch FaustWave.** A loopMIDI-style port created while the IDE is already running is reported like any other β€” it appears in the MIDI Devices panel and in `routing.list`, reads `connection: "open"`, and can be wired in the Patchbay β€” but **carries no data in either direction**. Nothing distinguishes it from a working port except that nothing ever arrives; rescanning, reopening the port and reloading the renderer all fail to revive it, only a restart of the app does (a WinMM/Chromium limitation; CoreMIDI does not have it). Start loopMIDI, add the port, *then* start FaustWave. > > This is about virtual ports only. **Real USB hardware plugged in after launch works at once**, and so does unplug/replug of a port that was there at startup. Since Web MIDI cannot tell the two cases apart, FaustWave does not warn β€” it only records the neutral fact on the Devices row and in `midi.ports.list`: *this port was not there at startup*. If a port that carries the late-arrival marker stays silent, this is the first thing to rule out. 2. In the Patchbay matrix, connect that device's input Source to **Transport Clock In**. > πŸ”˜ **MCP**: `routing.connect { kind: "midi", from_source: "midi-input-<deviceId>", from_port: "out", to_sink: "transport-clock:midi-in", to_port: "in" }`. The persisted routing survives restarts and unplug/replug like any other hardware routing. ## What "following" means MIDI clock is a stream of tick messages (24 per quarter note) plus Start/Stop commands. FaustWave does **not** replace its own clock with those ticks β€” the sample-accurate Faust master clock (see **FaustWave β€” Sequencer** Β§ *The shared Faust master clock*) stays the only timebase that triggers notes. Instead, the tick stream is treated as a **measurement**: a windowed tempo estimate (about two beats of ticks, first lock after half a beat) continuously nudges the transport BPM, the same way a cruise control holds speed. Tick jitter β€” real hardware clocks wobble by several milliseconds β€” is absorbed by the estimator; your groove stays sample-locked. While ticks are arriving, an **EXT** tag appears next to the BPM readout in the topbar transport strip (hover it for the source device and current external tempo). - **Start (0xFA)** β€” the transport (re)starts from the top, even if it was already playing. This mirrors what the master does: pressing Start on the master realigns everyone to bar 1. The **start** is phase-locked: the grid is anchored on the master's own step raster rather than on FaustWave's reaction time, so the first beat lands with the master instead of ~110 ms behind it (measured Β±2–5 ms against a deterministic software master). This is the START only β€” see below for what happens over the following minutes. - **Stop (0xFC)** β€” the transport stops. - **Continue (0xFB)** β€” followed as play, but FaustWave restarts from the top rather than resuming mid-song, and deliberately does **not** snap to the master's raster (there is no song position to snap to). Song-position resume is on the roadmap. - **Ticks alone never start playback.** Many masters send clock continuously even while stopped β€” FaustWave uses that to keep its tempo estimate warm, so pressing Start on the master drops you in at the right BPM immediately. ## Rules and gotchas - **One clock source at a time.** If two devices are routed to Transport Clock In, the one that ticks first wins; the other is ignored until the active one goes silent for over a second (then the survivor takes over). Two merged clock streams would read as double tempo β€” don't route two masters. - **Manual BPM edits are overridden while EXT is active.** The external master owns the tempo; edit BPM on the master, not in FaustWave. - **Out-of-range masters clamp.** FaustWave's transport range is 30–300 BPM; a master outside that range pins FaustWave to the nearer limit. - **Clock lost** (device unplugged, master powered off): after ~1 second the EXT tag disappears and FaustWave keeps the last followed BPM and play state β€” the set doesn't stop just because the cable did. The Logs panel records the hand-off. - **No hardware handy?** Any DAW can be the master over a virtual MIDI port (loopMIDI on Windows, IAC on macOS) β€” e.g. Reaper's "Send clock/SPP to output" per MIDI output device. On Windows, create the virtual port before launching FaustWave (see the warning above). ## Controller actions (pads & buttons run FaustWave) The second controller sink is **Controller Actions** (`controller-actions:midi-in`) β€” route a controller's MIDI input onto it and its pads/buttons can fire any action in the IDE: launch a scene, stop the transport, toggle recording. Same doctrine: routing is the enable switch, and the mapping persists per project. Bindings are authored via commands (ask the assistant, or palette.run): > πŸ”˜ **MCP**: `controller.bind.add { type: "note", number: 36, channel: 16, action_id: "sequencer.scene.launch", input: { scene_id: "..." } }` β€” pad note 36 on channel 16 launches that scene. `type: "cc"` fires button-style on the rising edge across value 64. `channel` is required: 1..16, or 0 = any channel β€” only when nothing else on that port uses the number (a hardware sequencer sharing the port fires an omni binding on every step). `controller.bind.list` shows the map; `controller.bind.remove { binding_id }` drops one. Or **Learn** it in the Controller panel: choose Action or Param, pick **Note** or **CC** (one kind at a time β€” a hardware sequencer sharing the port sends notes that look like pad presses), arm Learn, press the pad or turn the knob; a param widget's right-click menu has a "MIDI Learn…" row that lands there pre-selected. Every fired binding lands in the Activity log like a UI click β€” a live set stays auditable. A binding to a mistyped action id is rejected at add time; a binding whose action breaks later warns in the Logs panel instead of silently doing nothing. ## Encoders β†’ sound parameters (midi_cc node) Turning a hardware encoder into a *sound* control is not a binding β€” it's a Builder node. Add a **MIDI CC** node (Sources category) to the instrument's graph, set its CC number / channel / min–max range, and wire its `value` output into any control input (`cutoff_in`, `res_in`, a gain). The CC-to-value mapping runs inside the Faust worklet (`[midi:ctrl]` metadata β€” no JS hop, smoothed against zipper noise). Route the controller's MIDI input onto the **instrument's** `:midi-in` sink for this β€” the module, not the Controller Actions sink. Rule of thumb: **buttons/pads β†’ Controller Actions sink + bindings; knobs/encoders β†’ midi_cc node in the instrument.** ## What FaustWave does not do (yet) - **Running phase lock**: the START is snapped onto the master's raster (see above), but from then on the ticks correct the **rate only, never the position** β€” there is no control loop nudging the downbeats back together. Against a deterministic master the residual drift is ~0.04 ms/s; against real hardware it is whatever that device's tempo quantisation costs β€” a BeatStep Pro running "120" actually sends 119.9, which works out to ~0.6 ms/s, i.e. roughly 36 ms per minute. For a song-length set that is inaudible; over a long DJ-style set it is not, and re-pressing Start on the master re-aligns everyone. - **Clock master**: FaustWave sending clock OUT to hardware is planned but not wired yet. - **Ableton Link**: on the roadmap after the MIDI path is proven. --- <!-- https://faustwave.io/docs/patchbay/midi-monitor β€” book: Patchbay & Routing --> # MIDI Monitor β€” see every byte on the bus The MIDI Devices panel has a **MONITOR** section: a live, decoded tail of every MIDI message crossing the bus β€” hardware inputs *and* internal sources (Sequencer, Keyboard, Pads) alike, captured **before** any routing or filtering. That last part is the point: the monitor answers *"is anything arriving at all?"* even when nothing is routed anywhere yet. Each row reads `source Β· channel Β· type Β· detail`: ``` Pads ch1 NOTE ON C2 (36) vel 110 BeatStep ch10 CC CC 74 = 82 sequencer ch8 NOTE OFF A3 (57) ``` - **Pause** (⏸) freezes capture while you read; **clear** (πŸ—‘) empties the tail. - The list follows the newest entry; scroll up to read history and it stops following until you return to the bottom. - **Clock streams never appear as rows.** A master sending MIDI clock produces 48+ messages per second β€” as rows they would flood the list instantly. They aggregate into a per-source rate line instead ("realtime ~48/s"), which doubles as a live "the clock source is alive" indicator. ## The debugging recipe "My controller does nothing" always splits into two halves, and the monitor tells you which one you're in: 1. **No bytes in the monitor** β†’ the problem is *upstream*: cable, port, device off, wrong virtual-MIDI port, notes outside the current pad bank. 2. **Bytes present but nothing reacts** β†’ the problem is *downstream*: missing Patchbay route, receiver filtering a different channel, a binding on the wrong note number, a `midi_cc` node listening to a different CC. The assistant can run the same triage without screenshots: > πŸ”˜ **MCP**: `midi.monitor.get { limit? }` β€” returns the tail as text, newest last. `midi.ports.list` says which ports the browser actually handed over. ## The third case: a note that will not stop Bytes arrived, something reacted, and now it will not let go β€” the controller was unplugged mid-note, the device was switched off, a sequencer stopped between note-on and note-off. No amount of routing fixes that: the note-off simply never happened. **Panic** is the answer β€” the button at the right end of the transport strip, or the action: > πŸ”˜ **MCP**: `midi.panic {}` β€” every routed instrument sink releases its voices and gates (poly and mono alike), and every hardware MIDI output gets All-Sound-Off + All-Notes-Off on all 16 channels. It reports how many sinks and outputs were told. Two things happen on their own and are worth knowing so you do not go looking for them: **unplugging a MIDI input** releases the sinks that input was feeding, and **switching project, restarting the audio engine or closing to the picker** runs the same panic before the renderer goes away β€” otherwise external gear would keep holding whatever was sounding. `keyboard.notes.clear` is *not* this: it releases only what the on-screen keyboard holds. ## What it deliberately does not show Sub-millisecond timing (use the clock-follow diagnostics for that), SysEx payload contents (length only), and per-sink delivery β€” the monitor taps the bus *before* fan-out, so a row proves arrival at FaustWave, not arrival at a specific instrument. For per-receiver questions, check the route's enabled flag and the receiver's channel/CC configuration. --- <!-- https://faustwave.io/docs/recorder/recorder β€” book: Recorder --> # The Recorder β€” the tap, the file, the library The Recorder taps the master output and writes it to a lossless WAV file. One button to start, one button to stop. It's how you capture takes, render arrangement variations, and round-trip sound design (record a DSP β†’ re-import as a sample β†’ use in another DSP). This section covers the signal model and what lands on disk; the next section covers the workflows + the MCP catalog. ## The signal tap The Recorder is registered in the routing table as an **audio Sink** named `recorder-default`. Its bus consumes `master:out` β€” the same signal your speakers receive, after the master FX rack, after the master volume, after the softclip stage. That means: - **Everything you hear, you record** β€” every Builder, every sequencer-driven instrument, every send return, the lot. - **A muted track is silent in the recording too** β€” mute / solo on the Mixer is a real take-management tool. - **Master FX affects the recording** β€” the reverb tail, the saturator drive, the softclip protection. The recording captures the master signal AS-RENDERED, not the pre-fader sources. If you want to record ONE source dry (e.g. a Builder without master FX), three options: 1. **Solo its mixer track during the take** β€” simplest. Master FX still applies; everything else is silent. 2. **Solo + disable master FX slots** β€” `master.fx.slot.enabled.set { slot_id, enabled: false }` per slot during the take. Dry source, no master colour. 3. **Direct route to the Recorder** β€” `routing.connect { from_source: <builder-id>, to_sink: "recorder-default", kind: "audio" }`. The default `master β†’ recorder-default` cable stays; you add a second cable that bypasses the master entirely. Sum-into-recorder if you don't disconnect the master cable; pure-source if you do. See **FaustWave β€” Patchbay** Β§ 4 for the routing.* primitives, **FaustWave β€” Mixer & Master** Β§ 2 for the FX-rack actions. ## The Rec button Four ways to start / stop a recording, all driving the same underlying state: 1. **Master Transport Widget Rec button** β€” the red dot near the right end of the topbar widget, immediately left of the Panic button. Pulses red while recording. Click to start; click again to stop. 2. **Recorder rail panel toggle** β€” same control, plus the recordings library on the panel body. 3. **MCP** β€” `recorder.start`, `recorder.stop`, `recorder.toggle`. 4. **AI Assistant** β€” *"record the next loop pass"* routes through the same `recorder.toggle` action. All four sync to one underlying `recorderState` Svelte store β€” click the rail panel button while a chain is firing `recorder.toggle` and you'll see the panel flip even though the press came from elsewhere. ## What gets written | Property | Default | |---|---| | Format | **32-bit float WAV** | | Sample rate | Project's sample rate (typically 44.1 kHz or 48 kHz, set in Audio Inputs panel) | | Channel count | Stereo (2 channels, master out) | | Naming | `<ISO-date>_<random-suffix>.wav`, e.g. `2026-06-07T08-46-22_abc123.wav` | 32-bit float is lossless within the AudioContext's processing range β€” no clip distortion in the file even if the master meter shows red (the softclip kicks in earlier; the file captures the softclipped signal). About 12 MB per minute stereo at 48 kHz, so a long take is big but pristine. For sharing, transcode to 16-bit or 24-bit FLAC via ffmpeg / Audacity: ```bash # 24-bit FLAC, lossless, ~2-3Γ— smaller than 32-bit float WAV ffmpeg -i 2026-06-07T08-46-22_abc123.wav -c:a flac -sample_fmt s32 output.flac ``` ## Where the files live | OS | Path | |---|---| | **Windows** | `%APPDATA%\FaustWave IDE\recordings\` | | **macOS** | `~/Library/Application Support/FaustWave IDE/recordings/` | | **Linux** | `~/.config/FaustWave IDE/recordings/` | Electron's standard `userData` location β€” same place your projects + node-packs + Logto session live. The Recorder rail panel has a **Reveal** button per recording that opens the OS file manager directly at the file. ## The recordings library The Recorder rail panel lists every recording ever made, newest first. Each row carries: - **Name** β€” the timestamped filename. - **Duration** in seconds. - **Size** on disk. - Three action buttons: - **Play** β€” in-app preview via the audio system, independent of the master transport. - **Reveal** β€” open the file in the OS file manager. - **Delete** β€” hard-delete. No trash (greenfield discipline) β€” the file is gone the moment you click. Confirm with care. > πŸ”˜ **MCP**: `recordings.list` returns the library. `recordings.play { recording_id }` plays one. `recordings.delete { recording_id }` removes one. Use `recordings.list` to discover ids. ## A library-introspection chain ```yaml actions: - action_id: surface.open input: { surface: recorder-default } label: "β–Ά 0. Open the Recorder panel" - action_id: recordings.list input: {} save_as: lib label: "β–Ά 1. List existing recordings" - action_id: recorder.start input: {} label: "β–Ά 2. Start a new recording" - action_id: recorder.stop input: {} save_as: rec label: "β–  3. Stop + finalize (returns the WAV path)" ``` > 🟦 **HANDS-FREE PATH**: step 1 captures the current library so you can see what was there. Step 2 arms the Recorder; let some audio play (a sequencer pass, a manual keyboard note, anything). Step 3 finalizes; the new entry is now in `recordings.list` next time you call it. ## State observability The Recorder does NOT expose a state-snapshot MCP action of its own. Two ways to introspect: - **Observable state** β€” the rail panel + the Rec button stay in sync because they subscribe to `recorderState`. If the panel says "recording", the Recorder is recording. - **Library is the audit log** β€” every Stop writes a row to the library; `recordings.list` is the historical record. Compare two `recordings.list` snapshots to detect what landed between them. Next section: the workflows β€” the 30-second take, round-trip into the sample store, the live-perf capture pattern. --- <!-- https://faustwave.io/docs/recorder/workflows-and-scripting β€” book: Recorder --> # Workflows + scripting You know what the Recorder is and what it writes. Time for the real-world flows: the 30-second take, the DSP β†’ sample round-trip, live-perf capture, and the MCP catalog with a multi-take chain. ## The 30-second take 1. Master Transport Widget β†’ click the red **Rec** dot. 2. Click **Play** (or just keep playing if already running). The capture starts from the moment Rec lit up. 3. Let the loop run. 4. Click **Rec** again to stop. The WAV writes to disk; a row appears in the Recorder panel. The take lands in the rail panel β€” right rail β†’ the Recorder icon. Three rows of preview / reveal / delete buttons. ## The DSP β†’ recording β†’ sample round-trip The killer pattern. Turn a synth voice into a sample-back texture without leaving FaustWave: 1. Build a DSP β€” a poly-synth, a granular cloud, an FM pad. 2. Record yourself playing it for 10–60 seconds. 3. Re-import the recording into the sample-store. 4. Drop a `soundfile` node in a new Builder + reference the imported sample. 5. Play the recording as a granular / looped / pitched sample-back instrument. The sample sits keyed by sha256 β€” idempotent on re-import (same file twice = same entry). Lives alongside Builder graphs and KB-packs as a first-class project asset. ```yaml actions: - action_id: recorder.start input: {} label: "β–Ά 1. Start recording the master" - action_id: recorder.stop input: {} save_as: rec label: "β–  2. Stopβ€―β€” returns the WAV path" - action_id: samples.import input: file_path: "{{rec.path}}" save_as: sample label: "β–Ά 3. Import the recording into the sample store" - action_id: documents.add input: type: builder title: "Sample player" save_as: b label: "β–Ά 4. Spawn a fresh Builder" - action_id: builder.graph.node.add input: module_id: "{{b.module_id}}" kind: soundfile save_as: sf label: "β–Ά 5. Drop a soundfile node" - action_id: builder.graph.node.ref.set input: module_id: "{{b.module_id}}" node_id: "{{sf}}" param: sha256 value: "{{sample.sha256}}" label: "β–Ά 6. Bind the imported sample by sha" ``` > 🟦 **HANDS-FREE PATH**: this is the round-trip, captured as a single chain. Run after a live take is queued (so step 1 captures something interesting). Step 2's return carries the file path; step 3 stores it sha-keyed; step 4 opens a fresh Builder; step 5 + 6 wire the sample into it. You now have a Builder ready to be wired to an `output` node and played back as a sample. More on the Builder side in **FaustWave β€” Builder** Β§ 3 (Loading samples). ## Capturing a live performance The Recorder runs **realtime** β€” no faster-than-realtime bounce. For a 5-minute set: 1. Press Rec. 2. Set up your arrangement β€” the active slot per track, the sequencer key, the loop mode. 3. Press Play. 4. Perform for 5 minutes: live slot launches, scene swaps, param tweaks, mute/solo, master volume nudges. 5. Press Rec to stop. The file captures the actual realtime output **including your live gestures**. This is what "FaustWave as a live-performance instrument" means in practice β€” every gesture lands deterministically because the routing engine + the master clock + the dispatch path are all deterministic. ## Capturing a parameter sweep A scripted A/B comparison: same loop, different params. (Not a click-chain β€” the `slot_id` is YOUR reverb slot's id from `master.fx.slots.list`; ask the assistant to run the sweep against your rack.) ``` 1. master.fx.slot.param.set { slot_id: "<your reverb slot>", param_name: "room", value: 0.1 } 2. recorder.start {} 3. sequencer.play {} ← one pass of your loop 4. recorder.stop {} β†’ dry take path 5. master.fx.slot.param.set { slot_id: "<your reverb slot>", param_name: "room", value: 0.8 } 6. recorder.start {} 7. sequencer.play {} 8. recorder.stop {} β†’ wet take path ``` Two takes, same source, different reverb. Compare via `recordings.play` or in your DAW after `recordings.list` + Reveal. ## Common moves - **"Record only the next loop pass"** β€” wait for the cursor to hit step 0, press Rec, wait one pattern length, press Rec. (`sequencer.state` gives you `steps_per_pattern` and `steps_ms`, so you can work out one pattern length.) - **"Build up a take library of variations"** β€” a chain that varies one param + records each variation. The library then has multiple sha-keyed sources you can A/B externally. - **"Mass-delete old takes"** β€” `recordings.list` then iterate `recordings.delete { recording_id }` filtered by date or size. No trash, no undo β€” confirm the filter before firing. - **"Record while testing a Builder"** β€” the Recorder runs even when no sequencer is playing. Hit Rec, click keys on the on-screen Keyboard, capture the doodle. ## The full MCP surface | Action | Purpose | |---|---| | `recorder.start` | Begin recording. Idempotent re: already-recording (silent no-op). | | `recorder.stop` | End. Writes the file. Returns the WAV path. | | `recorder.toggle` | Flip start ↔ stop. The same action the UI button binds to. | | `recordings.list` | List the library (newest first). | | `recordings.play { recording_id }` | In-app preview. | | `recordings.delete { recording_id }` | Hard-delete. | The Recorder has the smallest MCP surface of any major subsystem (6 actions) and the largest payload (the WAV file itself). Everything routes through one tap, one start, one stop β€” the simplicity is intentional. ## What's not yet supported Tracked, not delivered: - **In / out points** β€” start at bar 3, stop at bar 11, quantized to bar boundaries. - **Auto-stop on a bar boundary** β€” hit Rec, the engine completes the current loop and stops cleanly. - **Per-source dedicated taps** β€” record one Builder dry without the routing dance. - **Faster-than-realtime bounce** β€” render N bars as fast as the CPU can compute. Today recording IS realtime. The Recorder is "barebones but reliable" today; the planned features are quality-of-life. ## Where to go from here - **FaustWave β€” Builder** Β§ 3 (Loading samples) β€” the sample side of the round-trip. - **FaustWave β€” Mixer & Master** Β§ 1–2 β€” what's UPSTREAM of the Recorder tap (the strips, the FX rack). - **FaustWave β€” Patchbay** Β§ 4 β€” the routing.* primitives if you want to set up alternate taps. - **FaustWave β€” Getting Started** Β§ *Your first recording* β€” the 5-minute starter flow. --- <!-- https://faustwave.io/docs/analysis/measure-dont-guess β€” book: Analysis --> # Measure, don't guess Every sound decision in FaustWave can be a number before it is an opinion. The Analysis tools record what reaches the master, or read a sample from the store, and return one report: loudness, crest, energy per band, where the root sits, mono-compatibility, onsets with a click score, single-sample steps, and a tonality verdict. Optionally the numbers are placed against a **reference window** β€” what professional material measures β€” and each window comes back as *inside / below / above*. This book exists because of one project. *Around the World* (the bundled trap demo) was built in a day where every "the bass is too thick", "something clicks", "these hats sound like an alarm clock" was answered by a measurement first. Three findings the ear had misread and the numbers caught: - a click on every second 808 note that was a **runtime** bug, not the patch (half a block of exact zeros at each note start); - a "feel" problem that was the ducker taking -8 dB exactly where the 808 notes landed; - a sample pack whose 36 "hats" were all a 7 kHz line (one FFT bin carried 10 % of the energy; a real 909 hat measures 2–3 %). ## Where it lives The **Analysis** pane sits in two shipped views. In **Build** it is under the Inspector β€” the knob you turn and the number it changes in one column. In **Signal** it is the audio half of "is something wrong" beside the MIDI half (Devices, MIDI Monitor, Routing). It is not in Perform: a set is played, not measured. ```yaml actions: - action_id: view.open input: { view: build } label: "β–Ά Open the Build view (Analysis under the Inspector)" - action_id: surface.open input: { surface: analysis } label: "β–Ά Or put the Analysis pane on the current view" ``` The pane has three controls: a **take picker** (every recording, newest first), **Measure** (analyse the picked take), and **Capture** (record the next bars and analyse them). The **vs trap-808** chip toggles the reference window. ## The four actions | Action | What it measures | When | |---|---|---| | `analysis.capture { bars, seconds, reference }` | Records the master for `bars` (default 8, at the live BPM; ceiling 60 s), stops, returns the report **and** the new recording id. Start the transport first β€” a silent take returns an error. | The loop while you tweak: change a knob, capture four bars, compare. | | `analysis.recording { recording_id, reference }` | A finished take from the Recorder library (`recordings.list`), the whole file, offline. Leading and trailing silence trimmed; the first 120 s measured. | A take you already have. | | `analysis.sample { sha256, reference }` | A sample from the store (`samples.list`). PCM WAV only. | **Before** wiring a sample into a kit β€” the tonality verdict is the alarm-clock detector. | | `analysis.references` | The reference profiles: id, provenance, every window with its question, lo/hi and unit. | Read once, so "inside" means something when you quote it. | All four return the same report shape; the next chapter reads it line by line. ```yaml actions: - action_id: analysis.references input: {} label: "β–Ά What does the trap-808 window contain?" - action_id: transport.play input: {} label: "β–Ά 1. Play" - action_id: analysis.capture input: { bars: 4, reference: trap-808 } label: "β–Ά 2. Capture four bars and judge them against the pro window" ``` > 🟦 **THE REFERENCE IS A WINDOW, NOT A LAW.** `trap-808` is what five commercial 808 loops measured β€” an 808 *alone*, on B0. The Around-the-World bass sat inside every window at B0 and sounded wrong ("too thick, the tone is too low"); an octave up it reads *above* on the fundamental band and is right. A verdict tells you where you are relative to a reference, and the ear decides whether that is where you want to be. --- <!-- https://faustwave.io/docs/analysis/the-report β€” book: Analysis --> # Reading the report One report, seven blocks. The numbers below are from a real capture of the Around-the-World PARTY scene, four bars, so you can see what a mix looks like β€” not a bass alone. ## level and loudness ```json "level": { "peakDb": -0.6, "rmsDb": -12.5, "crestDb": 11.9 }, "loudness": { "integratedLufs": -11.8, "momentaryMaxLufs": -8.2, "plrDb": 11.2 } ``` - **crestDb** = peak - RMS: density. Professional 808 loops sit at 6–9 dB; a full mix with hats and a plate is looser. 12+ on a bass alone means the limiter is only holding the ceiling and the RMS never arrived β€” a clipper *before* the master is what closes that gap. - **integratedLufs** is ITU-R BS.1770 (K-weighted, gated). **plrDb** is peak-to-loudness; the pros' 808 loops read 8–10. ## spectrum ```json "bandsDb": { "sub": -16.3, "fund": -0.5, "harm": -14.2, "body": -19.8, "mid": -15.8, "hi": -24.9 }, "centroidHz": 124, "slopeDbPerOct": -7.4, "strongestLowBinHz": 62.3 ``` - **bandsDb** is energy share per band **relative to the whole**: sub 20–60 Β· fund 60–120 Β· harm 120–250 Β· body 250–500 Β· mid 500–2k Β· hi 2k+. The band nearest 0 dB carries the take. Here the bass plays B1 (62 Hz), so *fund* carries; at B0 it was *sub*. - **strongestLowBinHz** (30–130 Hz): where the root sits. The one number that tells you which octave your bass line is actually in. - **slopeDbPerOct**: tilt. A trap master reads -6 to -8; flatter is bright, steeper is dull. ## stereo ```json "stereo": [ { "band": "sub", "correlation": 1, "sideToMidDb": -49.6 }, … { "band": "body", "correlation": 0.53, "sideToMidDb": -5.1 } ] ``` Per band: L/R **correlation** (+1 mono, 0 unrelated, -1 cancelling) and **side/mid** in dB. Below 120 Hz you want dead mono (pros: -80 dB and below; anything under -30 passes). Width belongs to the bands the ear places β€” body and up β€” and even there a correlation below +0.3 folds badly on a phone. Every lead, pluck and bell in the demo carries a `center` control for exactly this number. ## onsets ```json { "timeSec": 1.81, "levelDb": -8.7, "hfRelDb": -29.1, "clickScoreDb": 27.5, "isClick": true } ``` Every detected attack, with **hfRelDb** (energy above 4 kHz relative to the onset β€” a hat reads -6, a bass note -50) and a **clickScoreDb** (> 8 dB flags `isClick`). Read it *with* hfRelDb: a snare transient is sharp by nature and scores high; a bass note scoring 15+ is a real click. ## steps ```json "steps": { "count": 3, "top": [ { "timeSec": 3.6, "jumpRatio": 0.27 } ] } ``` Single-sample jumps, as a fraction of the take's peak. This is the detector for the things the ear calls a crackle: a gain that opens without a ramp, a runtime zero-block, a sample that restarts mid-tail or ends without reaching zero. On a **bass** it is exact β€” the 808 alone reads `count: 0` when clean. On **noise** it is meaningless: a hat or a snare jumps 30–70 % of its peak from sample to sample because that is what noise is. Solo the suspect first. ## tonality ```json "tonality": { "flatness": 0.02, "peakBinShare": 0.12, "peakBinHz": 562, "verdict": "tonal" } ``` The alarm-clock detector. **peakBinShare** is the share of energy in the single strongest FFT bin: a hat, clap or snare must read `"noise"` with a share <= 0.03; a synthetic "hat" that is really a 7 kHz line reads `"tonal"` at ~0.1 and sounds like an alarm clock on eighths. A bass or a mix is tonal by nature β€” the verdict matters for one-shots. ## The verdicts With `reference: "trap-808"` every window comes back as ```json { "metric": "spectrum.bandsDb.sub", "question": "energy 20–60 Hz β€” is the fundamental carrying the take?", "lo": -1.8, "hi": -0.4, "measured": -16.3, "verdict": "below" } ``` The `question` is the point: the window is an answer to it, and *below* on the sub band is not a failure when your bass plays an octave up on purpose. ## What the report does NOT know - It measures what reached the **master** β€” after every insert, the master rack, the volume and the softclip. To measure one instrument, solo its track (`mixer.tracks.set { track_id, soloed: true }`) and capture again. - Steps and clicks on noise material are not evidence (above). A kit that "clicks" is measured voice by voice, or by ear. - A reference is one genre's 808 alone. Other references will come; until then, quote the window's provenance, not just the verdict. --- <!-- https://faustwave.io/docs/analysis/live-node-and-workflows β€” book: Analysis --> # The live node, and three workflows ## `analysis_bass` β€” the numbers in the signal path Offline is for decisions; live is for watching. The **Bass Analyzer** node (`analysis_bass`, category *Analyzers*) is a stereo passthrough with eleven bargraphs: momentary LUFS (BS.1770, 400 ms), crest over 1 s, energy share for all six bands, and L/R correlation in sub, fund and harm. Drop it in a Builder as an insert β€” `input β†’ analysis_bass β†’ output` β€” and put that Builder on a track's FX chain or the master rack; the Inspector shows the meters while it plays. Six of the numbers are also **control outputs** (`lufs`, `crest`, `sub_db`, `fund_db`, `harm_db`, `corr_sub`), so a patch can react to them: auto-mono the low end when `corr_sub` drops, sidechain something on `lufs`. The bundled demo ships `Bass Analyzer.builder` ready to insert: ```yaml actions: - action_id: documents.list input: {} label: "β–Ά Find Bass Analyzer.builder in the project" ``` Then `mixer.tracks.fx.add` with the file as ref on the track you want to watch. Bands are 4th-order Butterworth (two cascaded 2nd-order stages) with 300 ms smoothing β€” the same filters as the offline analyzer. ## Workflow 1 β€” the tweak loop 1. Play the scene. 2. `analysis.capture { bars: 4, reference: "trap-808" }`. 3. Change ONE thing (`dsp.param.set`, a fader, an insert param). 4. Capture again. 5. Keep what moved the number the way you meant. The whole Around-the-World 808 was tuned this way: harm band *below* β†’ `harm_level` up; sub *above* and "too thick" β†’ an octave up and `body_level` down; crest 14 on the bass alone β†’ the clipper insert. Four bars is enough; eight when the pattern is eight. ## Workflow 2 β€” the sample audition Before a sample goes into a kit: ```yaml actions: - action_id: samples.list input: {} save_as: store label: "β–Ά 1. List the store" ``` then `analysis.sample { sha256 }` per candidate. Rules that came out of the alarm-clock pack: hats, claps and snares must read `"noise"` with `peakBinShare <= 0.03`; a kick's `strongestLowBinHz` should sit **above** the 808 (a trap kick is a knock, not a sub); a sample whose `crestDb` is under 5 is a constant tone and will end hard β€” give it an envelope, or let the player's end-fade handle it. ## Workflow 3 β€” the click hunt Something crackles. Do not guess which instrument. 1. Capture the mix; note the `steps` count and the onsets with `isClick` **and** a low `hfRelDb` (a click on a bass note, not a hat). 2. Solo one track (`mixer.tracks.set { track_id, soloed: true }`), capture again. A clean 808 reads a `steps` count of 0. 3. Move the solo until the steps appear. In the demo they were in the sampled 808 layer: the sample restarted on sample 0 while the previous hit still rang, and it ended without reaching zero β€” a player bug, fixed in the `soundfile` node (two read heads with a crossfade, end fade). The measurement went 3 β†’ 0. 4. Remember that noise material *always* shows steps. For a kit, the number that counts is the `isClick` on its low-frequency onsets, and after that the ear. ## Where to go from here - **FaustWave β€” Recorder** β€” the takes the analysis reads are Recorder files; `recordings.list` is the picker. - **FaustWave β€” Mixer & Master** β€” solo, track inserts, the master rack the capture listens after. - The *Around the World* book β€” every rule in this chapter with the measurement that made it. --- <!-- https://faustwave.io/docs/hub/hub β€” book: Hub --> # The Hub model β€” items, kinds, versions The Hub is FaustWave's community-publishing surface. You publish DSPs, sample packs, knowledge packs, full projects. Other users install with one click. Everything in this manual you're reading right now is on the Hub β€” each domain pack is its own item. This section is the conceptual model: what you can publish, the item / version shape, how sign-in works. The next two sections cover browsing + installing, and publishing + iterating. ## What you can publish | Kind | Source format | What install does | |---|---|---| | **builder** | `.builder` graph document | Saves into the project's `assets/` (Documents β†’ PROJECT); no editor tab. Add to the mixer via "+ ADD AUDIO"/"+ ADD FX", or open from PROJECT to edit. | | **dsp** | `.dsp` Faust source | Opens in a Faust DSP module. | | **lib** | `.lib` Faust library | Pops a file dialog; writes to disk; mounts in libfaust's VFS so `import("name.lib")` resolves immediately in any compile. | | **book** | `.md` markdown bundle with `## Section`-headed blocks | Indexes sections into the local KB's `community` corpus; opens in the Book Reader. | | **sample-pack** | A single `.wav` / `.flac` / `.aiff` audio file | Writes bytes to the sample store (sha-keyed; idempotent on re-install). | | **node-pack** | A `.nodepack.json` manifest | Installs into `userData/node-packs/`; the Builder picks up the new node kinds on next refresh. The pack-author surface (Box-DSL `toBox`, per-kind `imports`, paired-port stereo) is covered in **FaustWave β€” Node-Pack Authoring**. | | **project** | A `.fwproject.zip` (project.json + workspace.json + assets/) exported via `project.export` | Imports as a new project with the source's name (or server-suggested on collision). | Each kind has its own **content reader** on the publish side (parses + validates the source) and its own **installer** on the install side (puts it where the IDE expects it). The reader / installer pairs are wired such that publishing a `.builder` then installing it back gets you bytes-identical content β€” round-trip-clean. ## Items, versions, artifacts A Hub **item** is the metadata row β€” slug, title, owner, license, visibility, tags, description. Items live forever (until you delete them) and carry a stable id. Each item has one or more **versions** β€” `v1`, `v2`, `v3`, … β€” each with its own content + a changelog. Users install "the latest" by default but can browse to an older version. Version numbers are server-assigned (you don't pick `v17`; you upload, the server picks the next number). Each version has one or more **artifacts** β€” the actual content bytes. A `book` version has a `bundle` artifact (the markdown) + a `manifest` artifact (metadata). A `builder` version has one artifact (the .builder file). A `sample-pack` version's artifact is the audio bytes. The item-version-artifact split lets the Hub serve metadata cheap (search results carry summaries only) and download bytes on demand (the `downloadUrl` on each artifact). ## Visibility When you publish, pick one of: - **`public`** β€” listed in search; anyone can install. - **`unlisted`** β€” installable by direct link / id, but not surfaced in search. Like a YouTube unlisted video. - **`private`** β€” only YOU can see it. Drafts, work-in-progress. - **`friends`** β€” planned visibility tier (not yet wired in the backend; the publish call accepts the string but treat it as `private` today). - **`team`** β€” planned collaborative spaces (same caveat). Visibility is mutable; use `hub.item.update { visibility }` to flip it. > 🟦 **PRACTICAL**: publish as `unlisted` first, share the link with one or two readers, switch to `public` once you're happy. The user manual you're reading was built this way β€” every pack started `private`, flipped to `public` once it covered enough ground. ## License Every item requires an SPDX license identifier. The Hub doesn't enforce licensing on installs (it can't β€” a content download is a download) but the license is visible on every item card so installers know what they're licensing. Common choices: - `CC0-1.0` β€” public domain, no strings. Everything FaustWave itself publishes (the manual, the demo projects, the bundled instruments) is CC0. - `CC-BY-4.0` β€” attribution required. - `MIT` β€” permissive; usually for code-bearing items (libs, builders). - `CC0-1.0` β€” public domain dedication. "Use this for anything." - `GPL-3.0-or-later` β€” copyleft. Derivatives must be GPL too. The FaustWave manual you're reading is `CC0-1.0` β€” copy from it freely. ## Sign in Hub access is sign-in-gated. FaustWave uses **Logto OIDC** for auth; the same account works for browsing, installing, publishing. Not signed in yet? Click the **user icon** at the bottom-left of the rail (or Settings β†’ Account). The login modal opens in a windowed flow; complete it and you're back in the IDE with your handle (`@your-username`) visible in the topbar. > πŸ”˜ **MCP**: `hub.status {}` returns whether you're signed in + whether you've picked a username. Cheap precheck. `auth.login {}` opens the modal; `auth.logout {}` signs you out + clears local tokens. The IDE stays fully usable signed-out β€” only the Hub features (browse, install, publish) require sign-in. You can build DSPs, run the sequencer, record takes, install local samples, all without a Hub account. ## Items have owners Every item has exactly one owner: the user who published it. You can: - **Update YOUR items' metadata** via `hub.item.update`. - **Upload new versions of YOUR items** via `hub.version.upload`. - **Delete YOUR items** via `hub.item.delete` (permanent; artifact bytes go too). You CANNOT directly edit somebody else's item. To evolve another user's work into your own version, **fork** β€” see Β§ 3. ## A `hub.status` chain to ground orientation ```yaml actions: - action_id: hub.open input: {} label: "β–Ά 0. Open the Hub tab" - action_id: hub.status input: {} save_as: hs label: "β–Ά 1. Am I signed in?" - action_id: hub.items.my input: {} save_as: mine label: "β–Ά 2. What have I published?" ``` > 🟦 **HANDS-FREE PATH**: step 1 returns `{ signedIn, hasUsername }`. Step 2 returns the full ItemSummary list of your own items. Useful as a first orient when stepping into a fresh session β€” you know whether you can publish (signed in + has username) and what you already have on the Hub. ## What the Hub doesn't do (yet) - **Payments / commerce** β€” every item is free. License governs reuse. - **Comments / discussion** β€” no threads per item. - **Download counts in search ranking** β€” sort is recency + star count. - **Automated content moderation** β€” community-flag + manual admin review. - **Notifications** β€” "somebody forked your item" pushes are planned, not wired. Tracked in TODOs. Next section: browsing + installing β€” the search bar, the kind filter, what `hub.install` actually does per kind. --- <!-- https://faustwave.io/docs/hub/browsing-and-installing β€” book: Hub --> # Browsing + installing The Hub tab is the entry point for finding + installing other people's work. Search, filter by kind, open an item card, hit install. The whole flow is sign-in-gated; the underlying primitives are also MCP-callable so the assistant can browse on your behalf. ## Opening the Hub Three paths to the same place: - **Topbar cloud icon** β€” the cloud cluster's first icon. Opens the Hub tab. - **Knowledge rail panel β†’ Open Hub button** β€” contextual when you're already browsing the KB. - **MCP / palette** β€” `hub.open {}` (action id `hub.open`). Pops the same tab. The Hub is a regular IDE module β€” it lives in tabs alongside Builders, book readers, editors. You can have it open and a Builder open simultaneously, drag a Hub-installed sample into the Builder without leaving the IDE. ## Anatomy of the tab Top to bottom: - **Search bar** β€” free-text. Matches against title, description, tags. Case-insensitive substring match. - **Kind filter chips** β€” narrow to one of `builder`, `dsp`, `lib`, `book`, `sample-pack`, `node-pack`, `project`. Multi-select. - **Sort affordance** β€” recency (default) or star count. - **Results grid** β€” cards, each carrying title, kind badge, owner handle, tags, star count, fork count, license. Click a card to open the **item detail** β€” description, version history, artifacts, license, install / fork / star buttons. ## Search via MCP The same search the UI runs: ```yaml actions: - action_id: hub.search input: kind: book q: faustwave save_as: hits label: "β–Ά 1. Search public books matching 'faustwave'" - action_id: hub.item.get input: item_id: "{{hits.data.0.id}}" save_as: top label: "β–Ά 2. Fetch the top result's full detail" ``` > 🟦 **HANDS-FREE PATH**: step 1 returns a `{ data, total, page, limit, totalPages }` envelope; `data[0]` is the top hit (a summary). Step 2 fetches the full ItemDto for that item, including the artifact URLs you'd download for an install. The `hub.search` schema accepts `q` (free text against title + description), `kind`, and `tags` β€” all optional. Empty input = list everything. ## Installing From the item detail, click **Install**. Or via MCP: ``` hub.install { item_id: "<id from hub.search>" } ``` The action is kind-aware β€” you don't pass `kind`. Behaviour per kind: | Kind | What happens on install | |---|---| | `builder` | **Saves the `.builder` into your project's `assets/` folder** (Documents β†’ PROJECT; no editor tab opens). Idempotent by title. Add it to the mixer via "+ ADD AUDIO" / "+ ADD FX", open it from PROJECT to edit, or use the install toast's "Open in Builder" action. | | `dsp` | Opens the `.dsp` source in a Faust DSP module. | | `lib` | Lands in the project's `assets/` as `<owner>-<slug>.lib` β€” the owner prefix keeps two libraries of the same name apart in libfaust's flat VFS namespace. The IDE mounts it straight away, so `import("<owner>-<slug>.lib")` resolves immediately, and re-mounts it on every start. Rename with `project.file.rename` if you want a shorter import. | | `book` | Fetches the manifest + bundle, parses `## sections`, indexes them into the local KB's `community` corpus with `packId` provenance for book-scoped search. Available immediately in the Book Reader (Β§ `book.open`). | | `sample-pack` | Fetches the audio artifact, writes bytes to the local sample store via `samples.ensureFromBytes` (sha-keyed; idempotent). Available in any `soundfile` node by sha. | | `node-pack` | Drops the `.nodepack.json` manifest + companion files into `userData/node-packs/`. The Builder's node picker shows the new kinds on next refresh. | | `project` | Imports the `.fwproject.zip` as a new project. Server-suggested name on slug collision (e.g. `my-techno-set (2)`). | ## Inspecting before installing For a heavyweight or unfamiliar item, peek before installing: ``` 1. hub.item.get { item_id: "<id from hub.search>" } β†’ full ItemDto incl. artifacts 2. hub.items.user { username: "<its owner.username>" } β†’ the author's other work 3. hub.install { item_id: "<id>" } β†’ OK, install ``` (Not a click-chain β€” the item id comes from your own `hub.search`.) Step 1 returns the full ItemDto including `latestVersion.artifacts[].downloadUrl` for direct artifact fetching (useful if you want to inspect a `.builder` graph in your editor before installing). Step 2 lets you see other items by the same author β€” useful for vetting (a prolific publisher whose other items look good is less risky than a one-off mystery upload). ## Browsing another user's catalogue ```yaml actions: - action_id: hub.items.user input: username: mani save_as: their_kb label: "β–Ά List @mani's public + unlisted items" ``` Returns public + unlisted items (private items stay invisible). Useful for catalogue browsing by author. ## Starring Star items you find useful β€” stars surface in search ranking and signal to other users "this is worth looking at": ``` hub.star.set { item_id: "<id from hub.search>", starred: true } ``` Returns the new starCount. Idempotent both ways (re-starring or re-unstarring is a no-op). ## The book install loop A book install does more than file movement β€” it indexes the content into your local KB so the AI assistant can ground in it. After installing a book: 1. The `kb` corpus gains its sections (find them via `kb.search { corpus: "community", query: "..." }`). 2. The Knowledge rail panel's *Installed Packs* list grows by one row. 3. The pack is available to the Book Reader β€” `book.open { book_id }` opens a Reader tab. 4. The assistant can scope to the pack's content for ground-truth answers about it. This IS how the FaustWave manual you're reading works in practice β€” each pack installs, its sections index, the Reader opens, the assistant grounds in the content when you ask it about FaustWave. ## Common moves - **"Show me popular Builders"** β€” `hub.search { kind: "builder" }`. Without a `q` filter you get the most-recent. - **"Install everything from one user"** β€” `hub.items.user { username }` then iterate `hub.install` per `item.id`. (Mind that `lib` installs each pop a dialog.) - **"Vet then install"** β€” `hub.item.get { item_id }`, inspect `latestVersion.changelog`, install if it looks good. - **"Unstar something I starred by accident"** β€” `hub.star.set { item_id, starred: false }`. ## Common gotchas - **`lib` install blocks on the file dialog** β€” when scripting library installs in an action chain, the chain pauses until you accept / cancel the dialog. Plan accordingly. - **Re-installing the same `book` upgrades it** β€” doesn't duplicate. The local KB store keys by book_id so installing v3 of a pack you have at v1 simply replaces the indexed sections with v3's. - **`hub.install { item_id }` errors out if you're not signed in** β€” sign in first, or the call returns an auth error. - **`hub.search` returns a page of results** β€” the response envelope carries `total` / `page` / `totalPages`, so check `total` before assuming you've seen everything. Next section: publishing + iterating β€” the `hub.publish` flow, version uploads, forks, the My Items view, and the safety rails (delete, account.delete). --- <!-- https://faustwave.io/docs/hub/publishing-and-iterating β€” book: Hub --> # Publishing + iterating You have an instrument, a sample, a knowledge bundle, a full project. You want to share it. This section covers the publish flow, version bumps, forks, the My Items view, and the safety rails for deletion + account housekeeping. ## The canonical publish path The UI path: **Hub tab β†’ My Items β†’ Upload**. Pick a kind, point at the source file, fill in slug + title + license + visibility + description + tags, hit Upload. ONE door, deliberately β€” the IDE used to have "Publish" buttons in module footers (Builder, editors), they got pulled because three doors invited inconsistency. One form, one path. The MCP twin: ```ts hub.publish { file_path: "asset:assets/my-instrument.builder", kind: "builder", slug: "my-saturator-lead", title: "Saturator Lead", license: "CC0-1.0", visibility: "public" | "unlisted" | "private" | "friends" | "team", description?: "...", tags?: ["lead", "saturator", "synth"], changelog?: "v1 β€” initial release", } ``` Reads the file, validates the content for the requested kind, creates the item metadata row, uploads the content as v1. Returns the new ItemDto. > 🟦 **WHY THE FILE-PATH PATTERN?** Hub items are content-driven β€” the `.builder` document IS the DSP, the `.md` IS the knowledge pack. Publishing references a file on disk rather than in-memory state so what's published exactly matches what's on disk. Round-trip-clean: install your own item, you get bytes-identical content back. ## A publish chain end-to-end A Builder β†’ publish workflow, all MCP: ```yaml actions: - action_id: documents.add input: { type: builder, title: "My instrument" } save_as: b label: "β–Ά 1. Spawn a Builder" - action_id: builder.graph.node.add input: { module_id: "{{b.module_id}}", kind: osc } label: "β–Ά 2. Add an oscillator" - action_id: builder.graph.node.add input: { module_id: "{{b.module_id}}", kind: output } label: "β–Ά 3. Add an output" - action_id: documents.save input: { module_id: "{{b.module_id}}" } save_as: saved label: "β–Ά 4. Save the DSP (returns the .builder ref)" - action_id: hub.publish input: file_path: "{{saved.filePath}}" kind: builder slug: my-osc-test title: "My osc test" license: CC0-1.0 visibility: unlisted description: "Tiny osc β†’ output test DSP" tags: [test, osc] label: "β–Ά 5. Publish as v1, unlisted" ``` > 🟦 **HANDS-FREE PATH**: chains the whole flow. Step 4 saves the DSP and returns its on-disk path; step 5 hands that path to `hub.publish`. The crucial trick is `documents.save` for `builder` (it links the file to the module so the project re-opens it) β€” don't use `builder.export` here, that writes a file but doesn't link it. ## Slug + title + license + visibility β€” the required fields | Field | Rules | |---|---| | **slug** | `[a-z0-9-]+`, max 64 chars, **unique per owner across kinds**. Immutable after publish β€” you'd have to fork into a new slug to change. | | **title** | Display name. Mutable via `hub.item.update`. | | **license** | SPDX identifier. Mutable via `hub.item.update` (with care β€” retroactively changing your license is awkward for installers). | | **visibility** | `public` / `unlisted` / `private` / `friends` / `team`. Mutable. Start `unlisted` if you want a soft launch. | Optional but recommended: **description** (what it does, who it's for), **tags** (a handful, surfacing in search), **changelog** (what's in this version). ## Uploading a new version You shipped v1, improved it, want to push v2: ``` hub.version.upload { item_id: "<id from hub.items.my>", file_path: "asset:assets/my-DSP-v2.builder", kind: "builder", changelog: "v2 β€” replaced moog_vcf with a steeper SVF" } ``` `kind` is optional β€” omit it and the action does a server roundtrip to resolve the kind from the item record. Pass it to save the roundtrip. Server assigns the version number (`v(n+1)`). Users on "latest" automatically get v2 on next install. They can browse to v1 if they want. **The user manual you're reading uses this loop β€” every chapter rebuild is one `hub.version.upload` per affected pack.** ## Updating metadata without uploading No new content, just tweaks? ``` hub.item.update { item_id: "<id from hub.items.my>", title: "Saturator Lead (refined)", description: "Updated description with the new param list", tags: [lead, saturator, synth, fm], visibility: public } ``` Slug and kind are immutable; everything else is mutable. Sends only the fields you pass β€” omitting `tags` leaves them alone (it doesn't blank them). ## Forking somebody else's work Found a great DSP you want to evolve as your own variant? ``` hub.fork { item_id: "<source id from hub.search>" } β†’ fresh ItemDto in YOUR namespace ``` Returns a fresh ItemDto in YOUR namespace. v1 of the fork = the source's latest version, server-side copied. You own the fork; the parent stays untouched. Same-kind only β€” you can't fork a `builder` into a `dsp`. Forks are how community remixes happen β€” somebody publishes a great FM bass DSP, you fork it + add an envelope + upload as YOUR fork v2. Parent + fork co-exist. ## My Items β€” your dashboard The Hub tab's **My Items** view shows every item you've published β€” public, unlisted, private β€” as one list. Filter by kind. Edit metadata in-place. Upload new versions. Delete items. ```yaml actions: - action_id: hub.items.my input: { kind: book } save_as: my_kbs label: "β–Ά List my books" ``` ## Deleting items Destructive operation β€” confirm before firing: ``` hub.item.delete { item_id: "<id from hub.items.my>" } β†’ permanent; bytes go too ``` Gone is gone. Versions, artifacts, install URLs β€” all invalidated. People who installed older versions keep their local copies; new install attempts after delete fail. ## Account housekeeping A single safety-rail action covers the heaviest user op: - `hub.account.delete` β€” **PERMANENT, IRREVERSIBLE**: deletes your account, all published items, your bearer token. Signs you out and returns you to the signed-out shell. The UI path has a type-to-confirm modal; the MCP path doesn't. The AI assistant is the safety net β€” it refuses to call `hub.account.delete` unless your most recent message is unambiguous (e.g. "delete my account"). Casual phrasing like "clean up my Hub" gets a confirm-first response, not an immediate call. ## How the FaustWave manual is itself published Meta moment β€” the manual you're reading right now lives on the Hub as 10 book items (one per domain). Loop: 1. Author / edit a chapter as a markdown file in the markdown-editor module. 2. Save to `docs/books/<slug>/<NN>-chapter.md`. 3. Run `node docs/books/build-books.mjs` β€” builds one `_build/<slug>.md` per pack. 4. `hub.version.upload { item_id: <pack id>, kind: "book", file_path: <build artifact>, changelog }`. 5. `hub.install { item_id }` to re-index locally so the Reader picks up the new version. Author + reader are the same FaustWave instance. The manual evolves at the speed of the IDE. ## Common moves - **"Publish a draft I'll share with one friend"** β€” visibility `unlisted`, send them the item id. They install with `hub.install`. - **"Bulk-update tags on every book I own"** β€” `hub.items.my { kind: "book" }` then iterate `hub.item.update` per id with new tags. - **"Clone an item but with my own changes"** β€” `hub.fork`, then `hub.version.upload` to push your improved version on top of the source's v1. - **"Roll back a broken version"** β€” no rollback action; instead upload the previous content as v(n+1) with a "reverting to vX content" changelog. ## Where to go from here - **FaustWave β€” Builder** for the DSP side of `hub.publish kind: "builder"`. - **FaustWave β€” Recorder** Β§ 2 for the round-trip into sample-packs. - **FaustWave β€” AI Assistant** for asking the assistant to publish on your behalf. --- <!-- https://faustwave.io/docs/ai-assistant/assistant β€” book: AI Assistant --> # The AI Assistant FaustWave ships an in-app AI assistant pane with full read + write access to the IDE. It's not a chat sandbox β€” when you ask it to add a node, it adds the node. When you ask it for a chord progression, it drops the progression into your sequencer. Every action it takes lands in the Activity log so you can audit + (where applicable) undo. This section covers the user-facing side; for what the assistant sees + how external clients can drive the same surface, jump to Β§ 3 (MCP). ## Opening the assistant ```yaml actions: - action_id: chat.panel.toggle input: { open: true } label: "β–Ά Open the Assistant panel" - action_id: settings.open input: { section: ai-provider } label: "β–Ά Jump to provider setup (endpoint / model / key)" ``` Press **Ctrl+L** (Cmd+L on macOS), click the **Sparkles** icon in the topbar right cluster β€” or the first button above. The Assistant pane docks to the right of the workspace. If you haven't configured a model + API key yet, the pane prompts you. Click **Open Settings** (or the second button above) to jump to the configuration section. > 🟦 **NEW TO THE PATTERN?** Anthropic's Claude, OpenAI's GPT, and most other modern LLM APIs share a "tool use" protocol: you give the model a catalog of tools it can call, and it returns either a text reply or a tool call. FaustWave's Assistant passes the entire action registry as tool definitions, so the model can do anything you can do. No "limited subset", no permission tier β€” same surface, different operator. ## Bring your own key FaustWave does NOT host its own LLM. You bring your own provider + API key. Two reasons: (1) latency to a self-hosted model means seconds-long pauses on every tool call, ruinous UX; (2) your key, your data, your spend. Supported providers: | Provider | Status | |---|---| | **Anthropic Claude** | First-class β€” recommended for tool-use richness | | **OpenAI** | Supported β€” uses the chat/completions tool-use protocol | | **Any OpenAI-compatible endpoint** | Works β€” point at the URL + key (Groq, Together, local Ollama with the OpenAI compat shim, etc.) | Configure via Settings β†’ AI Assistant: - **Endpoint** β€” Anthropic's default URL, or your OpenAI-compatible URL. - **API key** β€” pasted, encrypted at rest in `app.json`. - **Model** β€” the model id (e.g. `claude-sonnet-4-6`, `gpt-4o`, `llama-3.1-70b-instruct`). You can swap models mid-conversation; the next turn uses the new model. ## What the assistant sees Every turn, FaustWave injects a system prompt + tool catalog into the model's context. The system prompt is composed of fragments contributed by each loaded extension: ``` <core extension prompt> ← project, FaustWave conventions, key idioms <dsp-builder prompt> ← node-kinds reference, MIDI conventions, mono/poly <sequencer prompt> ← track + slot + scale + chord-progression API <keyboard prompt> ← keyboard-source idioms <hub prompt> ← publishing + installation workflows <recorder prompt> ← recording + recordings library ``` Each fragment is short (< 1 KB typically) β€” they're not embedded reference content, just operational hints. For deeper context, the assistant searches the local Knowledge Base β€” your installed books, including this manual. The tool catalog is the curated set of hot-path actions (~25 today, well under the 128-tool cap) plus the `palette.*` meta-actions for reaching the rest of the ~130-action registry. See Β§ 3 for the discipline. The dsp-builder prompt fragment also teaches the assistant about per-kind library imports β€” kinds from `faust-vintage` declare `imports: ["tubes.lib"]` / `["tonestacks.lib"]` / `["vaeffects.lib"]`, and `faustGen` collects the union + emits `import("…")` lines after the default `stdfaust.lib` header. The assistant doesn't have to manage imports β€” it just adds the kind and the generator does the right thing. See **FaustWave β€” Node-Pack Authoring** Β§ 3 for the plumbing. ## The tool-use loop A turn looks like: 1. **You type a message** ("add a 4-voice poly sine to my Builder + sequence A minor over 16 steps"). 2. **Assistant receives** your message + the system prompt + the tool catalog + recent message history (up to a context-length cap). 3. **Model responds** with either: - A text reply ("here's what I'll do…"). - A list of tool calls (e.g. `builder.graph.node.add { module_id, kind: "osc" }` + `builder.graph.connect { ... }` + ...). 4. **FaustWave executes** each tool call through the action registry (same path you'd hit via UI or palette), captures the result, feeds it back to the model. 5. **Model responds again** with text or more tool calls. Loop until text-only response, or the model signals done. The loop streams as it happens β€” you see each tool call land in the **Activity** rail panel (right rail) in real time, and the assistant's text turns stream token-by-token. Everything the assistant writes is copyable: every fenced code block in a reply carries its own **copy button** (top-right of the block), and each assistant message has a **copy-as-markdown** button that grabs the whole reply as raw markdown β€” handy for moving a generated Faust snippet into the `.dsp` editor or a JSON blob into a node-pack manifest. ## Activity β€” audit + undo Open the **Activity** rail panel from the right rail (it ships seeded; if you removed it, `+ Add` β†’ *Activity*). It shows every action that flowed through the registry β€” your clicks, palette runs, AI tool calls, MCP calls β€” with input, outcome, timing, and result / error. Each entry shows: - **Timestamp**. - **Surface** β€” `mcp` for AI / external MCP, `palette` for your gestures, `controller` for a controller binding fired by a MIDI trigger (a pad, a button). - **Action id** + input. - **Outcome** (`completed` / `failed`) + duration (ms). - **Result** (success path) or error (failure path). For a multi-step AI session, the entries land in execution order. You can scroll back, see exactly what happened, and: - **Re-run** an action with the same input (handy for "do that again"). - **Copy as MCP** the action id + input, so you can replay it from a script. - **Filter** by surface (mcp / palette / controller) and outcome (completed / failed) to find issues. > 🟦 **THE AUDIT FLOW**: this is FaustWave's answer to "how do I trust an AI editing my files?" β€” the AI can't do anything the user can't see + audit. Sequencer + DSP edits have first-class undo (`sequencer.undo`, undo-able dsp actions); other actions you reverse by reapplying the inverse. If a session went sideways, scroll the Activity log back, identify the bad step, reverse from there. No black box. ## Common patterns ### "Build me a DSP" Describe the sound. The assistant adds nodes + wires them. > User: build me a fat saw lead in poly mode, 8 voices, with an envelope and a lowpass with some resonance > > Assistant: I'll set up a `saw` source β†’ `adsr` envelope β†’ `moog_vcf` lowpass β†’ `output`, in 8-voice poly mode. Adding nodes… > *(executes `dsp.polyphonic.set { voices: 8 }`, `builder.graph.node.add` Γ—4, `builder.graph.connect` Γ—4)* Five seconds, working DSP. ### "Tweak this" You're hearing the DSP + want a change. Ask for it. > User: lower the cutoff a bit and bring up the resonance to something dramatic > > Assistant: dropping cutoff to 600 Hz and resonance to 0.85. > *(`builder.graph.node.param.set` Γ—2)* The change is audible immediately (params live-write to the running engine). ### "Suggest a progression" The KB has 8 curated progressions with theory metadata. The Suggest Progression UI surfaces them; the AI does too. > User: drop a sad, 4-chord progression in A minor over my pattern > > Assistant: that fits the "i-vi-iv-v" loop in minor β€” A minor, F major, D minor, E minor. Inserting at steps 0/4/8/12 of the current sequencer. > *(`sequencer.progression.insert { progression_id: "i-vi-iv-v", key: "A", distribution: "bar" }`)* ### "Walk me through X" The assistant searches the KB before answering, so it can ground itself in the actual project + bundled theory + node reference. Ask things like *"what's the difference between a `moog_vcf` and an `lp` node?"* β€” it pulls the relevant node-pack entries and answers with the actual schemas. ## KB books β€” research the assistant keeps Ad-hoc answers evaporate when the conversation ends. For research worth keeping β€” "how does FM synthesis actually work", a deep-dive on a genre's drum programming, the harmonic analysis of a song you're covering β€” turn it into a **KB book**: a local book the assistant (and you) can recall in every future session. The flow: 1. **Research** β€” ask the assistant (or any external MCP client) to dig into the topic. 2. **Write the book** β€” save the findings as a project `.md` file: one `# Title` heading, then `## Section` headings per chapter. Tutorial `actions:` YAML blocks work here too, so a book can carry playable chains. 3. **Install** β€” `kb.book.install { file_path }`. No Hub, no auth, no network β€” the file indexes straight into the local KB. The pack id derives from the title as `local-<slug>`; re-installing the same book replaces its previous sections (clean update, nothing lingers). ```json { "action_id": "kb.book.install", "input": { "file_path": "asset:assets/fm-synthesis-notes.md" } } ``` (No button for this one β€” it needs YOUR file: a project ref like above, or an absolute OS path for a file outside the project. Ask the assistant to run it, or fire it via `palette.run`.) Once installed, the book behaves like any Hub-installed book: it appears in the Knowledge rail's *Installed Packs* list, opens in the Book Reader, and its sections answer `kb.search` β€” so next week's *"what was that sidechain trick again?"* grounds in YOUR book, not a fresh guess. Remove with `kb.book.uninstall { book_id }`; share it later with `hub.publish kind: "book"` using the same file. ## Scoping the assistant to a section When you open a section in the **Book Reader**, the Reader auto-pushes that section as the assistant's primary context. The next time you ask a question while reading **FaustWave β€” Builder** Β§ 1, the answer is grounded in that section first, then the rest of the manual, then the global corpora. You can pin a scope explicitly via the MCP `kb.context.scope.set` action β€” useful for a tutorial-walkthrough scenario where you want every question scoped to one chapter. `kb.context.scope.clear` releases the pin; the assistant returns to global grounding. ## Limits + things to know - **Context length** β€” the session history the model sees is capped: the system prompt plus the last 50 messages; older turns are dropped. The message the assistant is currently working on is never dropped, however many tool calls it takes. Tool results always reach the model in full β€” nothing is truncated. - **Response length** β€” *Settings β†’ AI Provider β†’ Max response tokens* (default 8192, minimum 256) is the output budget per request. Pay-per-use providers reserve it against your credit up front; reasoning models spend it on thinking too, so raise it if replies get cut off. - **No tool retries** β€” if a tool fails (e.g. wrong `module_id`), the error feeds back into the model and it can correct. You see the failed entry in the Activity log. - **No daemon mode interaction** β€” the Assistant pane is a renderer surface. For headless / scripted automation use MCP via Claude Desktop or a custom client; see Β§ 3. - **Cost** β€” the assistant pays for tokens at YOUR provider's rate. Long sessions with the full tool catalog can run dozens of cents per turn at Claude rates. Use a smaller model (haiku, gpt-4o-mini, an open 70B) for cheaper iteration. ## The hard parity rule This is the rule that makes the assistant trustworthy as a working partner: > Every UI gesture in FaustWave routes through `api.actions.run(action_id, input)`. The Assistant + the Command Palette + the MCP server all expose the same registry. There's no "AI-only" surface, no "human-only" surface β€” what you can do, the AI can do. What the AI can do, you can replay manually. The registry is the single source of truth for what's possible in the IDE. The lint that enforces this (`tools/check-ai-parity.mjs`) grep-checks that every action handler is reachable from both paths and runs on every PR. ## Where to go from here - **Β§ 2 (The Command Palette)** β€” the human-side surface to the same registry. - **Β§ 3 (MCP β€” external clients)** β€” same surface, different transport (Claude Desktop, custom scripts, daemon mode). - **FaustWave β€” Reference** for the full keyboard-shortcuts catalog. --- <!-- https://faustwave.io/docs/ai-assistant/palette β€” book: AI Assistant --> # The Command Palette The Command Palette is the human-facing surface to the same action registry that powers Β§ 1 (the AI Assistant) and Β§ 3 (the MCP server). Press `Ctrl+K` (Cmd+K on macOS), type what you want, hit Enter. ```yaml actions: - action_id: palette.overlay.toggle input: { open: true } label: "β–Ά Open the Command Palette (Escape to close)" ``` ## Opening + dismissing | Gesture | Effect | |---|---| | **Ctrl/Cmd+K** | Open the palette | | Click the **Search icon** in the topbar right cluster | Open the palette | | **Escape** | Dismiss | | Click outside the palette | Dismiss | The palette overlays the workspace; focus shifts to the input field. ## Fuzzy search Type any substring of an action's **title**, **description**, or **category**. Matches rank by relevance β€” exact-prefix on title beats mid-string in description. Examples: | Type | Surfaces | |---|---| | `dark` | Theme: Dark + any other action with "dark" in the title | | `bpm` | Master: Set BPM + Transport: Set BPM | | `play` | Transport: Play + DSP: Run + Sequencer: Play + Recorder: Start + DSP: Run | | `recorder` | Every `recorder.*` action | | `mcp` | The `palette.run` / `palette.list` / `palette.describe` meta-actions | Empty search shows recent + pinned actions (if any) followed by alphabetical. ## What's in the palette Only actions that declare a `run` handler (the UI-callable path). MCP-only actions (`mcpRun` without `run`) don't show up β€” they're intentionally not user-driveable from the palette (the human surface is the related UI; the AI surface is MCP). In practice that's: - All theme-set actions (`Theme: Dark` / `Light` / `Cyberpunk` / `System`). - Transport (`Transport: Play` / `Stop`). - Master + Mixer (`Master: Set Volume`, `Mixer: Set`, etc.). - All DSP-Builder, Sequencer, Recorder dual-path actions. - Module management (`Modules: Add` / `Close` / etc.). - Hub actions (`Hub: Open`, `Auth: Login`, ...). Roughly ~80 actions of the ~130 registered. ## Action previews Some actions render a small preview swatch in the palette row β€” currently the theme picker shows a gradient swatch matching the theme it sets: | Action | Preview | |---|---| | `Theme: Dark` | Dark gradient swatch | | `Theme: Light` | Light gradient swatch | | `Theme: Cyberpunk` | Magenta β†’ purple β†’ cyan gradient | | `Theme: System` | Split light / dark gradient | The preview surface is extensible β€” extensions can declare a custom preview renderer per action. ## Keyboard navigation Inside the palette: | Key | Effect | |---|---| | `↓` / `↑` | Move selection through filtered results | | `Enter` | Run the selected action | | `Escape` | Dismiss (no action) | | `Tab` | Cycle through *recent* and *all* filters (planned) | Mouse: hover highlights, click runs. ## Activity capture Every palette invocation lands in the **Activity** rail panel just like an MCP call or a UI click. Surface is tagged `palette`. Replay-able, audit-able β€” the same trace surface the assistant + external MCP clients land in. ## Why a palette exists alongside menus + shortcuts + the AI Three reasons: 1. **Discoverability** β€” keyboard shortcuts are fast once you know them, but you have to learn them first. The palette fuzzy-searches the entire surface; type what you want, get there. 2. **AI parity** β€” every palette entry is also a registry action. If you can do it in the palette, the AI can do it too. The palette doubles as documentation of what's possible. 3. **Friction-free access** β€” no menu hunting, no chrome to read, no spatial scanning. Two keystrokes + a search term + Enter. For the AI side of this same registry, see Β§ 1 and Β§ 3. ## Where to go from here - **Β§ 1 (The AI Assistant)** β€” the same registry, called by the model. - **Β§ 3 (MCP)** β€” the same registry, called from outside the IDE. - **FaustWave β€” Reference** Β§ *Keyboard shortcuts* β€” the persistent-shortcut surface for the most-used actions. --- <!-- https://faustwave.io/docs/ai-assistant/mcp β€” book: AI Assistant --> # MCP β€” driving FaustWave from external clients The **Model Context Protocol (MCP)** is the open standard for "LLMs talking to applications." FaustWave exposes its action registry as an MCP server. That means you can hook FaustWave up to **any MCP client** β€” Claude Desktop, a custom script, a CI job, another AI tool β€” and drive the IDE from outside. The same surface the in-app AI Assistant uses. Same actions, same input schemas, same trace into the Activity log. Different transport, that's all. ## What it's for Three real use cases: 1. **Claude Desktop driving FaustWave** β€” pull up Claude, ask it to build a DSP, it drives FaustWave via MCP. Same as the in-app Assistant, but you're outside the FaustWave window. 2. **Automation scripts** β€” render N variations of a DSP with different param sweeps, batch-import a sample folder, regenerate sequencer patterns from an external composer. Headless-friendly via daemon mode. 3. **Authoring tutorials + content** β€” the very manual you're reading is being written + published via MCP. The loop: open a markdown editor module via `documents.add`, `editor.source.set` the content, `documents.save`, build the pack, `hub.version.upload`. See Β§ *Authoring this manual via MCP* below. ## Connecting Claude Desktop The Claude Desktop config file lives at: | Platform | Path | |---|---| | **macOS** | `~/Library/Application Support/Claude/claude_desktop_config.json` | | **Windows** | `%APPDATA%\Claude\claude_desktop_config.json` | | **Linux** | `~/.config/Claude/claude_desktop_config.json` | Add a FaustWave entry under `mcpServers`: ```json { "mcpServers": { "faustwave": { "command": "<path-to-faustwave>", "args": ["--mcp"], "env": {} } } } ``` Restart Claude Desktop. You should see a "πŸ›  faustwave" indicator in Claude's tool palette next to your message bar; clicking it reveals the action catalog. > 🟦 **HOW THE TRANSPORT WORKS**: Claude Desktop spawns FaustWave as a child process and talks to it over stdin/stdout JSON-RPC. Each tool call is a JSON message; the response is a JSON message back. FaustWave's MCP server is the same Electron process β€” it boots into a headless renderer (no visible window in `--mcp` mode), registers the action catalog, and serves requests until Claude disconnects. ## The tool catalog When the MCP client connects, FaustWave publishes its actions as tools. Roughly: - **~130 actions** total in the registry. - **Hot-path tools** (a curated subset of the highest-frequency ones) are exposed directly with their full schemas so clients see them at first glance: `dsp.compile`, `dsp.run`, `dsp.stop`, `dsp.node.*`, `builder.graph.connect/disconnect`, `midi.note.play`, `sequencer.play/stop/step.set`, `master.volume.set`, `transport.play/stop`, `recorder.start/stop/toggle`, `routing.list/connect/disconnect`, etc. - **`palette.run`** is the meta-action β€” exposes the FULL catalog. To call any action that isn't a hot-path tool, you call `palette.run { action_id, input }`. The split exists because of the **MCP 128-tool soft cap** β€” Anthropic's API rejects more than 128 published tools in a single context. Hot-path tools are the high-frequency ones that benefit from inline schemas; everything else lives behind `palette.run`. Current publishing decision: ~22 hot-path tools + `palette.run` + `palette.list` + `palette.describe` = 25 published, well under the cap. ## Discovery β€” find what you need Three meta-actions FaustWave always publishes: | Action | Purpose | |---|---| | **`palette.list`** | Browse the full catalog. Defaults to category summaries. Pass `category` for a focused list. Pass `search` for substring matches. Pass `all: true` for everything. | | **`palette.describe`** | Full input_schema + description + examples for up to 10 action ids at a time. | | **`palette.run`** | Invoke any action by id with structured input. Validates against the schema; rejects unknown ids with a friendly error. | **Pattern**: when you want to do something β€” say, add reverb: ``` 1. palette.list { search: "reverb" } β†’ candidate actions 2. palette.describe { action_ids: ["master.fx.slot.add"] } β†’ input_schema + examples 3. palette.run { action_id: "master.fx.slot.add", input: { … } } ``` This is the dance the in-app Assistant does internally on every turn. External clients (Claude Desktop included) do the same. ## Hard-required `module_id` on DSP-Builder actions A discipline rule: **every DSP-Builder action requires an explicit `module_id`** input. No implicit fallback to "the currently active Builder." The reason is a time-of-check / time-of-use race. Before the rule, an AI mid-task could call `builder.graph.node.add` and FaustWave would silently target whatever Builder was active in the user's UI at the moment of dispatch. User switches tabs mid-AI-task β†’ next AI action lands on the WRONG Builder. The symptom (AI's work appearing in a different file) is hard for the user to spot because the AI's reply still reads coherent. Closed by the rule: `module_id` is required, the schema rejects the call without it, and the error path lists the available Builder ids so the AI self-corrects: ```json { "error": "Module id is required. Available Builders: builder-mq2dtphb-8ygb (\"test-slice-c\"), builder-mq2du5g5-coyr (\"untitled\")" } ``` A pre-existing **sequencer exception** is grandfathered β€” sequencer actions take an optional `instance_id` with active-fallback. Lower-frequency surface; sequencers are typically created once per project and switched between rarely, so the race window is empirically tiny. ## Activity surfaces every call `activity.recent { limit?, surface?, outcome?, text? }` returns the most-recent action invocations across MCP, palette, and AI surfaces. Filter to see only what YOU did via MCP: ```json { "action_id": "activity.recent", "input": { "surface": "mcp", "limit": 20 } } ``` For a session-history view, this is the source of truth. The in-app Activity rail panel reads from the same store, so you and the user see the same thing. ## Daemon mode β€” headless FaustWave For server-side use or scripted batch work, boot FaustWave with the `--daemon` flag (or set `FAUSTWAVE_DAEMON=1`). Behaviour: - No visible window. Tray icon for stop/quit control. - Full audio + MCP run. - MCP force-starts. - Persistence + project state work as normal. Use case: a batch job that opens a project, sweeps a Builder's params, records 20 takes, and exits. Daemon mode is a deliberate "FaustWave-as-service" surface β€” not for everyday use, but the door is open for the socket-API track and the "open alternative to controller second-tracks" use case. ## Building your own MCP client If Claude Desktop is too heavyweight (or you want CI integration), write a thin client. The protocol is open + documented at [modelcontextprotocol.io](https://modelcontextprotocol.io). FaustWave's server is a standard MCP implementation β€” no FaustWave-specific extensions. Roughly: 1. Spawn FaustWave as a child process with `--mcp` (or connect to a running daemon). 2. Send a `tools/list` request to discover the published tools. 3. Send `tools/call` requests with `{ name, arguments }` to invoke. 4. Process the response stream. Languages with established MCP SDKs include TypeScript / JavaScript (official), Python (official), and a growing community list. Pick whichever fits your stack. ## Authoring this manual via MCP β€” the live loop The book you're reading is the canonical live-loop demo: 1. **Open a markdown editor module in the IDE** via `documents.add { ref: "asset:assets/<chapter file>.md" }` (or `file_path` with an absolute OS path to import an external file). 2. **Edit** β€” `editor.source.set { module_id, source: "<new content>" }` (or just type in the editor surface). 3. **Save** β€” `documents.save { module_id }` writes to the file path. 4. **Build** the pack β€” `node docs/books/build-books.mjs` concatenates the pack's `.md` files into `_build/<slug>.md` with H1 β†’ H2 demotion so each chapter becomes one Book section. 5. **Publish a new version** β€” `hub.version.upload { item_id, kind: "book", file_path: "<built bundle>", changelog }`. 6. **Reinstall locally** β€” `hub.install { item_id }` to pull v(n+1) into the local KB. Four to six MCP calls per chapter update. The dance is fast enough that "write a paragraph, see it rendered" is a 10-second loop. The manual writes itself via the actions it documents. ## Authoring chains β€” not just one-shot The Book Reader supports **chained tutorial actions** in section YAML blocks. Each step's return value can be saved with `save_as: <var>`; downstream steps reference `{{var.path}}` to thread results through. Every playable chain in this manual uses the same primitive β€” launched from the Reader, the chain runs through `api.actions.run` with status badges, inline result chips, and a Reset button. See **FaustWave β€” Hub** Β§ 1 for the book content shape and any section's `actions:` block in this manual for examples. ## Where to go from here - **Β§ 1 (The AI Assistant)** β€” the in-app equivalent of what you do from Claude Desktop. - **Β§ 2 (The Command Palette)** β€” the human-side surface to the same registry. - **FaustWave β€” Hub** Β§ 3 β€” the publish + install workflows that the live-loop hits over MCP. - **FaustWave β€” Reference** Β§ *MCP actions* β€” the full hot-path catalog. --- <!-- https://faustwave.io/docs/reference/keyboard-shortcuts β€” book: Reference --> # Keyboard shortcuts The shortcut table here covers the high-frequency stuff; the full catalogue lives in the codebase + may evolve. Use the Command Palette (`Ctrl/Cmd+K`) when in doubt β€” it's how you discover everything. ## Conventions - **Cmd** on macOS = **Ctrl** on Windows / Linux. Listed as `Cmd/Ctrl+X` below. - **Option** on macOS = **Alt** on Windows / Linux. Listed as `Opt/Alt+X` below. - Shortcuts work from anywhere unless noted as scoped to a panel. ## Global | Shortcut | Action | |---|---| | `Cmd/Ctrl+K` | Open the Command Palette | | `Cmd/Ctrl+L` | Open the AI Assistant pane | | `Cmd/Ctrl+,` | Open Settings | | `Cmd/Ctrl+Z` | Undo (active document) | | `Cmd/Ctrl+Shift+Z` | Redo (active document) | | `Space` | Toggle global transport Play / Stop | | `Esc` | Dismiss any overlay (Palette / Settings / modals) | ## Transport | Shortcut | Action | |---|---| | `Space` | Play / Stop master transport | (More transport shortcuts β€” Loop toggle, Rec toggle β€” planned.) ## Documents | Shortcut | Action | |---|---| | `Cmd/Ctrl+W` | Close the active document | | `Cmd/Ctrl+Tab` | Cycle through open documents | | `Cmd/Ctrl+1` to `9` | Switch to the Nth open document | ## Sequencer (scoped to the Sequencer panel) | Shortcut | Action | |---|---| | `1` to `6` | Switch active track (1..6) | | `Shift+1` to `6` | Toggle track N visibility | | `Cmd/Ctrl+1` to `6` | Toggle track N solo | | `P` | Switch to Paint tool | | `S` | Switch to Select tool | | `Up` / `Down` | Move selected notes Β±1 semitone (Select tool) | | `Shift+Up` / `Down` | Move selected notes Β±1 octave (Select tool) | | `Esc` | Clear selection | | `Cmd/Ctrl+Z` | Sequencer undo | | `Cmd/Ctrl+Shift+Z` | Sequencer redo | ## Builder (scoped to the Builder canvas) | Shortcut | Action | |---|---| | Right-click pane | Open node picker at cursor | | Right-click node | Node context menu (Duplicate / Bypass / Disconnect all / Delete) | | Right-click edge | Edge context menu (Delete) | | `Delete` / `Backspace` | Delete selected nodes / edges | ## Editor (Faust / `.dsp` / `.lib`) Standard editor shortcuts (`Cmd/Ctrl+S` to save, etc.) β€” these are inherited from Monaco underneath. ## Why this table is short We deliberately keep registered shortcuts to a focused set β€” discoverability beats memorization for occasional gestures. The Command Palette gets you anywhere in two keystrokes + a search. Reach for shortcuts for the things you do dozens of times per hour; everything else stays in the palette. > πŸ”˜ **MCP**: shortcuts route through the same registry as everything else β€” every shortcut binding maps to an `action_id`. To inspect what an action_id does, `palette.describe { action_ids: [<id>] }`. ## Where to go from here - **FaustWave β€” AI Assistant** Β§ 2 β€” the Command Palette in depth. - **Β§ 2 (MCP actions)** β€” the underlying action catalog every shortcut routes through. --- <!-- https://faustwave.io/docs/reference/mcp-actions β€” book: Reference --> # MCP actions β€” full catalog The live catalog is always one MCP call away, and the codebase is the source of truth. This section explains how to navigate it, not enumerate every entry (the registry sits at ~130 actions today and adds / renames are continuous β€” recent additions include `dsp.compile` for FX-shape validation in the `.dsp` editor and `builder.kind.describe` now surfaces per-kind `imports` for the libfaust-wasm bundled libs). ## The discovery dance The pattern every MCP client uses (including the in-app Assistant) to find what it needs β€” say, to add reverb: ``` 1. palette.list { search: "reverb" } β†’ candidate actions 2. palette.describe { action_ids: ["master.fx.slot.add"] } β†’ input_schema + examples 3. palette.run { action_id: "master.fx.slot.add", input: { … } } ``` For a quick *"what categories exist"*: ``` palette.list {} ← returns category summaries ``` For everything: ``` palette.list { all: true } ← full catalog ``` ## Categories (today) Actions cluster by category. The current set: | Category | Roughly | |---|---| | **DSP** | Builder graph ops (~24 actions, hard-required `module_id`) + Faust DSP ops (`dsp.run`, `DSP.param.*`) | | **TRANSPORT** | `transport.play / stop / bpm.set` | | **MIDI** | Sequencer (~30 actions: tracks, slots, scenes, steps, key, progressions) + Keyboard (`keyboard.note.on/off`, `.channel.set`, `.octave.set`, `.hold.set`, `.notes.clear`, `.state`) + `midi.note.play` / `midi.sequence.play` / `midi.sequence.stop` + ports & diagnostics (`midi.ports.list`, `midi.monitor.get`) + **`midi.panic`** | | **AUDIO** | Master volume / softclip / clip clear, audio device select, Master FX slots, Recorder + recordings, MixerTracks (`mixer.tracks.*` β€” set / create / delete / rename / reorder / attach_source / fx.addΒ·removeΒ·set) + legacy per-module strip params (`mixer.set` / `mixer.clear`) | | **ROUTING** | Connection CRUD (`routing.list / connect / disconnect / set`), Source / Sink listing | | **MODULATION** | LFO sources, mod-edge connect/disconnect, `modulation.base.set` | | **HUB** | Search, install, publish, version upload, fork, star, item update / delete, account.delete (safety-railed) | | **KB** | Search, doc.get, section list / get, scope set / clear, pack uninstall, reindex | | **PROJECT** | List, current, switch, close, export, import, list project files (`project.files`), rename a project file (`project.file.rename`), delete a project file (`project.file.delete`) | | **FILE** | Samples (`samples.import / list / get / remove`), lib mount / list, library list, editor source set / get / save | | **VIEW** | Toggle rail / master / minimap panel | | **APPEARANCE** | Theme set (Dark / Light / Cyberpunk / Auto) | | **SYSTEM** | `system.state` snapshot (master / mixer / mixer_tracks / transport / chat / kb / auth / routing / midi-consumers / hub) | | **DEBUG** | `logs.get`, `activity.recent` | For a current count + breakdown: ```yaml actions: - action_id: palette.list input: {} save_as: cats label: "β–Ά Get the per-category counts" ``` ## Hot-path tools vs `palette.run` The MCP transport publishes a curated subset (~22) of the highest-frequency actions as **hot-path tools** β€” they appear in the MCP client's tool list with their full schemas. The other ~108 actions live behind the `palette.run` meta-action. The split exists because of the **MCP 128-tool soft cap** β€” Anthropic's API rejects more than 128 published tools in a single context. Current publishing decision: ~22 hot-path tools + `palette.run` + `palette.list` + `palette.describe` = 25 published, well under the cap. Which actions are hot-path varies by build; the canonical list is the one the MCP client sees on connect. ## Hard-required `module_id` rule Every DSP-Builder action requires `module_id` as a first-class input. No fallback to "the active Builder." See **FaustWave β€” AI Assistant** Β§ 3 *Hard-required `module_id` on DSP-Builder actions* for the rationale and the TOCTOU race it closes. The sequencer has a documented exception β€” `instance_id` is optional with active-fallback. Grandfathered as a lower-frequency surface. ## Dual-path vs mcpRun-only Some actions have a `run` handler (the human-callable / Command-Palette path) plus an `mcpRun` handler (the AI-callable / MCP path). Some are **mcpRun-only** β€” the registry doesn't surface them in the Palette because they only make sense from the AI side. Examples: - **Dual-path**: `transport.play` (UI button + MCP), `recorder.toggle` (Topbar Widget + MCP), most user-facing toggles. - **mcpRun-only**: `builder.graph.get` (no UI surface for "show me the graph as JSON" β€” that's the canvas), `kb.search` (the assistant's grounding tool, not a user gesture). ## Friendly error strings Most actions return strings (success or error) rather than throwing. The Activity log shows the string in the `result` field for both β€” handler-returned error strings still count as `outcome: completed`. Real exceptions are `outcome: failed` with the message in `error`. This is the "AI-friendly errors" convention β€” the model can read the string back, self-correct, retry. Examples: - `"Module id is required. Available Builders: builder-abc, builder-xyz"` - `"dsp.polyphonic.set needs a non-negative integer voices count, got string: 'eight'"` - `"Theme set to cyberpunk."` ## Where the real catalog lives For a precise + current listing, query the registry directly: ``` palette.list { all: true } ``` It returns id + title + category + description for every registered action. Pipe through `palette.describe { action_ids: [...] }` for the input schemas. The codebase organisation mirrors the categories β€” actions live in `packages/<extension>/src/commands/*.ts` or `lib/commands.ts`. Each `buildXxxCommands(api)` returns the `ActionDef[]` for that extension. The shell aggregates them at extension activation. ## Where to go from here - **FaustWave β€” AI Assistant** Β§ 2 β€” the Command Palette (human surface to the same registry). - **FaustWave β€” AI Assistant** Β§ 1 β€” the in-app AI surface. - **FaustWave β€” AI Assistant** Β§ 3 β€” the external-client (MCP) surface. --- <!-- https://faustwave.io/docs/reference/file-formats β€” book: Reference --> # File formats Every file format FaustWave reads, writes, or distributes β€” in one place. ## DSPs + libraries | Extension | Content | Tool | |---|---|---| | **.builder** | Builder graph document (JSON: nodes, edges, polyphonic config, sample metadata) | DSP Builder module | | **.dsp** | Faust DSP source β€” a `process = ...` definition | Faust DSP module + Faust editor | | **.lib** | Faust library β€” collection of definitions, no `process`. Mounted into libfaust VFS via `faust-library.mount`. | Faust editor | | **.nodepack.json** | Community node-pack manifest. Declares new node-kinds the Builder can use. | Build-tool / Hub install | A `.builder` is JSON; you can edit it in any text editor. The schema documents itself β€” open one and read. ## Knowledge + content | Extension | Content | Tool | |---|---|---| | **.md** (book) | Markdown bundle with `## Section`-headed blocks. Each section is a retrieval unit + optionally carries a YAML actions block. | Book Reader | | **.fwproject.zip** | Exported project bundle β€” `project.json` + `workspace.json` + `assets/` folder, zipped. Portable across machines. | Project import/export | | **.json** (generic) | Free-form JSON editor β€” useful for node-pack manifests, `app.json` edits, etc. | JSON editor module | ## Audio | Extension | Read | Write | Notes | |---|---|---|---| | **.wav** | βœ“ Full header (channels / rate / duration) | βœ“ 32-bit float, Recorder default | Lossless, big | | **.flac** | βœ“ Bytes only (header parsing is TODO) | β€” | Lossless, smaller | | **.aiff** | βœ“ Bytes only (header parsing is TODO) | β€” | Lossless | For non-WAV imports, channels / sample rate / duration land as zeros in the SampleEntry β€” playback still works (Faust's `soundfile` primitive handles arbitrary formats), but the metadata isn't there. ## App state | File | Purpose | |---|---| | **app.json** | App-shell preferences (theme, window state, audio devices, AI provider, active-project pointer). Per-install / machine-global. | | **project.json** | The song β€” MixerTracks, sequencer patterns, routing, controller bindings, param bases, surfaces. Auto-saved. | | **workspace.json** | How you were sitting β€” which documents were open, which track had focus. Travels with the project, but it is view state, not content. | | **assets/** | Per-project asset folder β€” the `.builder` / `.dsp` / `.lib` files referenced by the project. | Both are JSON β€” readable, diffable, git-able. Don't hand-edit while FaustWave is running; the auto-save will clobber your changes. Edit while the app is closed. ## Recordings | Path | Content | |---|---| | `<userData>/recordings/<ISO-date>_<rand>.wav` | 32-bit float WAV, stereo, project sample rate | OS-specific `userData`: - **Windows**: `%APPDATA%\FaustWave IDE\` - **macOS**: `~/Library/Application Support/FaustWave IDE/` - **Linux**: `~/.config/FaustWave IDE/` ## Sample store | Path | Content | |---|---| | `<userData>/samples/<sha>/<filename>.<ext>` | Original bytes, content-addressed by sha256 | The folder structure is sha-prefixed β€” that's what lets `samples.import` be idempotent + content-addressed. The original filename is preserved inside the folder so the file is recognizable on disk. ## Hub artifacts When you publish to the Hub, your file uploads as an Artifact. The Hub serves it via `/api/v1/artifacts/<id>` URLs β€” those are the `downloadUrl` fields you see in `hub.item.get` responses. Artifacts live behind the item's visibility (public artifacts are CDN-cached; private artifacts require an authenticated download). ## Where to go from here - **FaustWave β€” Builder** Β§ 2 β€” what's in a `.builder`, how to round-trip + diff. - **FaustWave β€” Builder** Β§ 3 β€” how samples land in the sample store (sha-keyed). - **FaustWave β€” Hub** Β§ 3 β€” publishing + artifact handling. --- <!-- https://faustwave.io/docs/reference/themes β€” book: Reference --> # Themes FaustWave ships four theme modes: **Dark**, **Light**, **Cyberpunk**, **Auto**. Switch via Settings β†’ Appearance, via the Command Palette (`Theme: Dark` / `Theme: Light` / `Theme: Cyberpunk` / `Theme: System`), or via the `theme.set` MCP action. ## The four modes | Mode | When to pick it | |---|---| | **Dark** | Default. High contrast, reduced eye strain in low light. | | **Light** | Bright environments, daylight, screens you share. | | **Cyberpunk** | Neon-on-near-black, LED-panel aesthetic. For vibe. | | **Auto** | Follows your OS appearance setting. Switches at sunset if your OS is configured to. | The chosen mode persists per-install (not per-project) β€” it's a user preference, not a project asset. ## How the theme system works Every theme is a block of CSS custom properties under a single `:root[data-theme="..."]` selector in `packages/ui-kit/src/styles/tokens.css`. The `setTheme()` function sets `<html data-theme="...">`; the rest of the codebase reads the tokens via `var(--token-name)`. ```css :root[data-theme="dark"] { --bg: #0b0e14; --text: #e2e8f0; --cyan: #00d4f5; /* ... ~80 more tokens */ } :root[data-theme="light"] { /* ... */ } :root[data-theme="cyberpunk"] { /* ... */ } ``` This means **the same component code runs in every theme** β€” only the token values differ. Build new components against the token names; they'll automatically theme. ## The token taxonomy Roughly 80 tokens per theme, grouped: | Group | Tokens | |---|---| | **Backgrounds** | `--bg`, `--bg-2`, `--bg-3`, `--panel`, `--panel-2`, `--card`, `--card-2` | | **Borders** | `--border`, `--border-2`, `--border-3`, `--card-border` | | **Text tiers** | `--text` (primary), `--text-2` (muted), `--text-3` (dimmed), `--text-4` (placeholder) | | **Shadows** | `--shadow-lg`, `--shadow-md`, `--shadow-sm` | | **Accent colors** | 8 hues: `--cyan`, `--purple`, `--pink`, `--orange`, `--green`, `--yellow`, `--red`, `--blue` | | **Hover variants** | `--red-hover`; the teal's is a role, `--action-hover` | | **Accent backgrounds** | 5 alpha tiers Γ— 8 hues: `--accent-bg-cyan-subtle`, `-15`, etc. | | **Accent borders** | 3 alpha tiers Γ— 8 hues: `--accent-border-cyan-40`, `-50`, etc. | | **Glow effects** | One per hue: `--glow-cyan`, etc. | | **Wave + node-editor** | `--wave-stroke`, `--wave-fill`, `--wave-grid`, `--node-bg`, `--node-edge`, `--node-port` | | **Sequencer track palette** | `--seq-track-1` through `--seq-track-6` (per-track colour-coded) | | **Utility overlays** | `--overlay-dark`, `--overlay-dark-35`, etc. | ## Cyberpunk-only effects The Cyberpunk theme adds an overlay layer on top of the regular token block β€” purely visual atmosphere: - **LED dot-matrix overlay** β€” a 4 px-tile cyan dot grid via `[data-theme="cyberpunk"]::after`, `mix-blend-mode: screen`. Reads as a faint LED panel substrate at typical viewing distance. - **Magenta-edge vignette** β€” soft radial gradient pulling the eye toward centre. - **Phosphor text-shadow** on the brand title + the Transport Widget's LCD readouts β€” tight 1 px core + 4–6 px halo, reads as a discrete pixel emitter. These effects are scoped via `[data-theme="cyberpunk"]` selectors β€” Dark and Light don't see them. ## Building a fourth theme To add a custom theme: 1. Open `packages/ui-kit/src/styles/tokens.css`. 2. Add a new block: `:root[data-theme="my-theme"] { /* full token set */ }`. Copy the Dark or Light block as a starting point + retune hex values. 3. Add the kind to the `setTheme` enum in `packages/extension-api/src/index.ts`. 4. Add a `theme.<my-theme>` palette action in `packages/extension-core/src/index.ts` (icon: pick from lucide). 5. Add the kind to the persistence allow-list in `packages/app/src/lib/persistence.ts` (the hydration validator that normalises unknown themes back to dark). 6. Add a segment button in `AppearanceSection.svelte`. 7. Add a Theme-Indicator preview in `CommandPalette.svelte`. About 6 small edits β€” none structural. The theme system was deliberately built to make adding a new theme a half-hour job. ## Theme persistence The chosen theme persists across restarts via `app.json`. The hydration step also has a validator that normalises any unknown theme back to Dark β€” leftover from a previous theme experiment + a safety net against corrupted state. ## Where to go from here - The actual token values per theme live in `packages/ui-kit/src/styles/tokens.css` β€” read it to see the exact hex / rgba per token per theme. - The Cyberpunk overlay rules live in `packages/app/src/styles/global.css` under the `[data-theme="cyberpunk"]::after` selector + the LCD text-shadow rules.