Explore a patch's parameters and structure to connect a control change with the sound it produces.
What it is#
A dock that pairs one synth's compiled node graph with large live signal panels for studying that patch. Every reachable IR node gets a tile; a silent preview voice in the engine rings each node so the picture is stable and controllable while you push knobs — the sound-design / teaching zoom level. Unlike the editor's playing-step overlay (which reads whatever the transport happens to be firing), the inspector drives its own voice.
Surface#
Not a language feature — a workbench panel, in two places:
- the standalone dock at the bottom of the right column — withheld for now (
PATCH_INSPECTOR_DISCOVERABLE = falseinui/src/actions.rs: its height kept collapsing on its own). Hidden, the dock, its head bar and the View ▸ Show ▸ Patch Inspector / ⌘I toggle are gone; the timeline takes the whole column. Flipping the const restores the head bar (ui/src/app/chrome.rsinspector_head, hosted byui/src/app/rightcol.rs). The sameRackUiserves theracksound view (⌘5), which stays. - the bench notebook's per-patch-cell
inspect ▸chip (ui/src/bench.rs), which opens the dock on itswireview (RackUi::for_dock).
Above the inspector, the DSP PROBE dock appears while the source editor's caret is on a user kernel call, an effect or a kernel recipe. It shows that DSP's measured response, impulse, step and transfer curves, a stability strip per parameter and findings such as "unstable above cut = 0.64×Nyquist". On an oscillator declaration or a processO call it shows the oscillator view instead: the measured spectrum of a held note at four pitches (110 Hz, 440 Hz, 1.33 kHz, 4.97 kHz) with its alias floor. On a spectralMap / spectralPolar call, or an effect over one, it adds a flat-frame view of the per-bin law's gain at three input levels. Its head collapses it to one line naming the subject. The dock runs its own offline probe, not the preview voice. See See your DSP.
Head-bar controls: the layout switcher, one framed segmented control (modules / wire / graph / routes / chain / grid, one selected; a View · menu when the header is narrow), then the Fit graph and Float graph actions set apart by a rule, ⏵ audition (fire a one-shot through the preview bus and ring every reachable node), the audition note and velocity as one chip (C4 · 0.80, a note name, not a MIDI number; it opens their sliders), A/B ref (freezes an A/B reference), COMPARE (Compare sound, as a popup), and ⇣ wav on tile hover. Every view shares this header with the case. Below each view, the case's navigation rail — SONG and a chip per track that plays the patch — opens the song or that track in the case. The chain layout's scrub rail solo-monitors one stage so you can hear where in the spine the sound changes.
Compare sound at equal level#
Open COMPARE in the Rack header. Set the audition note and velocity, then Keep A. Edit and Run the patch, or select another patch, and choose Compare B. A stays fixed as rendered audio, including its captured samples and macro settings. B uses A's note and velocity with its own current macros.
Play A and Play B loop the same four-second window and switch at the same playback position. Match level starts enabled. The suggested B trim is the difference in measured stereo RMS; Copy multiplier copies a linear factor such as * 2 for an intentional source edit. Auditioning itself does not change source, live gain, or exports. Stop comparison releases the monitor; Clear discards both captures.
This compares the last-run patch, with a 0.8-second hold and its tail up to four seconds, before track inserts, buses and master effects. Long rings end with a short fade at that window's edge. RMS matching is a repeatable level comparison; different spectra can still differ in perceived loudness. Playback aims at −24 dBFS RMS, lowers both sides together to leave −3 dBTP headroom, and caps makeup at +60 dB. With matching off, any necessary headroom attenuation is common to both sides, preserving their original level gap. Silent, invalid or missing-sample renders report an error. Live-input and cross-track-dependent patches need a printed sample first.
The comparison renders and plays through its own audition path (engine/src/audition.rs), not the engine's ordinary sample player, which caps gain at 8×, passes through the live master section and varispeed, and stops at the end of the buffer — none of which suits a gain-matched loop. A is kept as audio, not as a source string, so later edits, imports or sample reloads cannot change what A sounds like. Levels are measured as the energy of both stereo channels without downmixing first, so an opposite-polarity stereo patch measures as the sound it is rather than as cancelled silence. A new A/B surface plays through this same audition path, and Stop All must release its loop even while the song transport is idle.
Freeze reference remains the visual scope overlay. The audible comparison is separate. Design: Gain Matched Audition; hybrid examples: 1.9 Hybrids — the hand and the body.
How it's implemented#
- Engine.
engine/src/preview.rsrenders preview voices in a dedicatedVoiceArenainto a private stereo bus summed into the master at a smoothed monitor gain (0 = the silent study voice; the chain scrubber and hold-to-monitor raise it). Per-node tap rings (PREVIEW_TAP_LEN = 8192samples, at mostMAX_PREVIEW_TAPS = 24per voice) are prebuilt control-side, appended by the audio thread undertry_lock, and retired through the garbage ring.EngineHandle::read_preview_tapsunrolls them — computing the spectrum control-side with the AnalyserNode recipe (Blackman window + real FFT → dB) — into one little-endian binary frame. - Shell seam.
UiCommand::Audition { … , taps }registers the ring set;EngineBridge::preview_framepolls the binary frame, whichui/src/taps.rsdecode_preview_tapsturns into aPreviewFrame(spectrum_db_f32,harmonics,thd_percentlive beside it). The same seam serves the desktop, the browser worklet and the plugin window — there is one engine. - Panels.
ui/src/rack.rsowns every layout, the live traces, the period-lock window, the insight overlays and the freeze reference; static per-load node data (ids, ops, units, rates, source spans, inline constants) comes fromui/src/moduleinfo.rsparse_ir. - Live values without an audition. Between auditions each tile scrolls a block-RMS trace of the live song voice, read from the viz frame (
ui/src/frame.rs), reset on synth / module-generation change.
Native notebook inspect dock (grid / chain / graph)#
The dock is IR-node-driven and has six layouts switched by header chips. modules (the authored functions, combinators and effects) and wire (the complete signal graph with repatchable jacks; the dock opens here) are the Patch Bay and Rack surfaces; routes edits literal modulation depths (see Shape modulation in Routes); the other three:
- grid — one fixed scope tile per reachable node, in field-root DFS order (the same walk the audition taps register, capped at the engine's
MAX_PREVIEW_TAPS = 24), wrapped into columns by width. Each tile tracks the patch's live output by default: a scrolling block-RMS trace from the live song voice's per-node values (traces, reset on synth / module-generation change).⏵ auditionfires a one-shot through the preview bus and rings every reachable node, filling the tiles with, in priority:- a period-locked bipolar waveform — the most recent ~3 whole periods of the ring aligned to the last upward zero-crossing (
period_window; f₀ from the audition MIDI pitch), so periodic parts hold still; non-periodic parts (envelopes / noise / too-low pitch) fall back to the unaligned tail; - a log-warped spectrum strip (
taps::spectrum_db_f32, an alias of the Hann radix-2 FFTtaps::spectrum_db) with Hz axis labels; - a stats line — peak / RMS / crest / DC-offset (dBFS) from the ring.
- a period-locked bipolar waveform — the most recent ~3 whole periods of the ring aligned to the last upward zero-crossing (
- chain — the audio spine (
audio_spine: from theoutfield root, follow the first Audio-lane input to the source) as arrow-separated tiles left → right, each with its period-locked ring and a per-stage Δ dB (its RMS vs the previous stage's), so a level / character change localizes to its stage. - graph — the cable-graph (Patch Bay and Rack): module boxes + level-driven glowing cables, node click → source, plus a cable hover-probe — hovering a cable (point-to-polyline hit-test on the flattened bezier, scene-local pointer) highlights it and pops a tooltip with the source node's op / unit / live value and a mini scope of its audition ring (or an "audition to capture" hint).
Insight overlays (tier 2), on tiles with an audition ring:
- Dominance bars on a field-root
Sumtile — each summand's share of the mixed energy (Mixing tracks and buses §7), decoded from the preview frame's trailing dominance section (taps::decode_preview_taps, lenient so older frames still parse), labelled by each summand's op. - Harmonic ladder + THD (
taps::harmonics/thd_percent) under the spectrum on audio-rate periodic tiles — H1..H8 relative to the fundamental plus a THD readout. - Real filter curves on
Filter/Svf/Ladder/Diode/Korg35tiles — the actual RBJ magnitude response (benchscope::filter_band/band_response_db) over a log-f axis, cutoff / Q resolved from a liveHz/Qinput value else the folded-Constcaption; the 2-pole biquad forms are exact, the transistor-ladder (4-pole, doubled dB) / diode / Korg-35 are approximated as resonant lowpasses (a "≈" badge). Falls back to the canned glyph when params don't resolve.
Workflow tools (tier 3):
- A/B freeze reference — the header Freeze reference chip (enabled once a valid audition ring exists) captures the current audition as a
FrozenRef, keyed by node id (so re-auditioning in a different tap order still lines up), with a precomputed reference spectrum. While frozen, grid and chain tiles ghost the reference ring under the live wave, tick the reference spectrum over the current bars, and show a per-tile Δ dB (current RMS vs the reference). Cleared by a second click, and stale-guarded on synth switch / module-generation bump (ids get reassigned across a recompile). - WAV export (native only) — a
⇣ wavpill revealed on tile hover when an audition ring is present writes that tap's ring as a hand-rolled 44-byte- header 16-bit mono PCM WAV to~/Documents/Volta/exports/<synth>-<node>-<k>.wav(kbumps past any existing file), and surfaces the path as a bench toast (RackUi::notice, a field drained the wayjump_tois). The whole export path is#[cfg(not(target_arch = "wasm32"))]so ui-wasm keeps building.
Tests: ui/src/rack.rs (grid packing, trace accumulation / reset, period-lock window, audio-spine walk, filter resolve, dominance labels, freeze capture + stale-guard, Δ dB, WAV round-trip, cable point-to-polyline distance), ui/src/taps.rs (spectrum_db_f32, dominance decode, harmonics / THD), and ui/tests/behavior.rs (the inspect ▸ chip docks the rack, grid ⇄ graph toggle, tile click → source jump, chain spine order, Freeze reference disabled without a preview).
Notes & limits#
- Voice-internal only. The preview voice runs the per-voice graph; top-level track effects (reverb, pingpong, etc.) are outside it and are not shown.
- The preview bus is not level-capped. It renders into its own stereo bus and is summed into the master mix before the master section, so the whole chain a live note passes is
Σ voices × VOICE_GAIN (0.5) × monitor gain × MASTER_GAIN (0.7), then the master safety limiter andsoft_clip— the same ending every track has. Measured at 48 kHz on 0.4-gain sines: one voice at monitor 1.0 peaks 0.1397 against a nominal 0.1400, one at monitor 1.5 0.2095 (nominal 0.2100), two at 1.0 0.2794 (nominal 0.2800) — exact. Four ask for 0.56 and land at 0.4874, and at monitor 1.5 ask for 0.84 and land at 0.5233: that is the limiter, not the preview path. The same four sines as a TRACK chord measure 0.5407 against the preview's 0.5450. The only preview-specific bound is the monitor gain's own clamp, 0…1.5 (Command::PreviewMonitor). Pinned byengine/tests/preview_bus_level.rs. - A live note bypasses track inserts unless you route it.
one_shot,preview_triggerandnote_onplay on the preview bus, which by default joins the mix after every track has been through its fader, strip, sends and insert chain — so auditioning a synth does not sound like the track that plays it.EngineHandle::set_preview_route(Some("track"))(wasm:set_preview_route) moves the monitor sum onto that track's bus before it is mixed, and the audition then goes through the whole strip: measured througheqTrack [lowpass 120Hz], high-frequency energy fell from 0.01414 to 0.00032 of total.sole_track_for_synth(name)resolves "the track that plays this synth", and answersNonewhen two tracks do — with two there is no the insert chain. Three consequences to know: the track's fader and mute now gate the audition; a freeze print or offline capture on that track captures the audition too (both read the same pre-strip seam,AudioEngine::track_bus_seam); and a module load clears the route, because a track index does not survive the module it was resolved against. Under a staged-routing module (routing.is_some()) the plan has no per-track pre-strip seam, so the preview always lands on the master there andset_preview_routeis accepted but has no effect. - Mono analysis on stereo tiles. A stereo-originating node is tapped as a mono mix; true 2-channel panels are deferred (see Inspect a patch §10).
- Imported synths keep foreign spans. A node's
source_offsetindexes the module that declared it, so tile → source jumps are gated to the buffer that owns the patch (the signal-overlay containment rule). Inside a LOCAL patch every span is local: nodes inlined from an imported function carry the call's span (compiler/src/eval.rsinlined). - Tap latency. Panels paint from a polled frame rather than a per-frame analyser read, and a fresh ring zero-pads until it fills (~170 ms).
- One patch inspected at a time; closing the dock stops the preview pump and drops the tap interest.