Architecture
Prospero — Claude Context
Naming
- Prospero — app name, used in app header UI, App Store, bundle, MIDI source name
Project Overview
iOS app that turns hand tracking (Vision framework) into MIDI output and/or real-time audio via a built-in polyphonic synthesizer. Two input modes (camera hands, touch), two output modes (MIDI, audio). Retro rosewood/brass/cream UI with Art Deco typography (PoiretOne).
App Trilogy (Shakespeare / Tempest universe)
- Prospero — melodic instrument, hands → notes + CC, built-in audio + MIDI out, free/pro tiers
- Caliban — expressive MIDI controller, hands/body/face/relationships → CC + notes, MIDI out only
- Hecate — percussion instrument, body/face tracking → audio output
- All three share the four SPM packages (Horatio, Enobarbus, Touchstone, Ariel)
Data Flow
Camera frame → HandTracker (Vision) → [HandInputSource]
↓
PositionStabilizer (EMA + velocity clamp)
↓
MIDIRouter (scale quantization, CC mapping)
↙ ↘
MIDIController (CoreMIDI) AudioEngine (synthesis)
↓ ↓
External DAW / synth Speaker / headphones
TouchTracker → [TouchInputSource].
Architecture
Input Layer
HandTrackerdetects up to 4 hands viaVNDetectHumanHandPoseRequest, publishes[HandInputSource]on main threadTouchTracker/TouchInputViewhandles multi-touch (up to 4 slots: touch0–touch3)PositionStabilizersmooths positions (EMA α=0.6 for hands, velocity clamp at 0.15 screen units, stale-frame recovery after 5 rejections)- Input mode toggled in HUD: camera or tap
MIDI Layer
MIDIRouter.process()maps input positions to MIDI messages (notes, CC, pitch bend, velocity, depth)MIDIControllersends raw MIDI packets viaMIDIReceivedon a virtual source named "Prospero"MIDIClockReceiverlistens for external MIDI clock (24 ticks/quarter), publishes BPMMIDILearnSessioncaptures incoming CC/note for parameter assignment- Routes are rebuilt from scratch on every settings change via
updateRoutes()
Audio Layer
AudioEngine— lock-free polyphonic synth (44.1 kHz, up to 16 voices)- 4 waveforms: sine, sawtooth (PolyBLEP), triangle, square (PolyBLEP, 70% attenuation)
- Portamento (glide rate 0–1), ADSR envelope (configurable attack/release)
- LFO modulation: vibrato (±0.4 semitones) or tremolo (0–80% depth), switchable via CC 1
- Stereo/mono configurable
- All effects are InlineEffects processed in the render callback: Tone → Fuzz → Delay → Reverb
- Tone: ToneEffect (one-pole LPF, proven stable at all cutoffs; DSPComponent.makeTone/SVF abandoned — Chamberlin SVF unstable near Nyquist and per-sample bridge didn't propagate parameter changes)
- Fuzz: FuzzEffect (tanh saturation, per-sample drive smoothing via targetDrive + slew rate 0.002)
- Delay: FolioDSP DelayLine wrapped in MixedDSPComponent (DSPComponent.makeDelay, per-sample mix smoothing). DelayLine supports Linear and Hermite (cubic) interpolation modes (parameter index 2, default Hermite)
- Reverb: FolioDSP FeedbackNetwork (DSPComponent.makeReverb)
- Legacy ReverbEffect and DelayEffect remain in Touchstone but are unused
- Effect parameter routing: All effect parameters (tone, fuzz, delay mix, reverb mix) are routed through the lock-free command buffer to the audio thread. No main-thread
fxChain.setParameter()calls — eliminates data races and click artifacts.rtEffectsL/rtEffectsRarrays store audio-thread effect references, set instart(), cleared instop(). - Signal Graph + Recipe system (Touchstone library, not yet wired into Prospero's audio path):
SignalGraph= DAG ofGraphNodes connected byGraphEdges, with topological sort (Kahn's algorithm)Recipe= JSON-serializable definition: graph + metadata +ExposedParametermappingsRecipeEngine= mono RT-safe runtime: instantiatesDSPComponents, serial chain fast path, general graph fan-in, pre-allocated buffers, built-in Mixer/Crossfade node (2-input weighted blend by Balance parameter, handled at engine level without DSPComponent)RecipeLoader= JSON load/save,loadAll(from: directoryURL)for batch loading- Modulation routing:
ModulationRouteconnects modulator node output → target node parameter; block-rate (once per process call, not per sample)ModulationCurve: linear, exponential (x²), logarithmic (√x), sCurve (smoothstep)ModulationPolarity: bipolar (-1..1) or unipolar (0..1)- Multi-output modulators (ChaosAttractor, KnotModulator) read x/y/z via
FDSPSnapshotfield - Modulator-only nodes (no audio edges) are ticked for state but excluded from audio path
setExposedParameterupdates modulation route base values to prevent drift
- Starter recipes bundled as SPM resources in
Packages/Touchstone/Sources/Touchstone/Recipes/: miranda (shimmer reverb), falstaff (warm saturation), puck (chaos glitch), iago (creeping corruption, modulated), miranda-modulated (breathing shimmer, modulated), ariel (airy shimmer), caliban (raw distortion), hamlet (dual-path crossfade), lady_macbeth (dark ambience), lear (stormy chaos), mercutio (sharp flanger), oberon (spectral freeze), ophelia (gentle chorus), prospero (warm reverb), the_witches (eerie modulation), titiana (phaser shimmer), warm_bus (analog console channel strip) RecipeLoader.bundledRecipesURL— runtime accessor for the bundled recipes directory viaBundle.module- Recipe JSON parameter names must use exact FolioDSP bridge names (capitalized, with spaces, e.g. "Mod Rate", "Grain Size")
RecipeEngine.resetAllNodes()— resets all DSP components to initial state (used on voice steal/allocation)- SynthRecipe system (Touchstone library):
SynthRecipe= voice graph + optional effects graph + note bindings + polyphony configVoiceSynthEngine= polyphonic runtime: N voiceRecipeEngines + optional post-mix effects pairPolyphonyConfigincludes ADSR:attackTime,decayTime,sustainLevel,releaseTime(seconds/level)SynthRecipe.effectsGraph: SignalGraph?— optional post-mix effects, runs once on summed stereo outputSynthRecipe.effectsExposedParameters/effectsModulations— controls for the effects graph- Signal chain: voices → sum + polyGain → effectsGraph L/R → inputLevel → autoGain → limiter → output
- Voice reset:
resetAllNodes()called on fresh allocation and voice steal (not retrigger) - Bundled synth recipes in
Packages/Touchstone/Sources/Touchstone/SynthRecipes/: warm_pad, simple_lead, sub_bass SynthRecipeLoader— parallel to RecipeLoader for synth recipes- Output mode toggled in HUD: audio or MIDI
Tier Gating (TierConfig.swift)
#if DEBUGreturns.pro, release checksStoreManager.shared.isProStoreManagerhandles StoreKit 2: monthly + lifetime products, transaction listener, restore- Free: Pentatonic Major only, keys C/F/Bb only, 1 hand, sine waveform only, limited effects
- Pro: all 12 scales, all 12 keys, up to 4 hands, all waveforms, full effects + vibrato/tremolo
Critical Implementation Notes
MIDIRouter.activeNotesis keyed by sourceID, not channel. Multiple inputs can share a MIDI channel without killing each other's notes. On source loss → automatic noteOff.- The hand-tracking dots and fret overlay must share the same coordinate space. Both use a
GeometryReaderwith.ignoresSafeArea()to position in full-screen coordinates. Without this, the safe area inset creates a ~1-zone offset between the dots and the fret lines. positionToNoteusesindex = min(Int(floatIndex), numNotes - 1)— the same zone math asNoteFretOverlay.fretBoundaries. No position offset is needed when coordinate spaces match.- AudioEngine uses a lock-free ring buffer for main thread → audio thread commands. Never block the render callback.
- Finger counting compares fingertip vs PIP joint (not MCP) for more deliberate extension detection.
- See
docs/note-mapping-reference.mdfor all mapping formulas.
MIDI Channels
- All hands default to the same user-selected channel (1–16)
- Per-hand channel selection available in settings (4 channel pickers for 4 hands)
File Guide — App Target (Prospero/)
| File | Purpose |
|---|---|
ProsperoApp.swift |
@main entry point, --uitesting / --show-quotes launch args |
ContentView.swift |
Master orchestrator (636 lines): tracking, routing, audio, state, scene lifecycle |
HUDView.swift |
Heads-up display: mode toggles, settings/help buttons, velocity/mod dials |
SettingsView.swift |
Settings sheet: scale, key, note range, MIDI channels, visual toggles, pro upsell |
AudioControlsPanel.swift |
Audio mode controls: oscilloscope, waveform picker, effect knobs |
OnboardingView.swift |
3-page full-screen onboarding (welcome → how it works → get started) |
ProUpgradeView.swift |
Pro tier upgrade sheet with feature comparison + StoreKit purchase flow |
NoteFretOverlay.swift |
Metallic fret lines + smart note label circles (root, 5th, 3rd, gap-fill) |
OscilloscopeView.swift |
CRT phosphor-green waveform display with bloom + vignette |
DialView.swift |
Vintage analog gauge (needle sweep 210°–510°), InteractiveDialView for knobs |
CameraView.swift |
UIViewRepresentable wrapping AVCaptureVideoPreviewLayer |
TouchInputView.swift |
UIViewRepresentable multi-touch layer (4 slots → TouchInputSource) |
WoodGrainView.swift |
Procedural rosewood grain (seeded RNG, deterministic) |
ArcShape.swift |
Custom Shape for dial arcs |
BluetoothMIDIView.swift |
Wraps CABTMIDICentralViewController for BT MIDI pairing |
TierConfig.swift |
Single source of truth for tier state and free-tier limits |
StoreManager.swift |
StoreKit 2 integration: product loading, purchase, restore |
AppTypes.swift |
InputMode, OutputMode enums, midiNoteName() helper |
DesignTokens.swift |
ProsperoTokens design system (rosewood, brass, cream, phosphor green, PoiretOne font) |
ProsperoQuotes.swift |
Array of Shakespeare quotes from The Tempest, shown at launch |
File Guide — Workbench Target (Workbench/)
Internal developer tool for loading, auditioning, and tweaking Touchstone recipes and synth voices. Depends on Touchstone + Horatio (no Ariel, Enobarbus, or camera/Bluetooth entitlements). Stock SwiftUI, multi-platform (iOS + macOS) NavigationSplitView. Two modes: Effects (signal gen → recipe → limiter) and Synth (MIDI → VoiceSynthEngine → limiter).
| File | Purpose |
|---|---|
WorkbenchApp.swift |
@main entry point, macOS defaultSize(1000×700) |
ContentView.swift |
NavigationSplitView: sidebar recipe picker + detail area; switches between Inspector View and Graph View via state.viewMode; synth mode detail content |
Audio/WorkbenchAudioEngine.swift |
@Observable AVAudioEngine wrapper: dual render path (effects mode: signal gen → RecipeEngine L/R; synth mode: MIDI → VoiceSynthEngine → stereo output) → auto-gain → brickwall limiter → waveform ring buffer; CPU metering, live audio input, per-node profiling, recipe bypass, runtime sample rate negotiation, handleMIDIEvent() → SPSCRingBuffer<SynthCommand> |
Audio/SignalGenerator.swift |
Phase-accumulator signal generator (15 types: basic, tonal, transient, modulated, sample playback, live) |
Audio/MIDIInputListener.swift |
Persistent CoreMIDI listener: connects all sources, handles hot-plug, optional channel filter (omni default), forwards ParsedMIDIEvents to callback |
Models/NodeLayout.swift |
Node position for graph canvas layout |
Models/RecipeDocument.swift |
FileDocument wrapper for recipe JSON export/import |
Models/SignalType.swift |
Signal type enum with 15 cases (incl. live), grouped sections, usesFrequency/usesSamplePicker/requiresLiveInput properties |
Models/SynthesizedSample.swift |
SynthesizedSample enum: chord (C major), drums (kick-snare), melody (C scale) — generates loopable buffers |
Models/WorkbenchState.swift |
@Observable app state: WorkbenchMode (.effects/.synth), recipe loading, selection, synth recipe management, MIDI listener lifecycle, graph editing, A/B comparison, ViewMode enum (.graph/.inspector) |
Views/TransportView.swift |
Play/stop, mode picker (Effects/Synth), signal menu picker (effects) or MIDI status + voice count (synth), view mode picker, input/limiter controls, bypass, auto-gain, CPU meter, live input, profiling, file importer |
Views/RecipePickerView.swift |
Sidebar recipe list (effects mode) with selection highlight, New Recipe / Clear Selection buttons, Edit context menu |
Views/SynthRecipePickerView.swift |
Sidebar synth recipe list (synth mode) with node/binding counts, New/Clear/Edit actions, context menu |
Views/RecipeInfoView.swift |
Recipe metadata: name, category badge, description, node/edge/mod/control counts |
Views/ParameterPanelView.swift |
Exposed parameter sliders |
Views/WaveformView.swift |
Canvas waveform display (green on black, 30fps polling) |
Views/GraphEditorView.swift |
Main graph editor: HSplitView with ZStack canvas (left) + SelectedNodePanel (right, 300px, shows on node selection); toolbar (add/modulate/info/A/B/save/export) |
Views/EdgeCanvasView.swift |
Canvas drawing audio edges (solid Bezier) and modulation routes (dashed orange) |
Views/NodeBoxView.swift |
Individual graph node: role-colored rounded rect, input/output ports, context menu delete |
Views/ComponentBrowserView.swift |
Searchable component picker grouped by ComponentRole (69 components) |
Views/AddModulationSheet.swift |
Modulation route creation: source/target/depth/curve/polarity pickers |
Views/RecipeMetadataEditor.swift |
Recipe metadata editor: id, name, category, description fields |
Views/SnapshotView.swift |
Live FDSPSnapshot display polling at 15 Hz (float/bool/vec3 field rendering) |
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() |
Views/PerformanceMeterView.swift |
CPU load bar with peak marker and glitch count indicator |
Views/NodeInspectorComponents.swift |
Shared components used by both Inspector View and Graph View: NodeVisualization (snapshot polling at 15 Hz + visualization dispatch), NodeParameterSlider, ConnectionsSection |
Views/NodeInspectorView.swift |
Per-node DisclosureGroup: uses shared components from NodeInspectorComponents, bypass toggle, per-node timing display |
Views/Visualizations/SnapshotReader.swift |
SnapshotField model + SnapshotReader byte-level field extraction from FDSPSnapshot, extractFloatArray helper |
Views/Visualizations/TransferCurveView.swift |
16-point transfer curve + signal dot for Overdrive, Tape Saturation, Tube Warmth, Transformer Color |
Views/Visualizations/GainReductionView.swift |
Vertical GR meter + I/O levels for Compressor, BrickwallLimiter, Sag |
Views/Visualizations/FrequencyResponseView.swift |
Biquad/SVF frequency response curve (Audio EQ Cookbook math, 128 log-spaced points) |
Views/Visualizations/StaircaseView.swift |
Smooth sine + purple quantized staircase overlay for BitCrush |
Views/Visualizations/WavetableMorphView.swift |
32-point waveform preview for Oscillator |
Views/Visualizations/AnalysisReadoutView.swift |
PitchReadoutView (note name, cents, confidence, history trace) + OnsetReadoutView (flash, flux meter, band indicators) |
Views/Visualizations/ComponentVisualizationView.swift |
Dispatcher: maps component name → specialized visualization, falls back to raw field dump |
Workbench Signal Types
| Section | Types | Description |
|---|---|---|
| Basic | sine, sawtooth, square, noise, silence | Standard test tones (use frequency slider) |
| Tonal | gatedBurst, pwm, fm | Gated 200ms/500ms burst, PWM sweep 10–90% duty, 2-op FM with mod index sweep |
| Transient | impulse, pluck, arpeggio | Single-sample click, Karplus-Strong string, C major arpeggio at 3 notes/sec |
| Modulated | sweep, sampleAndHold | Log chirp 80–8000 Hz / 3s, random pitch jumps at 4 Hz |
| Playback | sample | Synthesized samples (chord/drums/melody) or user audio file import |
| Live | live | Microphone input via AVAudioSinkNode, with feedback protection |
Workbench Signal Chain (Effects Mode)
SignalGenerator / Live Input (AVAudioSinkNode flat buffer) → Input Level (0 dBFS default)
↓
RecipeEngine L/R (Touchstone graph processing)
↓
Auto-Gain Normalization (off by default; RMS-based, -18 dBFS target, ±12 dB range)
↓
BrickwallLimiter L/R (always on, -6 dBFS default ceiling)
↓
Feedback Mute (live input + no headphones → silence)
↓
Waveform ring buffer → AVAudioSourceNode output
Workbench Signal Chain (Synth Mode)
MIDI Keyboard → MIDIInputListener (CoreMIDI, persistent)
→ SPSCRingBuffer<SynthCommand> (lock-free, main→audio)
→ VoiceSynthEngine (audio thread)
├─ Voice 0: RecipeEngine L/R (clone of voice graph)
├─ Voice 1: RecipeEngine L/R
└─ Voice N: RecipeEngine L/R
Each voice: frequency + amplitude envelope + stereo pan
→ Sum voices → Input Level
→ Auto-Gain → BrickwallLimiter → Waveform → Output
DSPComponent("BrickwallLimiter") instances (FolioDSP kernels have internal state). Never bypassable. GR metering via FDSPSnapshot with cached field offset, throttled reads (~23ms).
- CPU metering: mach_absolute_time() timing around render callback. Smoothed load (0–1+), peak hold (~3 sec decay), glitch count (callbacks > 100% deadline). Transferred to UI via updateWaveformSamples().
- Per-node profiling: RecipeEngine.profilingEnabled flag adds per-node mach_absolute_time() timing. Separate profiled processing paths to avoid overhead when disabled. Toggle in TransportView, per-node timing in NodeInspectorView.
- Live audio input: AVAudioSinkNode connected to inputNode writes to a flat buffer read by AVAudioSourceNode in the same IO render cycle — no ring buffer needed. Uses setPreferredSampleRate(44100) and setPreferredIOBufferDuration(0.005) for ~5ms IO buffers. Runtime sample rate (_runtimeSampleRate) negotiated from AVAudioSession (iOS) or engine output format (macOS), used everywhere instead of hardcoded 44100. AVAudioSession switches to .playAndRecord on iOS. Feedback protection: iOS mutes output without headphones; macOS shows one-time warning. Route change notifications update protection state. Note: installTap was abandoned because iOS batches tap delivery into OS-determined chunks (~100ms) regardless of the bufferSize hint, making low-latency impossible.
- @ObservationIgnored: All audio-thread state uses this instead of nonisolated(unsafe) to avoid Swift 6.2 warnings while opting out of observation tracking. Naming convention: prefix with _rt for audio-thread variables to avoid collisions with @Observable backing stores.
SPM Packages
| Package | Module | Depends on | Purpose |
|---|---|---|---|
Packages/Horatio |
Horatio |
— | InputSource protocol, all concrete source types, MIDI types, Scale enum (12 scales), MIDIPacketParser, ParsedMIDIEvent, midiToHz() |
Packages/Enobarbus |
Enobarbus |
Horatio | HandTracker, BodyTracker, FaceTracker, CameraManager, PositionStabilizer, RelationshipProcessor, DepthTracker, BodyCalibration |
Packages/Touchstone |
Touchstone |
Horatio, FolioDSPBridge | AudioEffect protocol, FXChain, DSPComponent, MixedDSPComponent, BridgeAdapter, ComponentRegistry, ComponentDescriptor, ParameterDescriptor, ComponentTypes, SignalGraph, GraphNode, GraphEdge, Recipe, ExposedParameter, RecipeLoader, RecipeEngine, RecipeError, ModulationRoute, ModulationCurve, ModulationPolarity, RTHelpers (rtOnePole, rtAsymmetricSlew, rtSpikeGain), SPSCRingBuffer, SynthRecipe, NoteBinding, PolyphonyConfig, SynthCommand, VoiceSynthEngine, SynthRecipeLoader; legacy: ReverbEffect, DelayEffect, ToneEffect, FuzzEffect |
Packages/Ariel |
Ariel |
Horatio, Touchstone | AudioEngine, MIDIRouter, MIDIController, MIDIClockReceiver, MIDILearnSession (re-exports MIDIPacketParser from Horatio) |
All packages target iOS 16+ / macOS 13+ (Touchstone and Ariel require macOS 13+ due to FolioDSPBridge dependency). All have DocC catalogs (Sources/<Module>/<Module>.docc/) with landing pages and /// doc comments on all public APIs. Build docs with ⌃⇧⌘D in Xcode or xcodebuild docbuild -scheme <Module> -destination generic/platform=iOS.
Tests
Unit Tests (per-package)
| Package | Test files |
|---|---|
| Horatio | MIDIRouteTests, InputSourceTests, ScaleTests |
| Enobarbus | HandTrackerTests, BodyTrackerTests, FaceTrackerTests, DepthTrackerTests, RelationshipProcessorTests, PositionStabilizerTests |
| Touchstone | FXChainTests, ToneEffectTests, FuzzEffectTests, DSPComponentTests, SignalGraphTests, RecipeTests, RecipeEngineTests, RecipeLoaderTests, ModulationRouteTests |
| Ariel | MIDIRouterTests, MIDIClockReceiverTests, AudioEngineTests, MIDILearnSessionTests, MIDIPacketParserTests |
UI Tests (ProsperoUITests)
~10 XCUITests covering HUD interactions, settings, onboarding, and mode switching. Tests use --uitesting launch arg to skip onboarding/quotes.
When to run: Before merging UI-facing branches — especially changes to HUDView, SettingsView, OnboardingView, or ContentView layout/presentation. Don't run on every change; a full suite takes ~4 minutes in the simulator.
# Full UI test suite (~4 min)
xcodebuild test -scheme Prospero -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:ProsperoUITests
# Single test (fastest feedback)
xcodebuild test -scheme Prospero -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:ProsperoUITests/ProsperoUITests/testHUDSettingsButtonOpensSheet
Accessibility identifiers: All interactive elements tested have identifiers (hud.settings, settings.done, etc.). When adding new interactive UI, add an .accessibilityIdentifier() for testability.
Design Language
- Colors: rosewood (#2E1F14), brass (#B8945C), cream (#F2E1B8), nickel-silver frets (#BFB8A6), phosphor green (oscilloscope), wood grain variants
- Font: PoiretOne-Regular (Art Deco display)
- Visual motifs: CRT oscilloscope with bloom/vignette, analog gauge dials, procedural wood grain, metallic wire fret lines
- Status indicators: connected (solid/glow green), disconnected (solid/glow red)
Docs
| File | Content |
|---|---|
docs/note-mapping-reference.md |
Note mapping formulas |
docs/plan-docc-documentation.md |
DocC documentation plan |
docs/plan-midi-learn.md |
MIDI learn feature design |
docs/plan-stereo-mono-output.md |
Stereo/mono audio output design |
docs/plan-vision-body-face-lidar.md |
Body/face/depth tracking architecture (now largely in Caliban) |
docs/analog-character.md |
DelayLine Hermite interpolation + ConsoleBus component design |