forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathRayBanMetaAudioCapture.swift
More file actions
231 lines (202 loc) · 9.19 KB
/
Copy pathRayBanMetaAudioCapture.swift
File metadata and controls
231 lines (202 loc) · 9.19 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
import AVFoundation
import Foundation
/// Captures the Ray-Ban Meta glasses microphone over the Bluetooth HFP route.
///
/// The Meta Wearables Device Access Toolkit has no microphone API; Meta's
/// documented input path is HFP through AVAudioSession. This engine prefers
/// the glasses' HFP input port, taps the input node, converts to PCM16 mono
/// 16 kHz, and hands frames to the caller. It has no DAT dependency, so the
/// labeled audio-only fallback works on builds without the SDK.
///
/// Ordering caveat from Meta's docs: when combining with DAT camera streaming,
/// HFP must be fully active before the camera stream starts or the audio route
/// can fail silently. RayBanMetaHostApiImpl sequences that.
final class RayBanMetaAudioCapture {
static let targetSampleRate: Double = 16000.0
private let engine = AVAudioEngine()
private var converter: AVAudioConverter?
private var isRunning = false
private var targetInputUid: String?
/// PCM16 little-endian mono frames at targetSampleRate.
var onFrame: ((Data, Double) -> Void)?
/// Reports whether the active input route is a Bluetooth HFP port.
var onRouteChanged: ((Bool) -> Void)?
var onError: ((String, String) -> Void)?
init() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handleRouteChange(_:)),
name: AVAudioSession.routeChangeNotification,
object: nil
)
// A phone call (or Siri) interruption stops the engine out from under us;
// without observing it, isRunning stays true and capture dies silently.
NotificationCenter.default.addObserver(
self,
selector: #selector(handleInterruption(_:)),
name: AVAudioSession.interruptionNotification,
object: nil
)
// The engine rebuilds its I/O when the hardware format changes (e.g. the
// HFP route drops to the built-in mic); the tap installed at start() is
// frozen at the old format, so continuing would emit garbage or silence.
NotificationCenter.default.addObserver(
self,
selector: #selector(handleEngineConfigurationChange(_:)),
name: .AVAudioEngineConfigurationChange,
object: engine
)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
/// Bluetooth HFP input ports currently available. AVAudioSession's UID is
/// stable when the user renames the device; portName is not.
static func availableHfpInputs() -> [(uid: String, name: String)] {
let session = AVAudioSession.sharedInstance()
return (session.availableInputs ?? [])
.filter { $0.portType == .bluetoothHFP }
.map { (uid: $0.uid, name: $0.portName) }
}
/// True when the active input route is a Bluetooth HFP port.
static func isHfpRouteActive(inputUid: String? = nil) -> Bool {
return AVAudioSession.sharedInstance().currentRoute.inputs.contains {
$0.portType == .bluetoothHFP && (inputUid == nil || $0.uid == inputUid)
}
}
var isSelectedRouteActive: Bool { Self.isHfpRouteActive(inputUid: targetInputUid) }
func start(targetUid: String?) throws {
guard !isRunning else { return }
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .default, options: [.allowBluetoothHFP])
let hfpInputs = (session.availableInputs ?? []).filter { $0.portType == .bluetoothHFP }
let selectedInput: AVAudioSessionPortDescription?
if let targetUid {
selectedInput = hfpInputs.first { $0.uid == targetUid }
guard selectedInput != nil else {
throw NSError(
domain: "RayBanMetaAudioCapture", code: 3,
userInfo: [NSLocalizedDescriptionKey: "Selected Bluetooth microphone is unavailable"]
)
}
} else {
// DAT mode does not expose a mapping from its device ID to the HFP
// port UID, so preserve its existing first-HFP behavior.
selectedInput = hfpInputs.first
}
if let hfpInput = selectedInput {
try session.setPreferredInput(hfpInput)
}
targetInputUid = targetUid
try session.setActive(true, options: .notifyOthersOnDeactivation)
let input = engine.inputNode
let inputFormat = input.inputFormat(forBus: 0)
guard inputFormat.sampleRate > 0 else {
throw NSError(
domain: "RayBanMetaAudioCapture", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Audio input format unavailable"]
)
}
guard
let outputFormat = AVAudioFormat(
commonFormat: .pcmFormatInt16,
sampleRate: Self.targetSampleRate,
channels: 1,
interleaved: true
),
let converter = AVAudioConverter(from: inputFormat, to: outputFormat)
else {
throw NSError(
domain: "RayBanMetaAudioCapture", code: 2,
userInfo: [NSLocalizedDescriptionKey: "Could not build PCM16/16kHz converter"]
)
}
self.converter = converter
input.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
self?.convertAndEmit(buffer: buffer, outputFormat: outputFormat)
}
engine.prepare()
try engine.start()
isRunning = true
// Give the Bluetooth route a moment to settle, then report it. Meta's
// guidance: the HFP route can take ~2s to stabilize after engine start.
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
guard let self else { return }
self.onRouteChanged?(self.isSelectedRouteActive)
}
}
func stop() {
guard isRunning else { return }
engine.inputNode.removeTap(onBus: 0)
engine.stop()
converter = nil
isRunning = false
targetInputUid = nil
do {
try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
} catch {
// Deactivation failure is non-fatal; another component may hold the session.
}
}
var running: Bool { isRunning }
private func convertAndEmit(buffer: AVAudioPCMBuffer, outputFormat: AVAudioFormat) {
guard let converter = converter else { return }
let ratio = outputFormat.sampleRate / buffer.format.sampleRate
let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 16
guard let outBuffer = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: capacity) else { return }
var fed = false
var conversionError: NSError?
converter.convert(to: outBuffer, error: &conversionError) { _, outStatus in
if fed {
outStatus.pointee = .noDataNow
return nil
}
fed = true
outStatus.pointee = .haveData
return buffer
}
if let conversionError = conversionError {
onError?("audio_convert", conversionError.localizedDescription)
return
}
guard outBuffer.frameLength > 0, let channel = outBuffer.int16ChannelData?[0] else { return }
let data = Data(bytes: channel, count: Int(outBuffer.frameLength) * MemoryLayout<Int16>.size)
onFrame?(data, Self.targetSampleRate)
}
@objc private func handleRouteChange(_ notification: Notification) {
let hfpActive = isSelectedRouteActive
onRouteChanged?(hfpActive)
// Losing the glasses' HFP input mid-capture means the tap is now fed by
// whatever input took over (usually the phone mic) at a stale format.
// Stop and surface it instead of silently recording the wrong source.
if isRunning && !hfpActive {
DispatchQueue.main.async { [weak self] in
guard let self = self, self.isRunning, !self.isSelectedRouteActive else { return }
self.stop()
self.onError?("audio_route_lost", "Glasses microphone route was lost during capture")
}
}
}
@objc private func handleInterruption(_ notification: Notification) {
guard
let info = notification.userInfo,
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
else { return }
if type == .began && isRunning {
DispatchQueue.main.async { [weak self] in
guard let self = self, self.isRunning else { return }
self.stop()
self.onError?("audio_interrupted", "Audio capture was interrupted (phone call or another app)")
}
}
}
@objc private func handleEngineConfigurationChange(_ notification: Notification) {
guard isRunning else { return }
DispatchQueue.main.async { [weak self] in
guard let self = self, self.isRunning else { return }
self.stop()
self.onError?("audio_config_changed", "Audio input configuration changed during capture")
}
}
}