Workbench
Workbench — Claude Context
Internal developer tool for loading, auditioning, and tweaking Touchstone recipes and synth voices. Not shipped to users. Depends on Horatio + Touchstone only (no Ariel, Enobarbus, or camera/Bluetooth entitlements). Stock SwiftUI, multi-platform (iOS + macOS).
Two modes:
- Effects mode: Test effects chains (recipes) with signal generators or live input
- Synth mode: Design voice graphs, play via MIDI keyboard, hear polyphonic output in real-time
Purpose
Build and test DSP effect chains (recipes) and synth voice topologies with real-time audio, parameter tweaking, visualization, and A/B comparison — without needing the full Prospero app or a camera.
Architecture
State
| File |
Role |
Models/WorkbenchState.swift |
@Observable central state: WorkbenchMode (.effects/.synth), recipe loading (effects + synth), synth recipe management (load/save/delete/revert), MIDI listener lifecycle, dual graph editing (editingRecipe + editingSynthRecipe), synth node operations (add/delete/edge), note binding management (add/remove/query), A/B comparison, parameter exposure, modulation management, user recipe persistence (separate directories) |
Models/SignalType.swift |
15 signal types in 6 sections: basic (sine/saw/square/noise/silence), tonal (gatedBurst/pwm/fm), transient (impulse/pluck/arpeggio), modulated (sweep/sampleAndHold), playback (sample), live (microphone) |
Models/SynthesizedSample.swift |
Generates loopable buffers: chord (C major), drums (kick-snare), melody (C scale) |
Models/NodeLayout.swift |
Node position for graph canvas |
Models/RecipeDocument.swift |
FileDocument for recipe JSON export/import |
Audio
| File |
Role |
Audio/WorkbenchAudioEngine.swift |
@Observable AVAudioEngine wrapper. Dual render path: effects mode (SignalGenerator → RecipeEngine L/R) and synth mode (MIDI → VoiceSynthEngine → stereo). Shared tail: auto-gain → brickwall limiter → waveform ring buffer → output. CPU metering, per-node profiling, live input (AVAudioSinkNode), recipe bypass, handleMIDIEvent() → SPSCRingBuffer |
Audio/SignalGenerator.swift |
Phase-accumulator signal generation for all 15 types |
Audio/MIDIInputListener.swift |
Persistent CoreMIDI listener: connects all sources, handles hot-plug, optional channel filter (omni default), forwards ParsedMIDIEvents to callback |
Signal Chain (Effects Mode)
SignalGenerator / Live Input (AVAudioSinkNode)
→ Input Level (0 dBFS default)
→ RecipeEngine L/R (Touchstone graph processing)
→ Auto-Gain (off by default; RMS-based, -18 dBFS target, ±12 dB range)
→ BrickwallLimiter L/R (always on, -6 dBFS ceiling)
→ Feedback Mute (live + no headphones → silence)
→ Waveform ring buffer → AVAudioSourceNode
Signal Chain (Synth Mode)
MIDI Keyboard → MIDIInputListener (CoreMIDI)
→ SPSCRingBuffer<SynthCommand> (lock-free)
→ VoiceSynthEngine:
Voice 0..N (RecipeEngine each) → ADSR envelope → stereo pan
→ Sum + polyGain
→ Post-mix effectsGraph L/R (optional, runs once on summed output)
→ Input Level → Auto-Gain → BrickwallLimiter
→ Waveform ring buffer → AVAudioSourceNode
Views (33 files)
| File |
Role |
ContentView.swift |
NavigationSplitView: mode-aware sidebar (RecipePickerView or SynthRecipePickerView) + detail (Inspector/Graph view modes) + VStack transport bar (keyboard + waveform + transport); synth mode detail content (recipe info, note bindings, polyphony controls) |
Views/TransportView.swift |
Play/stop, mode picker (Effects/Synth), signal picker (effects) or MIDI status + voice count (synth), view mode picker, input/limiter controls, synth controls (polyphony stepper, glide slider, stereo spread, ADSR envelope sliders), bypass, auto-gain, CPU meter, live input, profiling |
Views/RecipePickerView.swift |
Sidebar recipe list (effects mode), New/Clear/Edit actions |
Views/SynthRecipePickerView.swift |
Sidebar synth recipe list (synth mode), New/Clear/Edit actions, node/binding counts |
Views/RecipeInfoView.swift |
Recipe metadata display |
Views/ParameterPanelView.swift |
Exposed parameter sliders |
Views/WaveformView.swift |
Canvas waveform (green/black, 30fps) |
Views/GraphEditorView.swift |
HSplitView: ZStack canvas + SelectedNodePanel (300px). Toolbar: add/modulate/info/A-B/save/export |
Views/EdgeCanvasView.swift |
Bezier audio edges (solid) + modulation routes (dashed orange) |
Views/NodeBoxView.swift |
Role-colored node with input/output ports, context menu |
Views/ComponentBrowserView.swift |
Searchable component picker grouped by ComponentRole (69 components) |
Views/AddModulationSheet.swift |
Modulation route creation (source/target/depth/curve/polarity) |
Views/RecipeMetadataEditor.swift |
Recipe metadata fields |
Views/NodeInspectorView.swift |
Per-node DisclosureGroup: parameters, bypass, timing |
Views/NodeInspectorComponents.swift |
Shared: NodeVisualization (snapshot polling 15 Hz), NodeParameterSlider, ConnectionsSection |
Views/SnapshotView.swift |
Live FDSPSnapshot display (15 Hz polling) |
Views/PerformanceMeterView.swift |
CPU bar + peak marker + glitch count |
Views/ValidationBannerView.swift |
Recipe validation status badges |
Views/OnScreenKeyboardView.swift |
2-octave on-screen piano keyboard (synth mode only): octave shift, velocity control, drag-to-play glissando, sends noteOn/noteOff via sendSynthCommand() |
Visualizations (Views/Visualizations/)
| File |
Components Visualized |
TransferCurveView.swift |
Overdrive, Tape Saturation, Tube Warmth, Transformer Color |
GainReductionView.swift |
Compressor, BrickwallLimiter, Sag |
FrequencyResponseView.swift |
Biquad/SVF filters (Audio EQ Cookbook math) |
StaircaseView.swift |
BitCrush (sine + quantized staircase) |
WavetableMorphView.swift |
Oscillator waveform preview |
AnalysisReadoutView.swift |
PitchReadout + OnsetReadout |
ComponentVisualizationView.swift |
Dispatcher: component name → specialized viz, fallback to raw fields |
SnapshotReader.swift |
SnapshotField model + byte-level FDSPSnapshot extraction |
Key Implementation Details
- Dual ContentView: Root
ContentView.swift handles navigation; Views/ContentView.swift is the detail view (the root one is at the Workbench top level).
- Runtime sample rate:
_runtimeSampleRate negotiated from AVAudioSession (iOS) or engine output format (macOS). Used everywhere instead of hardcoded 44100.
- Live input:
AVAudioSinkNode → flat buffer → read by AVAudioSourceNode in same IO cycle. installTap was abandoned (iOS batches ~100ms regardless of hint).
@ObservationIgnored: All audio-thread state. Prefix _rt for audio-thread variables.
- BrickwallLimiter: Separate L/R instances (FolioDSP kernels have internal state). Never bypassable.
- Per-node profiling:
RecipeEngine.profilingEnabled adds mach_absolute_time() per node. Separate code paths to avoid overhead when off.
- User recipes: Saved to app support directory. Effects recipes in
com.folioaudio.workbench/Recipes/, synth recipes in com.folioaudio.workbench/SynthRecipes/. User versions override bundled recipes with the same ID.
- Synth mode:
VoiceSynthEngine pre-allocates all voice RecipeEngine pairs at init. Optional post-mix effects graph (effectsGraph) runs once on summed stereo output. ADSR envelope from PolyphonyConfig (attackTime, decayTime, sustainLevel, releaseTime). Voice reset (resetAllNodes()) on fresh allocation/steal to clear old DSP state. MIDI events flow through MIDIInputListener → handleMIDIEvent() → SPSCRingBuffer<SynthCommand> → audio thread. Render callback branches on _rtVoiceSynthEngine != nil; when synthModeEnabled is true but engine is nil (e.g. all nodes deleted), outputs silence instead of falling through to effects-mode signal generator.
- On-screen keyboard: 2-octave piano keyboard visible in synth mode when a recipe is loaded. Sends
SynthCommand.noteOn/.noteOff directly via sendSynthCommand() — bypasses MIDIInputListener, same lock-free ring buffer path as hardware MIDI. Supports drag-to-play glissando, octave shift (0–7), velocity control.
- Synth recipe editing: Separate
editingSynthRecipe state with parallel graph operations (addSynthNode, addSynthEdge, deleteSynthNode). Graph editor auto-detects mode via workbenchMode. Supports both voice graph and effects graph editing — "Add Node" in synth mode prompts for target graph, effects nodes shown with orange border below a "POST-MIX EFFECTS" divider. NoteBinding editor in selected-node panel (synth mode only). MIDI binding badge on nodes with note bindings.
- SynthRecipeLoader: Parallel to
RecipeLoader — loads/saves SynthRecipe JSON. Bundled starter recipes in Packages/Touchstone/Sources/Touchstone/SynthRecipes/ (warm_pad, simple_lead, sub_bass).
- Mode-aware sidebar:
RecipePickerView for effects, SynthRecipePickerView for synth. Switched by workbenchMode.
- MIDIInputListener: Persistent (unlike
MIDILearnSession). Started/stopped with synth mode. Handles device hot-plug via MIDIClientCreateWithBlock notifications.
Rules
- No Ariel or Enobarbus imports — Workbench is audio/recipe only (Horatio + Touchstone).
- Recipe JSON parameter names must use exact FolioDSP bridge names (capitalized, with spaces, e.g. "Mod Rate", "Grain Size").
- Always test with the brickwall limiter on — never bypass it, even in development.
- When adding new visualizations, add the mapping in
ComponentVisualizationView.swift's dispatcher.