Detailed architecture documentation for the Equaliser app. See AGENTS.md for coding guidelines.
| Directory | Purpose |
|---|---|
src/app/ |
App entry point, state coordinator, and persistence |
src/routing/ |
Audio routing orchestration, mode strategy, and driver naming |
src/dsp/ |
EQ signal processing (biquad filters, chains, configuration, coefficient staging) |
src/dsp/biquad/ |
Core biquad filter math and DSP |
src/dsp/chain/ |
EQ chain processing and state |
src/dsp/config/ |
EQ configuration (bands, channels, filter types, bandwidth conversion) |
src/pipeline/ |
Audio capture, rendering, and shared infrastructure |
src/pipeline/capture/ |
Audio capture from driver (shared memory, HAL input) |
src/pipeline/hal/ |
CoreAudio HAL I/O |
src/driver/ |
Driver lifecycle management |
src/driver/protocols/ |
Driver protocols (-ing suffix) |
src/device/ |
CoreAudio device enumeration and control |
src/device/enumeration/ |
Device discovery and listing |
src/device/enumeration/protocols/ |
Enumeration protocols |
src/device/volume/ |
Volume control, observation, and sync |
src/device/volume/protocols/ |
Volume protocol |
src/device/change/ |
Device change detection, policies, and coordination |
src/meters/ |
Level metering (state and calculations) |
src/presets/ |
Preset file management and import/export |
src/ui/ |
SwiftUI views and view models |
src/ui/views/ |
SwiftUI view components |
| File | Purpose |
|---|---|
src/app/EqualiserStore.swift |
App state coordinator (delegates to feature modules) |
src/app/AppStateSnapshot.swift |
App state persistence |
src/app/EqualiserApp.swift |
App entry point |
src/routing/AudioRoutingCoordinator.swift |
Routing orchestration (delegates to PipelineManager, EQCoefficientStager, RoutingMode) |
src/routing/RoutingMode.swift |
Strategy protocol for mode-specific device resolution |
src/routing/AutomaticRoutingMode.swift |
Automatic routing: driver + macOS default |
src/routing/ManualRoutingMode.swift |
Manual routing: user-selected devices |
src/routing/RoutingStatus.swift |
Routing state enum (idle, starting, active, error) |
src/routing/DriverNameManager.swift |
Driver naming with CoreAudio refresh workaround |
src/pipeline/PipelineManager.swift |
Render pipeline lifecycle (create, configure, start, stop) |
src/dsp/config/EQConfiguration.swift |
EQ band data (storage-free) |
src/dsp/config/FilterType.swift |
Filter types (parametric, shelves, etc.) |
src/dsp/config/CompareMode.swift |
EQ vs Flat comparison mode enum |
src/dsp/config/CompareModeTimer.swift |
Auto-revert timer for compare mode |
src/dsp/config/CompareModeTimerControlling.swift |
Protocol for compare mode timer |
src/dsp/config/EQLayerConstants.swift |
EQ layer count and indexing constants |
src/dsp/config/BandwidthConverter.swift |
Q factor ↔ bandwidth (octaves) conversion and display |
src/dsp/biquad/BiquadCoefficients.swift |
Biquad coefficient value type (Equatable, Sendable) |
src/dsp/biquad/BiquadMath.swift |
RBJ Cookbook coefficient calculation (pure functions) |
src/dsp/chain/ChannelEQState.swift |
Per-channel EQ state (layers, bands) |
src/dsp/config/ChannelMode.swift |
Linked vs stereo mode enum |
src/device/change/DeviceChangeDetector.swift |
Built-in device diff detection (pure) |
src/device/change/DeviceChangeEvent.swift |
Device change event types (pure) |
src/device/change/HeadphoneSwitchPolicy.swift |
Headphone switch decision logic (pure) |
src/device/change/OutputDeviceHistory.swift |
Output device history for reconnection |
src/device/change/DeviceChangeCoordinator.swift |
Device change event coordination and headphone detection |
src/device/OutputDeviceSelection.swift |
Pure output device selection logic (preserve/default/fallback) |
src/device/volume/DeviceVolumeService.swift |
CoreAudio volume control |
src/device/volume/VolumeManager.swift |
Volume sync between driver and output device |
src/device/SystemDefaultObserver.swift |
macOS default output device observer |
src/pipeline/AudioConstants.swift |
Centralized audio/EQ constants and validation |
src/pipeline/AudioMath.swift |
Pure audio math utilities (dB/linear conversion) |
src/pipeline/AudioRingBuffer.swift |
Lock-free SPSC ring buffer for audio callbacks |
src/pipeline/RenderPipeline.swift |
Dual HAL + EQ processing |
src/dsp/biquad/BiquadFilter.swift |
vDSP biquad wrapper with delay elements |
src/dsp/chain/EQChain.swift |
Per-channel filter chain with lock-free updates |
src/pipeline/capture/CaptureMode.swift |
Capture mode enum (halInput, sharedMemory) |
src/pipeline/capture/DriverCapture.swift |
Shared memory capture from driver |
src/pipeline/capture/SharedMemoryCapture.swift |
Lock-free ring buffer reader |
src/driver/protocols/DriverAccessing.swift |
Protocol for driver lifecycle access |
src/meters/MeterStore.swift |
Meter state management |
src/device/enumeration/DeviceEnumerationService.swift |
Device enumeration and change events |
src/device/enumeration/DeviceManager.swift |
Device model and selection logic |
The audio pipeline triggers macOS microphone permission due to the AudioUnit type used for output:
| Component | AudioUnit Type | TCC Impact |
|---|---|---|
HALIOManager |
kAudioUnitSubType_HALOutput |
Triggers TCC at instantiation |
DriverCapture |
None (shared memory) | No TCC impact |
The HALIOManager uses kAudioUnitSubType_HALOutput because it supports device selection for both input and output. However, this AudioUnit type is flagged by macOS as potentially accessing audio input, triggering TCC permission when instantiated — even when only used for output.
Current architecture:
RenderPipeline.configure()
→ HALIOManager(outputOnly)
→ AudioComponentInstanceNew(kAudioUnitSubType_HALOutput)
→ TCC permission check triggered
See docs/dev/TCC-Permission-Architecture.md for potential solutions under investigation.
| Directory | Purpose |
|---|---|
src/ui/views/main/ |
Main EQ window, menu bar, settings |
src/ui/views/eq/ |
EQ band controls |
src/ui/views/meters/ |
Level meters |
src/ui/views/presets/ |
Preset management |
src/ui/views/device/ |
Device selection |
src/ui/views/driver/ |
Driver installation |
src/ui/views/shared/ |
Reusable components |
| Directory | Purpose |
|---|---|
tests/app/ |
App state tests |
tests/dsp/biquad/ |
Biquad math and filter tests |
tests/dsp/chain/ |
EQ chain tests |
tests/dsp/config/ |
EQ configuration, filter type, and bandwidth conversion tests |
tests/pipeline/ |
Audio math, ring buffer, and render pipeline tests |
tests/pipeline/capture/ |
Capture mode policy tests |
tests/device/change/ |
Device change, history, and headphone switch policy tests |
tests/device/enumeration/ |
Device manager tests |
tests/meters/ |
Meter calculation and store tests |
tests/presets/ |
Preset import/export, codable, and migration tests |
tests/ui/ |
View model tests |
| Directory | Purpose |
|---|---|
driver/ |
Kernel driver source code |
driver/src/ |
Driver C source files |
resources/ |
App icon and assets |
docs/user/ |
User documentation |
docs/dev/ |
Developer documentation |
Each feature group is self-contained — it owns its domain types, services, protocols, and coordination logic. The app/ layer orchestrates feature modules together.
┌─────────────────────────────────────────────────────────────┐
│ App Layer (State + UX) │
│ - EqualiserStore: app state, delegates to features │
└─────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ routing/ │ │ dsp/ │ │ pipeline/ │
│ Mode strategy│ │ Biquad DSP │ │ HAL, capture │
│ Device naming│ │ EQ chains │ │ rendering │
├───────────────┤ ├───────────────┤ ├───────────────┤
│ driver/ │ │ meters/ │ │ device/ │
│ Lifecycle │ │ Level meters │ │ Enum, volume │
│ Properties │ │ │ │ change detect│
└───────────────┘ └───────────────┘ └───────────────┘
│
▼
┌───────────────────────────┐
│ ui/ │
│ Views + ViewModels │
└───────────────────────────┘
| Component | Role | Location | Persistence |
|---|---|---|---|
EqualiserStore |
App state coordinator | app/ |
No |
EQConfiguration |
Pure data model | dsp/config/ |
No |
MeterStore |
Isolated 30 FPS meter state | meters/ |
No |
PresetManager |
Preset file management | presets/ |
Yes (JSON) |
AppStatePersistence |
Saves on app quit | app/ |
Yes (UserDefaults) |
EqualiserStore (in app/) is a thin coordinator that delegates to feature modules:
EqualiserStore (app/)
├── AudioRoutingCoordinator (routing/) — routing orchestration
│ ├── PipelineManager (pipeline/) — render pipeline lifecycle
│ │ └── RenderPipeline (pipeline/)
│ ├── EQCoefficientStager (dsp/) — EQ coefficient calculation and staging
│ ├── RoutingMode (routing/) — strategy: AutomaticRoutingMode or ManualRoutingMode
│ ├── DeviceChangeCoordinator (device/change/) — device events, headphone detection
│ │ └── OutputDeviceHistory (device/change/)
│ ├── VolumeManager (device/volume/) — volume sync and drift detection
│ ├── SystemDefaultObserver (device/) — macOS default changes
│ └── DriverNameManager (routing/) — driver naming
├── CompareModeTimer (dsp/config/) — auto-revert
├── DeviceManager (device/enumeration/) — device enumeration, selection logic
│ └── DeviceEnumerationService (device/enumeration/)
├── EQConfiguration (dsp/config/) — band data
├── MeterStore (meters/) — meter updates
└── PresetManager (presets/) — preset filesKey coordinators and managers:
DeviceChangeCoordinator(device/change/): Subscribes toDeviceEnumerationService.$changeEvent, managesOutputDeviceHistory, emits callbacks for headphone detection and missing devicesAudioRoutingCoordinator(routing/): Routes device resolution toRoutingModestrategy, delegates pipeline lifecycle toPipelineManager, EQ staging toEQCoefficientStager, createsVolumeManagerwhen routing startsPipelineManager(pipeline/): Creates, configures, starts, and stopsRenderPipeline. Sets upVolumeManagerandEQCoefficientStagerwhen pipeline startsEQCoefficientStager(dsp/): Calculates biquad coefficients viaBiquadMathand stages them toRenderPipeline. OwnscurrentSampleRateand allupdateBand*methodsVolumeManager(device/volume/): Owns volume sync state (gain, muted, device IDs), syncs volume between driver and output device, performs drift detection
Service dependencies via protocols:
VolumeManagerdepends onVolumeControllingprotocolAudioRoutingCoordinatordepends onDeviceProviding,PermissionRequesting,VolumeControlling, andSampleRateObservingprotocolsDriverNameManagerdepends onDeviceProvidingprotocol
Services are accessed via protocols for testability:
// Device enumeration
protocol Enumerating: ObservableObject {
var inputDevices: [AudioDevice] { get }
var outputDevices: [AudioDevice] { get }
func device(forUID uid: String) -> AudioDevice?
}
// Device providing (composition of lookup, enumeration, and fallback)
protocol DeviceProviding: AnyObject {
var inputDevices: [AudioDevice] { get }
var outputDevices: [AudioDevice] { get }
func device(forUID uid: String) -> AudioDevice?
func deviceID(forUID uid: String) -> AudioDeviceID?
func enumerateInputDevices()
func refreshDevices()
func findBuiltInAudioDevice() -> AudioDevice?
func selectFallbackOutputDevice(excluding excludeUID: String?) -> AudioDevice?
}
// Volume control
protocol VolumeControlling: AnyObject {
func getDeviceVolumeScalar(deviceID: AudioDeviceID) -> Float?
func setDeviceVolumeScalar(deviceID: AudioDeviceID, volume: Float) -> Bool
}
// Permission requesting
protocol PermissionRequesting {
var isMicPermissionGranted: Bool { get }
func requestMicPermission() async -> Bool
}
// Routing mode (strategy pattern)
@MainActor
protocol RoutingMode {
var isManual: Bool { get }
var requiresDriverVisibility: Bool { get }
var requiresSampleRateSync: Bool { get }
var handlesSystemDefaultChanges: Bool { get }
var handlesBuiltInDeviceChanges: Bool { get }
var needsMicPermission: Bool { get }
func resolveDevices(...) -> DeviceResolution
}
// Driver lifecycle
protocol DriverLifecycleManaging: ObservableObject {
var status: DriverStatus { get }
var isReady: Bool { get }
func installDriver() async throws
}Naming pattern:
- Service protocols: Pure capability names with
-ingsuffix (Enumerating,VolumeControlling,SampleRateObserving,DeviceProviding,PermissionRequesting) - Strategy protocols: Domain name with no suffix (
RoutingMode) - Concrete types: Domain prefix + service suffix (
DeviceEnumerationService,DeviceVolumeService,DeviceSampleRateService)
View models hold unowned store references and derive presentation state:
@Observable final class RoutingViewModel {
private unowned let store: EqualiserStore
var statusColor: Color { /* derive from store.routingStatus */ }
}The app uses a custom biquad DSP engine instead of AVAudioUnitEQ. This provides low-latency, real-time safe EQ processing with up to 64 bands per channel.
┌─────────────────────────────────────────────────────────────────┐
│ Main Thread (UI / Configuration) │
│ │
│ EQConfiguration ──▶ EQCoefficientStager │
│ │ │
│ ▼ │
│ BiquadMath.calculateCoefficients() │
│ │ │
│ ▼ │
│ RenderPipeline.updateBandCoefficients() │
│ │ │
│ ▼ │
│ EQChain.stageBandUpdate() │
│ │ │
│ ManagedAtomic<Bool> (hasPendingUpdate) │
└──────────────────────────────┬──────────────────────────────────┘
│ .releasing
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Audio Thread (Real-Time) │
│ │
│ RenderCallbackContext.processEQ() │
│ │ │
│ ▼ │
│ EQChain.applyPendingUpdates() │
│ │ - hasPendingUpdate.exchange(false, .acquiringAndReleasing) │
│ │ - Only rebuild filters whose coefficients changed │
│ │ - resetState: false for slider drags │
│ ▼ │
│ EQChain.process(buffer:) │
│ │ - Iterate active bands │
│ │ - Skip bypassed bands │
│ ▼ │
│ BiquadFilter.process() ──▶ vDSP_biquad │
│ │
└─────────────────────────────────────────────────────────────────────┘
| Component | Responsibility | Thread |
|---|---|---|
BiquadMath |
Calculate biquad coefficients (RBJ Cookbook) | Main thread |
BiquadCoefficients |
Value type for b0/b1/b2/a1/a2 | Shared (Sendable) |
BiquadFilter |
vDSP wrapper, owns delay elements | Audio thread only |
EQChain |
Per-channel filter chain with lock-free updates | Shared via atomics |
EQChannelTarget |
Routes updates to left/right/both channels | Main thread |
- No allocation: All biquad setups and delay elements pre-allocated at init
- No locks: Coefficient updates via
ManagedAtomic<Bool>flag - Dirty-tracking: Only changed coefficients trigger vDSP setup rebuild
- State preservation:
resetState: falsepreserves filter memory on slider drags
[UI: Gain Slider Drag]
│
▼
BiquadMath.calculateCoefficients(type, freq, q, gain)
│ Returns Double-precision coefficients
▼
EQCoefficientStager.stageBandCoefficients(index, config)
│ Determines channel target (.left/.right/.both)
▼
RenderPipeline.updateBandCoefficients(channel, bandIndex, coefficients, bypass)
│
▼
EQChain.stageBandUpdate(index, coefficients, bypass)
│ Writes to pendingCoefficients[index]
│ Sets hasPendingUpdate.store(true, .releasing)
▼
[Audio Thread: Next Render Cycle]
│
▼
EQChain.applyPendingUpdates()
│ Compares pending[i] != active[i] (Equatable)
│ Only rebuilds changed filters
▼
EQChain.process(buffer:)
The app supports two capture modes for the Equaliser driver:
Uses HAL input stream. Triggers macOS microphone indicator.
┌──────────────┐ ┌──────────────┐ ┌───────────────┐
│ Input Device │ ──▶ │ Input HAL │ ──▶ │ Input Callback│
└──────────────┘ └──────────────┘ └───────────────┘
│
▼
┌──────────────┐
│ Ring Buffer │
└──────────────┘
│
▼
┌──────────────┐ ┌──────────────┐ ┌────────────────────┐
│ Output Device│ ◀── │ Output HAL │ ◀── │ Output Callback │
└──────────────┘ └──────────────┘ │ + Manual Rendering │
│ + EQ (64 bands) │
└────────────────────┘
Uses lock-free shared memory. No TCC permission required. Audio goes directly from shared memory to EQ processing — no intermediate ring buffer needed since both poll and render run on the same output thread.
┌──────────────┐ ┌────────────────────────────────────┐
│ Equaliser │ ──▶ │ Driver WriteMix │
│ Driver │ │ (audio stored in shared memory) │
└──────────────┘ └────────────────────────────────────┘
│
▼ (mmap, lock-free)
┌────────────────────┐
│ DriverCapture │
│ pollIntoBuffers() │
└────────────────────┘
│
▼ (direct, same thread)
┌──────────────┐ ┌──────────────┐ ┌────────────────────┐
│ Output Device│ ◀── │ Output HAL │ ◀── │ Output Callback │
└──────────────┘ └──────────────┘ │ + EQ (64 bands) │
└────────────────────┘
| Component | Purpose |
|---|---|
HALIOManager |
Single HAL unit (input or output mode) |
RenderPipeline |
Orchestrates HAL units + EQ |
AudioRingBuffer |
Lock-free SPSC buffer for clock drift (HAL input mode only) |
DriverCapture |
Polls driver shared memory for audio |
SharedMemoryCapture |
Lock-free shared memory ring buffer reader (mmap) |
Routing mode is implemented via the Strategy pattern (RoutingMode protocol). AudioRoutingCoordinator delegates device resolution to the current mode:
| Mode | Strategy | Input | Output | Use Case |
|---|---|---|---|---|
| Automatic | AutomaticRoutingMode |
Equaliser driver | macOS default | Recommended |
| Manual | ManualRoutingMode |
User-selected | User-selected | Advanced |
Mode-specific behaviour is defined by RoutingMode protocol properties: requiresDriverVisibility, requiresSampleRateSync, handlesSystemDefaultChanges, handlesBuiltInDeviceChanges, needsMicPermission.
| Capture Mode | Method | TCC Permission | Use Case |
|---|---|---|---|
| sharedMemory | Driver mmap (lock-free) | NOT required | Default, recommended |
| halInput | HAL input stream | Required | Legacy, fallback |
MeterConstants: silence threshold (-90 dB), range (-36...0), gamma (0.5), normalizedPosition()MeterMath: linearToDB, dbToLinear, calculatePeak
AudioConstants (in src/pipeline/) provides centralized constants for audio pipeline configuration:
maxFrameCount(16384): Maximum frames per render callback (supports up to 768kHz)ringBufferCapacity(32768): Ring buffer samples per channel (clock drift absorption)minEQFrequency/maxEQFrequency(1–22000 Hz): EQ frequency range (audible spectrum)minGain/maxGain(-36...+36 dB): UI slider rangeclampGain(),clampFrequency(),clampBandwidth(): Validation helpers
All preset imports and UI sliders use these constants for consistent validation.