A Swift package that provides a unified, dependency-injected audio recording client for macOS and iOS. Supports real-time audio streaming, Voice Activity Detection (VAD), and file recording — all through a single testable interface built on swift-dependencies.
| Platform | Minimum Version |
|---|---|
| macOS | 13.0 |
| iOS | 16.0 |
Swift 5.7+, Xcode 15+
Add the package via Swift Package Manager:
// Package.swift
dependencies: [
.package(url: "https://github.com/atacan/AudioRecorder", from: "x.y.z"),
],
targets: [
.target(
name: "YourTarget",
dependencies: [
.product(name: "AudioRecorderClient", package: "AudioRecorder"),
]
),
]Or add it in Xcode via File → Add Package Dependencies.
The client is grouped into three namespaces:
| Namespace | Purpose |
|---|---|
.permissions |
Request microphone access |
.live |
Real-time audio streaming (PCM16, Float32, VAD chunks) |
.file |
Record audio directly to a file |
SystemAudio is a separate utility (not part of the client) for reading and setting the system mute state.
Only one session can be active at a time. Starting a second session while one is running throws .sessionAlreadyActive.
Use lifecycle when a caller must not start another recording until this package has
released its microphone, audio-session, and file/stream resources. Lifecycle starts
return a RecordingSession capability that must be supplied to control operations.
let recording = try await audioRecorder.lifecycle.startStreamingFile(.init(url: fileURL))
switch try await audioRecorder.lifecycle.stop(recording.session) {
case .released(.file(let result)):
// It is now safe to start another recording.
print(result.url)
case .releasedWithError(let error):
// Finalization failed, but recorder resources are released.
print(error)
case .releaseUnknown(let error):
// Do not start another recording. Recover explicitly.
print(error)
let release = try await audioRecorder.lifecycle.teardown(recording.session)
guard case .released = release else { return }
}lifecycle.stop is idempotent for a session: rapid duplicate calls receive the same
terminal outcome and do not stop a newer session. teardown preserves partial files.
The existing .live and .file APIs remain available for compatibility, but their
throwing stop methods cannot express whether an error happened before or after
release; use lifecycle for exclusive ownership.
Inject the client via the @Dependency macro:
import AudioRecorderClient
import Dependencies
@Observable
final class RecorderModel {
@ObservationIgnored
@Dependency(\.audioRecorder) var audioRecorder
}let granted = await audioRecorder.permissions.requestRecordPermission()Stream raw 16-bit PCM audio, suitable for sending to speech-to-text APIs:
let stream = try await audioRecorder.live.start(
.init(
mode: .pcm16,
sampleRate: 16_000,
channelCount: 1,
bufferDuration: 0.1
)
)
for try await payload in stream {
guard case let .pcm16(data) = payload else { continue }
// send `data` over a WebSocket, write to disk, etc.
}let stream = try await audioRecorder.live.start(.init(mode: .float32))
for try await payload in stream {
guard case let .float32(samples) = payload else { continue }
// process Float array
}VAD mode buffers audio and yields a chunk only when speech ends. Silence is automatically trimmed.
let stream = try await audioRecorder.live.start(
.init(
mode: .vad(
.init(
silenceThreshold: 0.022,
silenceTimeThreshold: 30,
stopBehavior: .flushBufferedSpeech
)
)
)
)
for try await payload in stream {
guard case let .vadChunk(samples) = payload else { continue }
print("Speech chunk: \(samples.count) samples")
// pass to SFSpeechRecognizer, Whisper, Deepgram, etc.
}All three live controls are available and throw on invalid state:
try await audioRecorder.live.pause()
try await audioRecorder.live.resume()
try await audioRecorder.live.stop()let fileURL = URL(fileURLWithPath: "/tmp/recording.wav")
try await audioRecorder.file.start(
.init(url: fileURL, sampleRate: 16_000, channelCount: 1)
)
// Poll elapsed time while recording
let elapsed: TimeInterval? = await audioRecorder.file.currentTime()
// Pause and resume
try await audioRecorder.file.pause()
try await audioRecorder.file.resume()
// Stop and inspect metadata
let result = try await audioRecorder.file.stop()
print("Saved to \(result.url.lastPathComponent)")
print("Duration: \(result.duration)s, samples: \(result.sampleCount)")Check and control the system output mute state independently of any recording session:
import AudioRecorderClient
// Read
let muted = try SystemAudio.isMuted()
// Write (macOS only)
try SystemAudio.setMuted(true)
try SystemAudio.setMuted(false)public enum AudioRecorderClientError: Error, Sendable {
case sessionAlreadyActive // tried to start while another session is running
case noActiveSession // called pause/resume/stop with nothing active
case invalidOperationForActiveMode // e.g. called live.stop() during a file session
case engineStartFailed
case converterFailed
case fileWriteFailed
}SystemAudio throws AudioError:
case .noDefaultDevice
case .propertyNotFound
case .propertyNotSettable
case .osStatusError(OSStatus)
case .notSupportedOnPlatform
case .couldNotActivateAudioSessionThe package ships testValue and previewValue implementations via swift-dependencies.
testValue uses unimplemented stubs — any endpoint you don't override will fail the test if called. Override only what you need:
withDependencies {
$0.audioRecorder.permissions.requestRecordPermission = { true }
$0.audioRecorder.live.start = { _ in
AsyncThrowingStream { continuation in
continuation.yield(.float32([0.1, -0.1, 0.25, -0.25]))
continuation.finish()
}
}
$0.audioRecorder.live.stop = {}
} operation: {
// test your model
}Use previewValue in SwiftUI previews — it returns a single synthetic payload and finishes immediately.
AudioRecorder/
├── Sources/
│ └── AudioRecorderClient/
│ ├── AudioRecorderClient+LiveKey.swift # AVAudioEngine implementation
│ ├── AudioRecorderClient+TestKey.swift # testValue / previewValue
│ └── SystemAudio.swift # platform mute utilities
├── Tests/
│ └── AudioRecorderClientTests/
├── Examples/
│ └── AudioRecorderExamples/ # Xcode project with runnable examples
│ ├── AudioEndpointHarnessView.swift # all endpoints in one UI
│ ├── StreamVADView.swift # VAD + SFSpeechRecognizer
│ ├── StreamToSFSpeech.swift # minimal VAD → transcription
│ ├── IsMutedView.swift # SystemAudio demo
│ └── OpenAIRealTimeTranscription.swift # WebSocket streaming example
├── docs/
│ └── MIGRATION_GUIDE.md
└── Package.swift
The Examples/ Xcode project is the best place to see full working patterns. AudioEndpointHarnessView exercises every endpoint in one screen with a live event log.
# build
swift build
# run tests
swift testNo additional tooling is required beyond Xcode and the Swift toolchain.
Audio capture and conversion logic is partially adapted from WhisperKit (MIT).
If you are upgrading from a version that had separate AudioDataStreamClient or AudioProcessorClient targets, see docs/MIGRATION_GUIDE.md.