forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathStreamingPCMPlayer.swift
More file actions
378 lines (344 loc) · 14.1 KB
/
Copy pathStreamingPCMPlayer.swift
File metadata and controls
378 lines (344 loc) · 14.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
@preconcurrency import AVFoundation
import Foundation
/// `AVAudioPCMBuffer` is not Sendable; this box lets a scheduled-buffer
/// completion carry the buffer across to the main-actor bookkeeping hop.
private struct PCMBufferBox: @unchecked Sendable {
let buffer: AVAudioPCMBuffer
}
/// Tracks buffers that AVAudioPlayerNode owns but has not reported as played yet.
///
/// `AVAudioPlayerNode.stop()` discards every scheduled buffer. Route/sample-rate
/// changes force us to stop and rebuild the node graph, so the app must own a
/// mirror of the scheduled tail and replay it after recovery. Keep this small
/// state machine separate from AVFoundation calls so route-change behavior is
/// testable without real audio hardware.
final class StreamingPCMPlaybackQueue<Buffer: AnyObject> {
/// The result of accepting one physical buffer completion.
///
/// A completion is emitted only when the callback belongs to the queue's
/// current generation. The remaining count is intentionally bounded to
/// queue metadata; it contains no audio content and is useful for liveness
/// diagnostics and deciding whether this completion drained the tail.
struct Completion: Equatable, Sendable {
let generation: Int
let remainingBufferCount: Int
var isIdle: Bool { remainingBufferCount == 0 }
}
private(set) var scheduledBuffers: [Buffer] = []
private(set) var generation = 0
var isEmpty: Bool { scheduledBuffers.isEmpty }
var scheduledBufferCount: Int { scheduledBuffers.count }
@discardableResult
func appendScheduled(_ buffer: Buffer) -> Int {
scheduledBuffers.append(buffer)
return generation
}
@discardableResult
func markPlayed(_ buffer: Buffer, generation completionGeneration: Int) -> Bool {
markPlayedResult(buffer, generation: completionGeneration) != nil
}
/// Accepts one physical playback completion and returns the resulting queue
/// metadata. Stale callbacks from a prior configuration/replacement/stop
/// are rejected before they can produce progress or idle notifications.
@discardableResult
func markPlayedResult(
_ buffer: Buffer,
generation completionGeneration: Int
) -> Completion? {
guard completionGeneration == generation else { return nil }
if let index = scheduledBuffers.firstIndex(where: { $0 === buffer }) {
scheduledBuffers.remove(at: index)
return Completion(
generation: generation,
remainingBufferCount: scheduledBuffers.count)
}
return nil
}
func buffersToReplayAfterConfigurationChange() -> [Buffer] {
let buffers = scheduledBuffers
generation += 1
scheduledBuffers.removeAll()
return buffers
}
func clearForExplicitStop() {
generation += 1
scheduledBuffers.removeAll()
}
}
/// Progress emitted after one queued PCM buffer has physically played.
///
/// `playbackEpoch` identifies the scheduled buffer and is monotonic within a
/// live queue generation; earlier epochs are valid progress while a later
/// buffer remains queued. Consumers should fence the lifecycle with
/// `queueGeneration` and their active output lease, then use `isIdle` only for
/// the final callback. `queueGeneration` changes on configuration replay and
/// explicit stop, fencing callbacks from an old turn or replacement.
struct StreamingPCMPlaybackProgress: Equatable, Sendable {
let playbackEpoch: Int
let queueGeneration: Int
let remainingBufferCount: Int
var isIdle: Bool { remainingBufferCount == 0 }
}
/// Keeps non-I/O audio units ready for the largest render slice macOS may ask
/// them to process after an output-route or sample-rate change.
///
/// CoreAudio can retain a route-specific `maximumFramesToRender` when an
/// `AVAudioEngine` graph is rebuilt. A later 512-frame render against a stale
/// 480-frame ceiling fails with `kAudioUnitErr_TooManyFramesToProcess`; the
/// player remains nominally running, but no `.dataPlayedBack` callback arrives.
/// Apple documents 4096 frames as the safe capacity for non-I/O units. This
/// must be applied while render resources are deallocated.
enum StreamingPCMRenderCapacity {
static let minimumFrames: AUAudioFrameCount = 4096
@discardableResult
static func configure(units: [AUAudioUnit]) -> [AUAudioFrameCount] {
units.map { unit in
if !unit.renderResourcesAllocated, unit.maximumFramesToRender < minimumFrames {
unit.maximumFramesToRender = minimumFrames
}
return unit.maximumFramesToRender
}
}
}
private final class DeferredConfigurationRecoveryAction: @unchecked Sendable {
let action: () -> Void
init(_ action: @escaping () -> Void) {
self.action = action
}
}
final class DeferredConfigurationRecovery: @unchecked Sendable {
typealias MainQueueScheduler = @Sendable (@escaping @Sendable () -> Void) -> Void
private let lock = NSLock()
private let onMainQueue: MainQueueScheduler
private var isPending = false
private var generation = 0
init(
onMainQueue: @escaping MainQueueScheduler = { action in
DispatchQueue.main.async(execute: action)
}
) {
self.onMainQueue = onMainQueue
}
func schedule(action: @escaping () -> Void) {
let scheduledGeneration: Int
lock.lock()
guard !isPending else {
lock.unlock()
return
}
isPending = true
generation += 1
scheduledGeneration = generation
lock.unlock()
let actionBox = DeferredConfigurationRecoveryAction(action)
onMainQueue { [weak self, actionBox] in
guard self?.isPendingRecovery(generation: scheduledGeneration) == true else { return }
actionBox.action()
self?.finishPendingRecovery(generation: scheduledGeneration)
}
}
func cancel() {
lock.lock()
generation += 1
isPending = false
lock.unlock()
}
private func isPendingRecovery(generation scheduledGeneration: Int) -> Bool {
lock.lock()
defer { lock.unlock() }
return isPending && generation == scheduledGeneration
}
private func finishPendingRecovery(generation scheduledGeneration: Int) {
lock.lock()
if generation == scheduledGeneration {
isPending = false
}
lock.unlock()
}
}
/// Plays streamed mono PCM16 audio incrementally (OpenAI Realtime / Gemini Live
/// output is 24 kHz). Feed chunks with `enqueue(_:)`; they play back-to-back in
/// arrival order. Used by `RealtimeHubController` to play the realtime model's
/// spoken response as it streams in.
///
/// Ported from the `feature/gpt-realtime` worktree's `LiveVoiceSession` audio
/// path (path adapted to the `desktop/macos/…` layout).
final class StreamingPCMPlayer: @unchecked Sendable {
private let engine = AVAudioEngine()
private let player = AVAudioPlayerNode()
private let format: AVAudioFormat
private var configObserver: NSObjectProtocol?
private let playbackQueue = StreamingPCMPlaybackQueue<AVAudioPCMBuffer>()
private let configurationRecovery = DeferredConfigurationRecovery()
private(set) var playbackEpoch = 0
/// Queue generation changes whenever the scheduled tail is invalidated.
/// Exposed so the lifecycle owner can fence progress callbacks without
/// requiring equality with the per-buffer `playbackEpoch`.
private(set) var playbackQueueGeneration = 0
/// Number of PCM buffers still awaiting a physical completion. This is
/// queue metadata only; it contains no audio content.
var scheduledBufferCount: Int { playbackQueue.scheduledBufferCount }
/// Bounded render metadata for diagnostics and regression verification.
private(set) var renderCapacities: [AUAudioFrameCount] = []
var onPlaybackScheduled: ((Int) -> Void)?
/// Called once for every valid physical `.dataPlayedBack` completion,
/// including non-final buffers. `onPlaybackIdle` remains final-only.
var onPlaybackProgress: ((StreamingPCMPlaybackProgress) -> Void)?
var onPlaybackIdle: ((Int) -> Void)?
init(sampleRate: Double = 24000) {
// Float32 mono at the source rate; the mixer resamples to the device rate.
format = AVAudioFormat(
commonFormat: .pcmFormatFloat32, sampleRate: sampleRate, channels: 1, interleaved: false)!
engine.attach(player)
engine.connect(player, to: engine.mainMixerNode, format: format)
configureRenderCapacity()
// Output-level tap for the notch speaking animation. Tapping the mixer
// (not enqueue-time RMS) keeps the visual in sync with what is audibly
// playing rather than leading it by the scheduled-queue depth. The tap
// callback runs on an audio thread; only the cheap RMS math happens there.
engine.mainMixerNode.installTap(
onBus: 0, bufferSize: 1024, format: engine.mainMixerNode.outputFormat(forBus: 0)
) { buffer, _ in
let level = Self.rmsLevel(of: buffer)
DispatchQueue.main.async {
AudioLevelMonitor.shared.updateVoicePlaybackLevel(level)
}
}
// An audio configuration change (another process grabbing the audio device, a
// device/sample-rate change, a Bluetooth A2DP↔HFP flip, etc.) STOPS the engine
// mid-stream — that's what cuts the reply off and can leave the engine in a
// half-dead state (isRunning=true but no output) that silences later turns.
// Fully tear down + rebuild the node graph and restart so playback always
// recovers. (The PTT path also avoids the BT flip by capturing from the
// built-in mic when output is Bluetooth — see PushToTalkManager.)
configObserver = NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange, object: engine, queue: .main
) { [weak self] _ in
guard let self = self else { return }
self.configurationRecovery.schedule { [weak self] in
guard let self = self else { return }
self.rebuildAfterConfigurationChange()
}
}
}
deinit {
if let observer = configObserver {
NotificationCenter.default.removeObserver(observer)
}
}
/// Ensure the engine + player are actually running before scheduling. Checking
/// the real `isRunning`/`isPlaying` state (not a one-shot flag) is what makes
/// playback survive past the first turn: AVAudioEngine auto-suspends when idle
/// after a reply finishes, so later turns must restart it.
private func ensureRunning() -> Bool {
if !engine.isRunning {
engine.prepare()
do {
try engine.start()
log(
"StreamingPCMPlayer: engine started, isRunning=\(engine.isRunning), outRate=\(engine.outputNode.outputFormat(forBus: 0).sampleRate)"
)
} catch {
log("StreamingPCMPlayer: engine start FAILED: \(error.localizedDescription)")
return false
}
}
if !player.isPlaying {
player.play()
}
return player.isPlaying
}
private func rebuildAfterConfigurationChange() {
log("StreamingPCMPlayer: audio config changed — rebuilding engine")
let buffersToReplay = playbackQueue.buffersToReplayAfterConfigurationChange()
playbackQueueGeneration = playbackQueue.generation
player.stop()
engine.stop()
engine.disconnectNodeOutput(player)
engine.connect(player, to: engine.mainMixerNode, format: format)
configureRenderCapacity()
_ = ensureRunning()
for buffer in buffersToReplay {
schedule(buffer)
}
}
private func configureRenderCapacity() {
renderCapacities = StreamingPCMRenderCapacity.configure(
units: [player.auAudioUnit, engine.mainMixerNode.auAudioUnit])
if renderCapacities.contains(where: { $0 < StreamingPCMRenderCapacity.minimumFrames }) {
log("StreamingPCMPlayer: render capacity remained below 4096 frames: \(renderCapacities)")
}
}
/// `data` = little-endian Int16 PCM, mono, at the configured sample rate.
@discardableResult
func enqueue(_ data: Data) -> Bool {
let sampleCount = data.count / 2
guard sampleCount > 0,
let buffer = AVAudioPCMBuffer(
pcmFormat: format, frameCapacity: AVAudioFrameCount(sampleCount))
else { return false }
buffer.frameLength = AVAudioFrameCount(sampleCount)
let channel = buffer.floatChannelData![0]
data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in
let src = raw.bindMemory(to: Int16.self)
for i in 0..<sampleCount {
channel[i] = max(-1.0, min(1.0, Float(src[i]) / 32768.0))
}
}
guard ensureRunning() else { return false }
schedule(buffer)
return true
}
private func schedule(_ buffer: AVAudioPCMBuffer) {
playbackEpoch += 1
let scheduledPlaybackEpoch = playbackEpoch
onPlaybackScheduled?(scheduledPlaybackEpoch)
let generation = playbackQueue.appendScheduled(buffer)
// AVAudioPCMBuffer is not Sendable; box it so the main-actor completion hop
// can carry it across the concurrency boundary.
let bufferBox = PCMBufferBox(buffer: buffer)
player.scheduleBuffer(buffer, completionCallbackType: .dataPlayedBack) { [weak self] _ in
guard let self else { return }
DispatchQueue.main.async {
guard
let completion = self.playbackQueue.markPlayedResult(
bufferBox.buffer, generation: generation)
else { return }
self.onPlaybackProgress?(
StreamingPCMPlaybackProgress(
playbackEpoch: scheduledPlaybackEpoch,
queueGeneration: completion.generation,
remainingBufferCount: completion.remainingBufferCount))
if completion.isIdle {
self.onPlaybackIdle?(scheduledPlaybackEpoch)
}
}
}
}
func stop() {
playbackEpoch += 1
configurationRecovery.cancel()
playbackQueue.clearForExplicitStop()
playbackQueueGeneration = playbackQueue.generation
player.stop()
engine.stop()
DispatchQueue.main.async {
AudioLevelMonitor.shared.updateVoicePlaybackLevel(0)
}
}
/// Root-mean-square level of a float PCM buffer across all channels, 0…1.
static func rmsLevel(of buffer: AVAudioPCMBuffer) -> Float {
guard let channels = buffer.floatChannelData, buffer.frameLength > 0 else { return 0 }
let channelCount = Int(buffer.format.channelCount)
let frames = Int(buffer.frameLength)
var sum: Float = 0
for channel in 0..<channelCount {
let samples = channels[channel]
for frame in 0..<frames {
let sample = samples[frame]
sum += sample * sample
}
}
return min(1, sqrt(sum / Float(frames * channelCount)))
}
}