Skip to content

Commit e9e1c29

Browse files
cornelcroiclaude
andcommitted
feat(swift): VoiceProcessedAudioIO — capture+playback on one VP engine
Playback renders through the same voice-processed engine as capture, so the assistant audio is guaranteed to be in the AEC reference path (review feedback: the split-pair reference is device-level and route-dependent). Recommended wiring: one instance as both input and output. Internal lock serializes enqueue/flush/stop against runtime actor reentrancy. Refs #559 Co-authored-by: Claude <claude@anthropic.com>
1 parent f365b8a commit e9e1c29

13 files changed

Lines changed: 366 additions & 74 deletions

File tree

docs/astro.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ export default defineConfig({
288288
label: 'Audio',
289289
items: [
290290
{ label: 'Overview', slug: 'swift/audio/overview' },
291+
{ label: 'VoiceProcessedAudioIO', slug: 'swift/audio/built-in/voice-processed-audio-io' },
291292
{ label: 'MicCapture', slug: 'swift/audio/built-in/mic-capture' },
292293
{ label: 'AudioPlayback', slug: 'swift/audio/built-in/audio-playback' },
293294
{ label: 'Custom audio', slug: 'swift/audio/custom' },

docs/src/content/docs/swift/audio/built-in/audio-playback.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ description: AVAudioEngine-backed AudioOutput that converts PCM16 frames to floa
55

66
`AudioPlayback` is part of the `AgentSquadAudio` product. It accepts PCM16 @ 24 kHz frames, converts them to float32, and schedules them on an `AVAudioPlayerNode` for continuous playback. `flush()` provides an instant barge-in cut by discarding all buffered audio.
77

8+
:::tip
9+
For voice sessions, prefer [`VoiceProcessedAudioIO`](/agent-squad/swift/audio/built-in/voice-processed-audio-io/) — playback renders through the same voice-processed engine as capture, guaranteeing echo cancellation of the assistant's audio.
10+
:::
11+
812
```swift
913
import AgentSquadAudio
1014
```

docs/src/content/docs/swift/audio/built-in/mic-capture.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ description: AVAudioEngine-backed AudioInput that taps the microphone, converts
77

88
By default it captures through Apple's **Voice-Processing I/O** unit: echo cancellation that uses the speaker signal as a hardware reference to subtract the assistant's own voice from the mic, plus noise suppression and automatic gain control.
99

10+
:::tip
11+
For voice sessions, prefer [`VoiceProcessedAudioIO`](/agent-squad/swift/audio/built-in/voice-processed-audio-io/) — capture and playback on **one** engine, which guarantees the assistant's audio is in the AEC reference path. With the split `MicCapture`/`AudioPlayback` pair the reference is device-level and route-dependent.
12+
:::
13+
1014
```swift
1115
import AgentSquadAudio
1216
```
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
title: VoiceProcessedAudioIO
3+
description: Capture and playback on one voice-processed AVAudioEngine — echo cancellation with the assistant's audio guaranteed in the reference path. The recommended wiring for voice sessions.
4+
---
5+
6+
`VoiceProcessedAudioIO` is part of the `AgentSquadAudio` product. It runs microphone capture **and** assistant playback on a **single** `AVAudioEngine` with Apple's Voice-Processing I/O unit enabled. Because the assistant's audio renders through the same engine's voice-processed output, it is by construction in the echo canceller's reference path — the configuration the VP unit is designed around.
7+
8+
Conforms to **both** `AudioInput` and `AudioOutput`: pass **one instance** as both `input:` and `output:`.
9+
10+
```swift
11+
import AgentSquadAudio
12+
13+
let io = VoiceProcessedAudioIO()
14+
let runtime = RealtimeRuntime(session: assistant, input: io, output: io)
15+
try await runtime.start()
16+
```
17+
18+
Prefer this over the separate [`MicCapture`](/agent-squad/swift/audio/built-in/mic-capture/) + [`AudioPlayback`](/agent-squad/swift/audio/built-in/audio-playback/) pair for voice sessions — with two engines the echo reference is taken at the device level, which is route-dependent.
19+
20+
---
21+
22+
## Init
23+
24+
```swift
25+
public init(
26+
sampleRate: Double = 24_000,
27+
maxBufferedFrames: Int = 16,
28+
voiceProcessing: VoiceProcessing = .default,
29+
sessionPolicy: AudioSessionPolicy = .managed,
30+
configureEngine: (@Sendable (AVAudioEngine) throws -> Void)? = nil
31+
)
32+
```
33+
34+
| Parameter | Default | Notes |
35+
|---|---|---|
36+
| `sampleRate` | `24_000` | Both capture and playback rate. Must match what the realtime session expects (OpenAI Realtime: PCM is always 24 kHz). |
37+
| `maxBufferedFrames` | `16` | Capacity of the capture `AsyncStream`; oldest frames dropped under back-pressure. |
38+
| `voiceProcessing` | `.default` | AEC + noise suppression + AGC tuning. **Non-optional** — raw capture defeats this class's purpose; use `MicCapture(voiceProcessing: nil)` for that. |
39+
| `sessionPolicy` | `.managed` | Who configures the `AVAudioSession` — see [AudioSessionPolicy](/agent-squad/swift/audio/built-in/mic-capture/#audiosessionpolicy-ios-only). |
40+
| `configureEngine` | `nil` | Escape hatch: runs with the raw `AVAudioEngine` after voice processing is enabled and the player is wired, before the tap is installed. |
41+
42+
---
43+
44+
## Public surface
45+
46+
```swift
47+
public let frames: AsyncStream<Data> // AudioInput — captured PCM16 LE mono frames
48+
49+
public func start() async throws // both roles; idempotent
50+
public func enqueue(_ pcm16: Data) async // AudioOutput — schedule one frame
51+
public func flush() async // AudioOutput — instant barge-in cut
52+
public func stop() async // both roles; idempotent
53+
```
54+
55+
`start()` and `stop()` are **idempotent**`RealtimeRuntime` calls each twice on the same instance (once through the `AudioOutput` role, once through `AudioInput`), and the second call is a no-op. `enqueue`/`flush` before `start()` or after `stop()` are safe no-ops. One instance serves **one session**: `stop()` finishes the `frames` stream for good — create a new instance to start again.
56+
57+
Failure modes are the shared [`MicCaptureError`](/agent-squad/swift/audio/built-in/mic-capture/#miccaptureerror) cases: `permissionDenied`, `converterUnavailable`, `voiceProcessingUnavailable`.
58+
59+
:::caution
60+
The **simulator performs no echo cancellation** — validate AEC on a real device. Voice-processed output sounds "call-like" and slightly quieter; counter with `duckingLevel: .min`.
61+
:::
62+
63+
---
64+
65+
## Related pages
66+
67+
- [Audio overview](/agent-squad/swift/audio/overview/) — the `AudioInput`/`AudioOutput` protocols
68+
- [MicCapture](/agent-squad/swift/audio/built-in/mic-capture/) — capture-only built-in (split-pair wiring, `VoiceProcessing`, `AudioSessionPolicy` docs)
69+
- [AudioPlayback](/agent-squad/swift/audio/built-in/audio-playback/) — playback-only built-in
70+
- [Voice overview](/agent-squad/swift/voice/overview/) — the `RealtimeRuntime` that consumes this class

docs/src/content/docs/swift/audio/overview.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ title: Audio overview
33
description: The AgentSquadAudio product and the AudioInput/AudioOutput protocols that connect microphone capture and speaker playback to the voice runtime.
44
---
55

6-
`AgentSquadAudio` is a separate Swift package product that ships two AVFoundation-backed implementations — `MicCapture` and `AudioPlayback` — built on top of two protocols declared in the core `AgentSquad` module.
6+
`AgentSquadAudio` is a separate Swift package product that ships three AVFoundation-backed implementations — `VoiceProcessedAudioIO` (the recommended one), `MicCapture`, and `AudioPlayback` — built on top of two protocols declared in the core `AgentSquad` module.
77

88
```swift
99
import AgentSquad // AudioInput, AudioOutput protocols
10-
import AgentSquadAudio // MicCapture, AudioPlayback
10+
import AgentSquadAudio // VoiceProcessedAudioIO, MicCapture, AudioPlayback
1111
```
1212

1313
---
@@ -48,14 +48,17 @@ public protocol AudioOutput: Sendable {
4848

4949
## How they feed the voice runtime
5050

51-
[`RealtimeRuntime`](/agent-squad/swift/voice/overview/) accepts an `AudioInput` and an `AudioOutput` at construction time:
51+
[`RealtimeRuntime`](/agent-squad/swift/voice/overview/) accepts an `AudioInput` and an `AudioOutput` at construction time. The recommended wiring is one `VoiceProcessedAudioIO` instance in both roles — capture and playback share a single voice-processed engine, so the assistant's audio is guaranteed to be in the echo canceller's reference path:
5252

5353
```swift
54-
let runtime = RealtimeRuntime(
55-
input: MicCapture(),
56-
output: AudioPlayback(),
57-
// ... other config
58-
)
54+
let io = VoiceProcessedAudioIO()
55+
let runtime = RealtimeRuntime(session: assistant, input: io, output: io)
56+
```
57+
58+
The split pair works too (the AEC reference is then device-level, which is route-dependent):
59+
60+
```swift
61+
let runtime = RealtimeRuntime(session: assistant, input: MicCapture(), output: AudioPlayback())
5962
```
6063

6164
The runtime drives `start`, `stop`, `enqueue`, and `flush` from its single event pump, so implementations are never called concurrently by the runtime itself.
@@ -70,7 +73,8 @@ The wire format for both protocols is **PCM16 little-endian mono at 24 kHz**. Th
7073

7174
| Type | Protocol | Description |
7275
|---|---|---|
73-
| [`MicCapture`](/agent-squad/swift/audio/built-in/mic-capture/) | `AudioInput` | AVAudioEngine tap → PCM16 @ 24 kHz, with iOS permission gating |
76+
| [`VoiceProcessedAudioIO`](/agent-squad/swift/audio/built-in/voice-processed-audio-io/) | `AudioInput` + `AudioOutput` | Capture and playback on **one** voice-processed engine — guaranteed AEC reference path; pass one instance as both input and output |
77+
| [`MicCapture`](/agent-squad/swift/audio/built-in/mic-capture/) | `AudioInput` | AVAudioEngine tap → PCM16 @ 24 kHz, voice-processed by default, with iOS permission gating |
7478
| [`AudioPlayback`](/agent-squad/swift/audio/built-in/audio-playback/) | `AudioOutput` | AVAudioEngine + AVAudioPlayerNode, with barge-in flush |
7579

7680
---

swift/README.md

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ the integrations you need (each isolates its own dependencies):
214214
|---|---|---|---|
215215
| **`AgentSquad`** (core) | `import AgentSquad` | **nothing external** | protocols, agents, orchestrator, native tools (`ToolKit`, `Tool.local`/`.http`, `HTTPToolGroup`, `AggregateToolProvider`), `FileChatStorage`, `DeviceChatStorage` _(iOS 17+)_, `InMemoryChatStorage`, `OSLogTracer` |
216216
| **`AgentSquadMCP`** | `import AgentSquadMCP` | the official [MCP Swift SDK](https://github.com/modelcontextprotocol/swift-sdk) | `MCPServer` (alias of `MCPToolProvider`) — connect any MCP server with `MCPServer(url:)`, with MCP Apps UI support |
217-
| **`AgentSquadAudio`** | `import AgentSquadAudio` | AVFoundation (Apple platforms only) | `MicCapture` (echo-cancelled by default) + `AudioPlayback` for the realtime voice runtime — requires `NSMicrophoneUsageDescription` in your Info.plist |
217+
| **`AgentSquadAudio`** | `import AgentSquadAudio` | AVFoundation (Apple platforms only) | `VoiceProcessedAudioIO` (echo-cancelled capture + playback on one engine — the recommended wiring), `MicCapture`, `AudioPlayback` for the realtime voice runtime — requires `NSMicrophoneUsageDescription` in your Info.plist |
218218

219219
So an app that doesn't use MCP never downloads the MCP SDK. Future optional integrations
220220
(e.g. `AgentSquadLangfuse` for trace export) follow the same pattern — the core never grows a
@@ -229,7 +229,7 @@ dependency. Add a product to your target's `dependencies` to use it:
229229

230230
## Voice audio — echo cancellation and full control
231231

232-
By default `MicCapture` captures through Apple's **Voice-Processing I/O** unit — the native
232+
The audio layer captures through Apple's **Voice-Processing I/O** unit by default — the native
233233
equivalent of what ChatGPT's voice mode gets via WebRTC. The signal sent to the speaker is used
234234
as a hardware reference to subtract the assistant's own voice from the mic, plus noise
235235
suppression and automatic gain control. Without it, the assistant hears itself through the
@@ -238,53 +238,58 @@ speaker and interrupts its own answers.
238238
The audio layer is configurable in four independent levels — each level keeps everything the
239239
previous levels give you:
240240

241-
**Level 0 — defaults.** Echo-cancelled capture, nothing to configure:
241+
**Level 0 — defaults.** `VoiceProcessedAudioIO` runs capture **and** playback on one
242+
`AVAudioEngine`, so the assistant's audio renders through the voice-processed output and is by
243+
construction in the echo canceller's reference path. Pass one instance as both `input:` and
244+
`output:`:
242245

243246
```swift
244247
import AgentSquadAudio
245248

246-
let runtime = RealtimeRuntime(session: assistant, input: MicCapture(), output: AudioPlayback())
249+
let io = VoiceProcessedAudioIO()
250+
let runtime = RealtimeRuntime(session: assistant, input: io, output: io)
247251
try await runtime.start()
248252
```
249253

254+
The separate `MicCapture` + `AudioPlayback` pair still works (both echo-cancelled on the capture
255+
side by default) — but with two engines the echo reference is taken at the device level, which is
256+
route-dependent. Prefer `VoiceProcessedAudioIO` for voice sessions.
257+
250258
**Level 1 — tune voice processing** (or turn it off):
251259

252260
```swift
253261
// Keep AEC but disable gain control and minimize how much the system ducks playback volume:
254-
let mic = MicCapture(voiceProcessing: .init(automaticGainControl: false, duckingLevel: .min))
262+
let io = VoiceProcessedAudioIO(voiceProcessing: .init(automaticGainControl: false, duckingLevel: .min))
255263

256-
// Raw capture — no AEC at all (the previous default):
264+
// Raw capture — no AEC at all (the previous default; split pair only):
257265
let rawMic = MicCapture(voiceProcessing: nil)
258266
```
259267

260268
If the Voice-Processing unit can't be enabled, `start()` throws
261269
`MicCaptureError.voiceProcessingUnavailable(_:)` rather than silently degrading — catch it and
262270
retry with `voiceProcessing: nil` if raw capture is an acceptable fallback for your app.
263271

264-
**Level 2 — own the `AVAudioSession`, or reach the raw engine.** Both `MicCapture` and
265-
`AudioPlayback` take an `AudioSessionPolicy`; give them the same one so they can't fight:
272+
**Level 2 — own the `AVAudioSession`, or reach the raw engine.** All three audio classes take
273+
an `AudioSessionPolicy` (if you use the split pair, give both the same one so they can't fight):
266274

267275
```swift
268276
// Your app already manages its AVAudioSession (music, video, CallKit…) — AgentSquad won't touch it.
269277
// You must configure AND activate the session yourself before runtime.start().
270-
let mic = MicCapture(sessionPolicy: .external)
271-
let out = AudioPlayback(sessionPolicy: .external)
278+
let io = VoiceProcessedAudioIO(sessionPolicy: .external)
272279

273280
// Or let AgentSquad drive the timing but with YOUR configuration (iOS):
274-
let policy = AudioSessionPolicy.custom { session in
281+
let io2 = VoiceProcessedAudioIO(sessionPolicy: .custom { session in
275282
try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetoothHFP]) // no speaker override
276283
try session.setActive(true)
277-
}
278-
let mic2 = MicCapture(sessionPolicy: policy)
279-
let out2 = AudioPlayback(sessionPolicy: policy)
284+
})
280285
```
281286

282287
The `configureEngine` hook hands you the underlying `AVAudioEngine` at the right lifecycle
283288
moment (after voice processing is enabled, before the tap is installed), so any AVFoundation
284289
API stays reachable without forking the class:
285290

286291
```swift
287-
let mic = MicCapture(configureEngine: { engine in
292+
let io = VoiceProcessedAudioIO(configureEngine: { engine in
288293
// e.g. inspect engine.inputNode, insert effect nodes, adopt future AVFoundation APIs…
289294
})
290295
```

swift/SKILL.md

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Every component is a `Sendable` protocol with one built-in implementation — sw
3333
|---|---|---|
3434
| `AgentSquad` | nothing external | protocols, `Agent`, `GroundedAgent`, `Orchestrator`, `LLMClassifier`, `ChatCompletionsClient`, `FileChatStorage`, `DeviceChatStorage`, `InMemoryChatStorage`, `OSLogTracer`, OTLP export |
3535
| `AgentSquadMCP` | MCP Swift SDK | `MCPServer` (= `MCPToolProvider`), `SDKMCPClient` |
36-
| `AgentSquadAudio` | AVFoundation | `MicCapture` (voice-processed/AEC by default), `AudioPlayback`, `VoiceProcessing`, `AudioSessionPolicy` (needs `NSMicrophoneUsageDescription`) |
36+
| `AgentSquadAudio` | AVFoundation | `VoiceProcessedAudioIO` (capture+playback, one engine, AEC — the recommended wiring), `MicCapture` (voice-processed/AEC by default), `AudioPlayback`, `VoiceProcessing`, `AudioSessionPolicy` (needs `NSMicrophoneUsageDescription`) |
3737

3838
SwiftPM: `.package(url: "https://github.com/2FastLabs/agent-squad", branch: "main")`.
3939

@@ -76,10 +76,13 @@ stream. `.final` is what the orchestrator persists. Inputs/messages are value ty
7676
- **Voice**: two `VoiceAssistant`s over a WebSocket — `OpenAIVoiceAssistant` (single LLM, speaks
7777
directly) and `OpenAIGroundedVoiceAssistant` (grounded Brain → Presenter). Both are self-sufficient
7878
(own `tracer`/`store`/`userId`/`sessionId`; with a `store`, completed turns persist and prior
79-
history seeds on `start()`), wired to the mic/speaker by `RealtimeRuntime` with `MicCapture`/`AudioPlayback`.
80-
`MicCapture` captures through Apple's Voice-Processing I/O unit by default (echo cancellation,
81-
noise suppression, AGC — tune via `voiceProcessing:`, or pass `nil` for raw capture); both audio
82-
classes take an `AudioSessionPolicy` (`.managed` / `.custom` / `.external` for apps that own the
79+
history seeds on `start()`), wired to the mic/speaker by `RealtimeRuntime`. Preferred audio
80+
wiring: ONE `VoiceProcessedAudioIO` instance passed as both `input:` and `output:` — capture and
81+
playback share one voice-processed `AVAudioEngine`, so the assistant's audio is guaranteed to be
82+
in the echo canceller's reference path. The split `MicCapture`/`AudioPlayback` pair also works
83+
(capture is voice-processed by default; the AEC reference is then device-level/route-dependent;
84+
`MicCapture(voiceProcessing: nil)` = raw capture). All three audio classes take an
85+
`AudioSessionPolicy` (`.managed` / `.custom` / `.external` for apps that own the
8386
`AVAudioSession`) and a `configureEngine` hook exposing the raw `AVAudioEngine`.
8487
Session tuning on both: `transcriptionModel` (the user's STT only), `turnDetection`
8588
(`.semanticVAD(eagerness:)` / `.serverVAD(threshold:…)` / `.disabled`), and `sessionOverrides`
@@ -121,12 +124,14 @@ signatures live in `Sources/AgentSquad/`.
121124
pattern-scrub PII — supply a custom `Redactor` for that.
122125
- **Realtime** is a peer runtime, not an agent; its `events` stream is non-throwing; needs
123126
`NSMicrophoneUsageDescription`; always `stop()`.
124-
- **Voice processing (AEC)**: on by default in `MicCapture`; if it can't be enabled `start()`
125-
throws `.voiceProcessingUnavailable` (degrade deliberately with `voiceProcessing: nil`). The
127+
- **Voice processing (AEC)**: on by default; if it can't be enabled `start()` throws
128+
`.voiceProcessingUnavailable` (degrade deliberately with `MicCapture(voiceProcessing: nil)`).
129+
For guaranteed echo cancellation use `VoiceProcessedAudioIO` and pass the **same instance** as
130+
input and output (its `start()`/`stop()` are idempotent — the runtime calls each twice). The
126131
simulator does **no** AEC — validate on a device. VP quiets the speaker (counter with
127-
`duckingLevel: .min`); never enable VP on the playback engine. With `sessionPolicy: .external`
128-
the app must configure **and activate** its `AVAudioSession` before `start()`, and should pass
129-
the same policy to both `MicCapture` and `AudioPlayback`.
132+
`duckingLevel: .min`); never enable VP on a playback-only engine. With
133+
`sessionPolicy: .external` the app must configure **and activate** its `AVAudioSession` before
134+
`start()`, and should use the same policy everywhere.
130135
- **`ContentPart` Codable** keys off case + label names — renaming breaks stored history.
131136

132137
## Go deeper

swift/Sources/AgentSquadAudio/AudioConfiguration.swift

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,21 @@ public struct VoiceProcessing: Sendable, Equatable {
2323
public static let `default` = VoiceProcessing()
2424
}
2525

26-
/// Who configures the `AVAudioSession`. `MicCapture` and `AudioPlayback` take the same policy so
27-
/// the two can't fight over the session. No effect on macOS (no `AVAudioSession` there).
26+
extension VoiceProcessing.DuckingLevel {
27+
@available(iOS 17.0, macOS 14.0, *)
28+
var avLevel: AVAudioVoiceProcessingOtherAudioDuckingConfiguration.Level {
29+
switch self {
30+
case .default: .default
31+
case .min: .min
32+
case .mid: .mid
33+
case .max: .max
34+
}
35+
}
36+
}
37+
38+
/// Who configures the `AVAudioSession`. All audio classes take a policy; if you use the split
39+
/// `MicCapture`/`AudioPlayback` pair, give both the same one so they can't fight over the
40+
/// session. No effect on macOS (no `AVAudioSession` there).
2841
public enum AudioSessionPolicy: Sendable {
2942
/// AgentSquad configures it: `.playAndRecord`, `.voiceChat`, speaker output, Bluetooth HFP.
3043
case managed

0 commit comments

Comments
 (0)