Capturing Spatial Audio in your iOS app
At a glance
| Item |
Summary |
| Purpose |
Capture first-order ambisonic audio, a stereo fallback, and spatial metadata into one movie, then play the recording. |
| App architecture |
SwiftUI composition with one observable capture owner and a separate playback view model. |
| Main patterns |
Observable model, AVFoundation delegate callbacks, queue confinement, and a small playback MVVM slice. |
| Project style |
One app target; files are named by primary view or model type with capture and playback kept separate. |
Project structure
SpatialAudioDemo/
├── SpatialAudioDemoApp.swift # Composition root
├── ContentView.swift # Recording flow and waveform UI
├── AudioRecorder.swift # Capture, writing, metadata, and meters
├── AudioPlayerView.swift # Playback controls
├── AudioPlayerViewModel.swift # AVPlayer state and observation
└── AudioVisualizerView.swift # Small level visualization
Structure observations
AudioRecorder is the capture boundary; it intentionally combines session setup, writer setup, delegate processing, and waveform calculation.
- Playback is a separate view/view-model pair, so capture resources do not leak into the player UI.
- There is no app-defined service protocol or repository layer.
Overall architecture
flowchart LR
App["SpatialAudioDemoApp"] --> UI["ContentView"]
UI --> Recorder["AudioRecorder"]
Recorder --> Capture["AVCaptureSession + audio outputs"]
Capture --> Writer["AVAssetWriter + metadata generator"]
UI --> PlayerView["AudioPlayerView"]
PlayerView --> PlayerModel["AudioPlayerViewModel"]
PlayerModel --> Player["AVPlayer"]
Reference code
SpatialAudioDemo/ContentView.swift:14 and SpatialAudioDemo/ContentView.swift:82 — view-owned recorder
@State private var audioRecorder = AudioRecorder()
.onAppear() {
audioRecorder.setupCaptureSession()
}
Interpretation
ContentView owns the feature lifecycle and sends user intents to AudioRecorder. The recorder is both observable app state and an AVFoundation adapter. A completed file URL is passed to the independently owned playback slice.
Ownership and state
classDiagram
ContentView *-- AudioRecorder : creates and observes
AudioRecorder *-- AVCaptureSession : creates
AudioRecorder *-- AVAssetWriter : creates per recording
AudioRecorder *-- AVCaptureAudioDataOutput : owns two
AudioPlayerView *-- AudioPlayerViewModel : creates
AudioPlayerViewModel *-- AVPlayer : creates on load
Ownership evidence
SpatialAudioDemo/AudioRecorder.swift:17 — private capture and writer state
private var assetWriter: AVAssetWriter?
private var session: AVCaptureSession
private var stereoAudioDataOutput: AVCaptureAudioDataOutput?
private var spatialAudioDataOutput: AVCaptureAudioDataOutput?
private var sessionQueue: DispatchQueue
| Owner |
Object or state |
Relationship |
Mutation authority |
ContentView |
Recorder, timer, sheet state |
Creates and retains as SwiftUI state |
ContentView for UI state; recorder for capture state |
AudioRecorder |
Session, outputs, writer inputs, metadata generator |
Creates and retains |
AudioRecorder, primarily on sessionQueue |
AudioRecorder |
isRecording, amplitudes, output URL |
Publishes observable state |
Recorder callbacks; some UI code clears amplitudes |
AudioPlayerView |
AudioPlayerViewModel |
Creates as @State |
View model methods and observers |
AudioPlayerViewModel |
AVPlayer, observer token, subscriptions |
Creates and cleans up |
View model only |
Class and protocol design
SpatialAudioDemo/AudioRecorder.swift:14 — framework protocol integration
@Observable
class AudioRecorder: NSObject,
AVCaptureAudioDataOutputSampleBufferDelegate,
@unchecked Sendable {
// ...
}
| Type |
Responsibility |
Depends on |
AudioRecorder |
Configure audio capture, build the writer graph, process sample buffers, and publish meter state |
AVFoundation, CoreMedia, dispatch queues |
AudioPlayerViewModel |
Load, observe, seek, play, and pause one audio file |
AVPlayer, Combine |
ContentView |
Permission messaging, recording intent, timer, waveform, and player presentation |
AudioRecorder |
LiveWaveformShape |
Convert amplitude history into a SwiftUI path |
SwiftUI |
The sample uses AVFoundation’s delegate protocol, but defines no application protocol for interchangeable recorders or players. @unchecked Sendable is a concurrency assertion, not an access level.
Access control
| Symbol |
Access |
Verified effect |
Likely rationale |
Session, outputs, writer, writer inputs, and sessionQueue |
private |
Only AudioRecorder can reconfigure the capture/write graph. |
Preserves graph and queue invariants. |
setupAssetWriterWithSpatialAndStereoAudioOutput |
private |
Only the first-buffer path can build writer inputs. |
Keeps a lifecycle helper out of the UI-facing API. |
Player observer token, cancellables, and addPeriodicTimeObserver |
private |
AudioPlayerView cannot manipulate observation lifecycle. |
Prevents duplicate observers and centralizes cleanup. |
ContentView recorder, timer, and sheet flags |
private |
State stays local to the view instance. |
Expresses view ownership. |
AudioRecorder.fileURL |
declared public, effectively capped by internal enclosing type |
No other module can construct the internal AudioRecorder type as a public API. |
The explicit public is wider than this sample needs; likely documentation emphasis rather than a library boundary. |
| Unmodified app types and commands |
internal by default |
Collaborators in the app module can call them; external modules cannot. |
Suitable for a single-target sample. |
Reference code
SpatialAudioDemo/AudioRecorder.swift:172 — hidden writer construction
private func setupAssetWriterWithSpatialAndStereoAudioOutput(
_ spatialAudioOutput: AVCaptureAudioDataOutput,
_ stereoAudioOutput: AVCaptureAudioDataOutput
) {
// creates AVAssetWriter and its spatial, stereo, and metadata inputs
}
Logic ownership and placement
| Logic |
Owning type or file |
Placement rationale |
| Permission display, timer, and modal playback |
ContentView |
Presentation and feature flow |
| Capture-session configuration |
AudioRecorder.setupCaptureSession() |
Same owner as all mutable session resources |
| Spatial/stereo track associations |
AudioRecorder.setupAssetWriterWithSpatialAndStereoAudioOutput |
Writer invariant hidden behind one setup operation |
| Sample routing and metadata finalization |
AudioRecorder.captureOutput |
AVFoundation delegate boundary |
| RMS calculation and amplitude publication |
AudioRecorder.computeValuesForWaveFormUI |
Derived directly from captured stereo buffers |
| Playback observation and commands |
AudioPlayerViewModel |
Keeps AVPlayer mechanics out of SwiftUI layout code |
Design patterns
| Pattern |
Source evidence |
Purpose or tradeoff |
| Observable model |
SpatialAudioDemo/AudioRecorder.swift:14, SpatialAudioDemo/AudioPlayerViewModel.swift:12 |
SwiftUI reads state directly; the recorder consequently has both domain and presentation-facing responsibilities. |
| Delegate |
SpatialAudioDemo/AudioRecorder.swift:15, SpatialAudioDemo/AudioRecorder.swift:274 |
Receives synchronized framework callbacks without polling. |
| Queue confinement |
SpatialAudioDemo/AudioRecorder.swift:44, SpatialAudioDemo/AudioRecorder.swift:148 |
Serializes session/output work away from the main thread. |
| MVVM for playback |
SpatialAudioDemo/AudioPlayerView.swift:19, SpatialAudioDemo/AudioPlayerViewModel.swift:12 |
Separates controls from player lifecycle and observation. |
Naming conventions
- Types use role suffixes:
View, ViewModel, and Recorder.
- Commands use verbs and expose intent:
setupCaptureSession, startRecording, stopRecording, seekToAndPlay.
- Framework-backed properties name the owned object explicitly, such as
assetWriterSpatialAudioInput.
- Files match their main type; the small
LiveWaveformShape stays beside its only consumer in ContentView.swift.
Architecture takeaways
- Give one object exclusive ownership of a mutable capture/writer graph and its queue.
- Keep playback resources separate from capture resources even in a small demonstration app.
- Use private framework state and a small command surface to protect ordering requirements.
- This is delegate-oriented framework integration, not a broadly protocol-oriented application design.
Source map
| Source file |
Relevant symbols |
SpatialAudioDemo/SpatialAudioDemoApp.swift |
SpatialAudioDemoApp |
SpatialAudioDemo/ContentView.swift |
ContentView, LiveWaveformShape |
SpatialAudioDemo/AudioRecorder.swift |
AudioRecorder, capture delegate methods |
SpatialAudioDemo/AudioPlayerView.swift |
AudioPlayerView |
SpatialAudioDemo/AudioPlayerViewModel.swift |
AudioPlayerViewModel |
SpatialAudioDemo/AudioVisualizerView.swift |
AudioVisualizerView |