Hecate
Hecate — Claude Context
Face-controlled chord synthesizer. Third app in the Prospero trilogy. Depends on Horatio + Enobarbus + Touchstone + Oberon (no Ariel — has its own DroneSynthEngine). iOS only (requires TrueDepth camera for ARKit face tracking).
Concept
"Your face plays chords. Open your mouth to add the 5th and bass. Raise your eyebrows for the 3rd. Puff cheeks for the 7th. Purse lips for lush 7th+9th extensions. Head position sweeps the filter, shapes vowel formants, and dials resonance. Smile to brighten the chord with octave shimmer. A chord synth controlled entirely by your face."
Data Flow
ARTrackingSession (.face) → ARKitFaceTracker → @Published [FaceLandmarkInputSource]
→ Combine bridge → HecateState.faceSources
→ FaceMapping.defaults → normalize + route to:
→ ChordTone volumes → DroneSynthEngine.setToneVolume()
→ Bass volume → DroneSynthEngine.setBassVolume()
→ SynthParameter values → DroneSynthEngine.setParameter()
Architecture
Audio Layer
SPSCRingBuffer<T> — Generic lock-free SPSC ring buffer (256 slots, atomic head/tail)
DroneSynthEngine — @Observable, AVAudioSourceNode polyphonic chord synth:
- 6 tone banks (root, 5th, 3rd, 7th, 9th, octave), each with 3 detuned oscillators (waveform from VoicePreset)
- Sub-bass oscillator (follows root, 1 octave down)
- Bass voice (sweet-spot octave selection)
- Always-on drone (key root + 5th sine, harmonium style) — pinned to KEY root, not chord root; injected post-effects for clean pedal tone
- Tremolo LFO: sine/triangle/square/saw, BPM-synced subdivisions; applied post-effects (after reverb) so wet signal pumps; one-pole smoothed to soften sharp edges on square/saw
- 8-step arpeggiator cycling through active chord tones
- Breathing LFO (0.15 Hz, slow organic movement)
- Signal chain: ToneBanks → masterGain → faceLostFade → SVF Filter (LP) → Formant Filters (F1+F2 parallel bandpass, preset dry/F1/F2 mix) → Reverb (FeedbackNetwork) → Tremolo → Drone (post-effects injection) → Spike Protection → BrickwallLimiter
- Smile → octave shimmer (tone bank 5 amplitude, replaces chorus which was inaudible on phone speakers)
- Effects: StateVariableFilter, FeedbackNetwork, BrickwallLimiter — separate L/R instances via
makeEffectPair helper
- Face-lost handling: smooth gain fade (~100ms down, ~450ms up) instead of hard gate; also used for launch fade-in
- Spike protection: pre-limiter gain duck on extreme transients (fast duck ~0.5ms, slow recovery ~100ms)
@ObservationIgnored + _rt prefix for all audio-thread state
- Display timer @30 Hz snapshots audio state →
viz* properties for visualization
- Uses
rtOnePole from Touchstone RTHelpers for all per-sample smoothing
- All sound-shaping constants driven by
VoicePreset (via _rtPreset): waveform, detune, filter range/resonance, formant ranges/Q/mix, reverb params, sub/bass/drone amplitudes, brightness cap, breath rate
applyPreset() sends new preset via command buffer; drainCommands reconfigures DSP effects
- Limiter ceiling: -6 dBFS default (exclusive), -10 dBFS (mixing + wired/speaker), -14 dBFS (mixing + Bluetooth) — adjusted dynamically via
setLimiterCeiling() command
- Audio session: defaults to exclusive
.playback; opt-in "Play along" mode uses .duckOthers and lowers limiter ceiling
AudioOutputRoute enum: .bluetooth, .wired, .speaker — detected from AVAudioSession.currentRoute.outputs
onRouteChanged callback: fires on routeChangeNotification, HecateState recalculates limiter ceiling
Face Mapping Layer
FaceMapping — Declarative source→target mapping with normalization:
absFromCenter: uses distance from 0.5 (both head-turn directions map equally)
inputMin/inputMax: defines the sensitive range
invert: flips the mapping
- Per-tone max-accumulation: multiple face mappings targeting the same chord tone use
max() before sending to engine (prevents last-write-wins conflicts)
SoundPack System
SoundPack — bundles VoicePreset (sound) + ShaderCatalog shader + optional VizConfig override + metadata
VoicePreset — all sound-shaping constants (Codable): waveform, detune, filter, formants, reverb, sub, bass, drone, brightness, breath rate
- Two built-in packs: Cathedral (triangle, warm, phone-speaker-friendly, Moss Grid shader with cathedral palette) and Coven (sawtooth, harmonic richness, Spiral Plasma shader with coven palette)
- Topology stays fixed across all packs — packs change character, not signal chain
- Constants that stay hardcoded (NOT in preset): slew rates, face-lost fade, spike protection, arp gate slew, ring buffer sizes
- Limiter ceiling values (-6/-10/-14 dBFS) are hardcoded in
HecateState.updateLimiterCeiling(), not in presets
HecateState.currentPack with didSet → droneEngine.applyPreset()
- Settings → Sound section for pack selection
State
HecateState — @Observable, owns ARKitFaceTracker + ARTrackingSession + DroneSynthEngine
- Combine bridges from
@Published → @Observable
- Face source processing with per-mapping normalization and routing
faceLost → smooth fade to silence (face-controlled tones AND drone)
- Loading indicator: black overlay shown while
isTracking && !isRunning, with 100ms delay before startAudio() to let spinner render
- Meters default collapsed (
metersExpanded = false)
mixWithOtherAudio persisted via UserDefaults; setter reconfigures audio session + limiter ceiling
updateLimiterCeiling() called on route changes (via droneEngine.onRouteChanged) and on toggle
Visual Modes
- Selfie (Camera): TrackingSessionPreviewView (live camera feed)
- Immerse (Viz): Full-screen Metal shader visualization via Oberon
VizCanvasView. Shader and palette driven by current SoundPack (shader: ShaderCatalog, optional vizConfig: VizConfig override). DroneSynthEngine viz properties mapped to VizInput (rms, bass, tone amplitudes → params).
Chord Voicing Mappings (Default)
| Expression |
Target |
| At rest |
Root drone |
| Open mouth (lower half) |
Add 5th |
| Mouth wide open (upper half) |
Bass note |
Raise eyebrows (browInnerUp) |
Add 3rd |
Puff cheeks (cheekPuff) |
Add 7th |
Purse lips (mouthPucker) |
Add 7th + 9th |
Sound Sculpting Mappings (Default)
| Source |
Parameter |
Range |
Mode |
| face.yaw (head turn) |
Filter cutoff |
0–0.25 |
absFromCenter |
| face.pitch (nod) |
Formant F2 (vowel shape) |
0–0.20 |
absFromCenter |
| face.roll (tilt) |
Filter resonance |
0–0.12 |
absFromCenter |
| face.smile |
Brightness (octave shimmer) |
0–1 |
linear |
| face.arkit.jawOpen |
Formant F1 (vowel openness) |
0–1 |
linear |
| face.smile |
Formant F2 (vowel shape) |
0–1 |
linear |
- Full-screen Metal rendering via Oberon
VizCanvasView
- Each SoundPack selects a
ShaderCatalog shader and optionally overrides VizConfig (feedback decay/warp, palette, parameter definitions)
HecateState.metalConfig returns the pack's vizConfig override or falls back to the shader's default config
HecateState.vizInput maps DroneSynthEngine viz properties → VizInput (rms, bass, tone amplitudes → params)
- 60 fps active, idle throttling to 10 fps when RMS < 0.005 (handled by Oberon)
- Cathedral pack: Moss Grid shader, cathedral palette
- Coven pack: Spiral Plasma shader, coven palette
DroneSynthEngine Visual Properties (@30 Hz)
| Property |
Source |
Used by |
vizToneAmplitudes[0–4] |
Tone bank current amplitudes × arp gates |
PlasmaField spawn density |
vizBassAmplitude |
Bass voice amplitude |
PlasmaField center bloom |
vizTremoloPhase |
BPM-synced LFO phase (0–1) |
PlasmaField brightness pump |
vizBreathPhase |
0.15 Hz organic LFO phase |
PlasmaField center drift |
vizChorusWidth |
Octave shimmer amplitude (smile) |
PlasmaField particle size/glow |
vizFilterSweep |
Normalized filter cutoff |
PlasmaField tangential stretch |
vizRmsEnergy |
Smoothed RMS (0.7×old + 0.3×new) |
PlasmaField brightness + spawn density |
waveformSamples |
256-sample ring buffer |
HUD mini waveform |
UI Layout
Top area (ModulatorMetersView, single header row):
HECATE title (CinzelDecorative-Black, kerning -1.3)
+ METERS toggle button (inline, right of title)
+ Visual mode icons (selfie/plasma, icon-only, right-aligned)
Collapsible meters (below header when expanded):
VOICING: JAW (ember), BROW (violet), CHEEK (cyan), PUCKER (green)
SCULPTING: TURN (violet), NOD (cyan), TILT (orange), SMILE (green, "Bright")
Center: Visual mode content (camera feed or plasma nebula)
Bottom (VStack, spacing: 0):
ChordBarView — key indicator + Nashville chord buttons (I–vii°)
HecateHUD — mini waveform, BPM stepper, ARP toggle,
sound mute, help(?), settings(⚙)
Design Language
- Core colors: charcoal (#1F1A24), violet (#A633D9), bone (#EBE6DA), ember (#F25933)
- Surface colors (dark → light): abyss (#0A090D), obsidian (#14111A), slate (#211C29), smoke (#2E2836)
- UI colors: darkPanel (obsidian @ 90%), border (white @ 12%), scrim (black @ 50%)
- Meter colors: meterVowel (#4DD9F2, warm cyan), meterReso (#F29933, warm orange), meterChorus (#66D173, soft green)
- All colors live in
DesignTokens.swift — no hardcoded colors elsewhere
- Display font: CinzelDecorative-Black (classical serif, used ONLY for "HECATE" title + "HECATE PRO")
- Body font: system rounded; Data font: system monospaced
- Vibe: Groovy, organic — distinct from Prospero's vintage brass/rosewood
- Strophalos motif: Gravitational attractor in the plasma, evoking Hecate's wheel
Tier Gating
TierConfig.debugOverride allows switching tiers in DEBUG builds (Settings → Debug section)
#if DEBUG defaults to .pro; release checks StoreManager.shared.isPro
- Free: C/F/Bb keys only, selfie mode + 15-sec plasma preview (soft toast), no arpeggiator (shows upgrade sheet)
- Pro: all 12 keys, unlimited plasma, arpeggiator
- StoreManager: single lifetime purchase (
hecate_one_time_purchase)
Files
| File |
Purpose |
HecateApp.swift |
@main entry |
ContentView.swift |
Root ZStack: visual mode, meters, HUD, mapping guide, loading indicator, plasma preview toast |
Audio/CommandRingBuffer.swift |
Re-exports SPSCRingBuffer<T> from Touchstone + SampleRingBuffer (waveform display) |
Audio/DroneSynthEngine.swift |
Chord synth engine: tone banks, filter, formants, reverb, tremolo, arp, drone, spike protection, viz snapshots, VoicePreset support |
Models/FaceMapping.swift |
Face→sound mapping definitions, NashvilleChord, MusicalKey, ChordTone, SynthParameter |
Models/HecateState.swift |
Central @Observable state, tracker + engine ownership, face processing, tier gating, SoundPack selection |
Models/VoicePreset.swift |
OscillatorWaveform enum + VoicePreset struct (all sound-shaping constants, Codable) |
Models/SoundPack.swift |
SoundPack struct (VoicePreset + ShaderCatalog + optional VizConfig + metadata) |
Models/VisualMode.swift |
Visual mode enum (selfie, viz/Immerse) |
Views/ChordBarView.swift |
Nashville number chord picker |
Views/HecateHUD.swift |
Bottom bar: BPM stepper, ARP toggle, mute, help, settings |
Views/MappingGuideView.swift |
Full-screen onboarding overlay: voicing + sculpting mappings |
Views/MetronomeView.swift |
Visual beat indicator |
Views/ModulatorMetersView.swift |
Header row (HECATE + METERS toggle + mode icons) + collapsible meters |
| Views/ProUpgradeView.swift | Pro upgrade sheet: feature comparison + StoreKit purchase |
| Views/SettingsView.swift | Settings: sound pack, key, bass, drone, reverb, tremolo, audio mixing, debug tier |
| DesignTokens.swift | HecateTokens: colors (core, surfaces, meters), CinzelDecorative-Black font |
| TierConfig.swift | Tier gating + debug override + hasArp |
| StoreManager.swift | StoreKit 2: lifetime purchase |
Rules
- No Ariel import — Hecate has its own DroneSynthEngine. Uses Oberon for visualization.
- All audio-thread state uses
@ObservationIgnored + _rt prefix.
- Never block the render callback — use CommandRingBuffer for main→audio communication.
- Effects use separate L/R DSPComponent instances (FolioDSP kernels have internal state).
- BrickwallLimiter is always on, never bypassable.
- PlasmaFieldView: spawn in onChange (not Canvas closure) to prevent gaps.
- Drone bypasses effects chain — injected post-reverb as clean sine pedal.
- Tremolo depth didSet must also send rate (
updateTremoloRate()) to ensure both values are set.
- Always create feature branches — never commit directly to main.