Skip to content

Commit 539eca4

Browse files
joeblauclaude
andauthored
feat: banner when a Bluetooth mic pairs + fix silent BT mic level meter (#38)
Two related pieces of Bluetooth-mic UX in the streaming flow. Banner: AudioInputProvider now diffs the Bluetooth input set on each route change and fires onBluetoothConnected only for devices that *arrive* mid-session (already-connected devices at launch just seed the baseline, so no false positive). The provider is hoisted from SettingsView into ContentView so its AVAudioSession.routeChangeNotification observer runs for the whole app lifetime, not just while the Settings sheet is open; both screens now share one instance. A transient glass banner slides in on the main streaming screen ("<name> connected"), stacked under the LIVE pill, and auto-dismisses after 3s. Fix: the Settings mic-level meter read silent for a Bluetooth mic even when the engine was correctly routed to it (confirmed on device: route=[DJI Mic 3/ BluetoothHFP] 16kHz, yet flat). Two causes: - A tap alone does not reliably pull a Bluetooth HFP input — the engine only renders its input when the graph drives an output. The input node is now routed through the main mixer (output muted) to force a full I/O cycle so the tap receives real samples. - There was no AVAudioEngineConfigurationChange observer, so if the HFP link settled asynchronously after setPreferredInput the tap stayed bound to the stale (built-in) format and went silent. Tap installation is split into a reusable installMeterTap() that the new observer re-runs on reconfiguration; the observer is torn down in stopLocalCapture. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6b6e999 commit 539eca4

3 files changed

Lines changed: 183 additions & 21 deletions

File tree

Stream/AudioInputProvider.swift

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ final class AudioInputProvider {
3838
/// the new route. Not fired for a manual `refresh()` (the caller already knows).
3939
var onInputsChanged: (() -> Void)?
4040

41+
/// Fired when a Bluetooth audio input appears that wasn't present on the
42+
/// previous enumeration — i.e. a device paired/connected mid-session. Carries
43+
/// the new device's display name so the UI can surface a "<name> connected"
44+
/// banner. Deliberately NOT fired for devices already connected when monitoring
45+
/// starts (the first `refresh()` only seeds the baseline), only for arrivals.
46+
var onBluetoothConnected: ((String) -> Void)?
47+
48+
/// UIDs of the Bluetooth inputs seen on the most recent enumeration. A later
49+
/// route change diffs against this to tell which device is *newly* connected
50+
/// (fire the banner) versus one that was already present (stay quiet).
51+
private var knownBluetoothUIDs: Set<String> = []
52+
4153
/// Observer token for `AVAudioSession.routeChangeNotification`. Marked
4254
/// `nonisolated(unsafe)` so `deinit` (which is nonisolated on a `@MainActor`
4355
/// type) can remove it; an `NSObjectProtocol` token is safe to touch there.
@@ -117,7 +129,7 @@ final class AudioInputProvider {
117129
switch reason {
118130
case .newDeviceAvailable, .oldDeviceUnavailable:
119131
guard permission == .granted else { return }
120-
configureAndEnumerate()
132+
configureAndEnumerate(announceNewBluetooth: true)
121133
onInputsChanged?()
122134
default:
123135
break
@@ -126,7 +138,10 @@ final class AudioInputProvider {
126138

127139
// MARK: - Private
128140

129-
private func configureAndEnumerate() {
141+
/// - Parameter announceNewBluetooth: when true, fire `onBluetoothConnected`
142+
/// for each Bluetooth input not seen on the previous enumeration. False for
143+
/// the initial `refresh()`, which only seeds the baseline set.
144+
private func configureAndEnumerate(announceNewBluetooth: Bool = false) {
130145
let session = AVAudioSession.sharedInstance()
131146
// Setting the category is sufficient to enumerate Bluetooth inputs. Do
132147
// not activate merely for enumeration; activation is asynchronous on
@@ -139,10 +154,10 @@ final class AudioInputProvider {
139154
continue
140155
}
141156
}
142-
enumerate(from: session)
157+
enumerate(from: session, announceNewBluetooth: announceNewBluetooth)
143158
}
144159

145-
private func enumerate(from session: AVAudioSession) {
160+
private func enumerate(from session: AVAudioSession, announceNewBluetooth: Bool) {
146161
let available = session.availableInputs ?? []
147162
inputs = available.map { port in
148163
let isBT = port.portType == .bluetoothHFP || port.portType == .bluetoothLE
@@ -152,6 +167,15 @@ final class AudioInputProvider {
152167
isBluetooth: isBT
153168
)
154169
}
170+
// Diff the Bluetooth set against the previous enumeration so a device that
171+
// paired mid-session surfaces once; already-connected devices stay quiet.
172+
let bluetooth = inputs.filter(\.isBluetooth)
173+
if announceNewBluetooth {
174+
for input in bluetooth where !knownBluetoothUIDs.contains(input.uid) {
175+
onBluetoothConnected?(input.displayName)
176+
}
177+
}
178+
knownBluetoothUIDs = Set(bluetooth.map(\.uid))
155179
}
156180

157181
/// Activates a record-capable session that surfaces Bluetooth inputs.

Stream/ContentView.swift

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import SwiftUI
22
import StreamCore
33

4+
/// A Bluetooth audio device that paired mid-session, backing the transient
5+
/// "connected" banner. The `id` is fresh per arrival so replacing the banner
6+
/// restarts its dismiss timer even when the same device reconnects.
7+
private struct BluetoothConnection: Equatable, Identifiable {
8+
let id = UUID()
9+
let name: String
10+
}
11+
412
/// Root view. The main page shows the live chat feed; the toolbar carries a "Live"
513
/// recording button (top right) that starts/stops capture and a gear button that
614
/// presents every setting — connection, video, backup, audio, and chat — in a
@@ -18,6 +26,17 @@ struct ContentView: View {
1826
/// sheet's chat section so both observe one connection.
1927
@State private var chat = RestreamChat()
2028

29+
/// Audio input helper, owned here so its `AVAudioSession` route-change monitor
30+
/// runs for the whole app lifetime. Drives the Bluetooth-connect banner below
31+
/// and is threaded into the Settings sheet so both share one instance/observer.
32+
@State private var audio = AudioInputProvider()
33+
34+
/// The Bluetooth device that most recently paired mid-session, shown as a
35+
/// transient banner. Set from `audio.onBluetoothConnected`; auto-cleared after
36+
/// a few seconds by a `.task` keyed on its `id` (a fresh `id` restarts the
37+
/// timer, so a second device replaces the banner cleanly).
38+
@State private var bluetoothBanner: BluetoothConnection?
39+
2140
@State private var showingSettings = false
2241

2342
/// Gates the "Stop Broadcast" confirmation. A live stream is easy to kill by a
@@ -59,9 +78,16 @@ struct ContentView: View {
5978
if capture.isLive { micFAB }
6079
}
6180
.overlay(alignment: .top) {
62-
if capture.isLive { livePill }
81+
VStack(spacing: 8) {
82+
if capture.isLive { livePill }
83+
if let banner = bluetoothBanner {
84+
bluetoothBannerView(name: banner.name)
85+
}
86+
}
87+
.padding(.top, 8)
6388
}
6489
.animation(.spring(duration: 0.3, bounce: 0.2), value: capture.isLive)
90+
.animation(.spring(duration: 0.35, bounce: 0.25), value: bluetoothBanner)
6591
.navigationTitle("Stream")
6692
.navigationBarTitleDisplayMode(.inline)
6793
.toolbar {
@@ -83,7 +109,27 @@ struct ContentView: View {
83109
}
84110
.sheet(isPresented: $showingSettings) { settingsSheet }
85111
}
86-
.task { chat.autoConnect() }
112+
.task {
113+
chat.autoConnect()
114+
// Surface a banner when a Bluetooth audio device pairs mid-session.
115+
audio.onBluetoothConnected = { name in
116+
bluetoothBanner = BluetoothConnection(name: name)
117+
Haptics.tap()
118+
}
119+
// Seed the baseline set + start the route-change observer without
120+
// prompting for mic access here (Settings owns the permission ask).
121+
// Already-connected devices only populate the baseline; they don't
122+
// trigger a banner — only devices that arrive afterward do.
123+
audio.refresh(requestPermission: false)
124+
}
125+
// Auto-dismiss the Bluetooth banner. Keyed on the connection id so a new
126+
// device restarts the timer; cancellation (id change) skips the stale clear.
127+
.task(id: bluetoothBanner?.id) {
128+
guard bluetoothBanner != nil else { return }
129+
try? await Task.sleep(for: .seconds(3))
130+
guard !Task.isCancelled else { return }
131+
bluetoothBanner = nil
132+
}
87133
.confirmationDialog(
88134
"Stop the broadcast?",
89135
isPresented: $showingStopConfirmation,
@@ -170,7 +216,6 @@ struct ContentView: View {
170216
.padding(.horizontal, 12)
171217
.padding(.vertical, 7)
172218
.glassEffect(.regular, in: .capsule)
173-
.padding(.top, 8)
174219
.transition(.scale.combined(with: .opacity))
175220
// Purely informational: never intercept taps/scroll on the chat beneath it.
176221
.allowsHitTesting(false)
@@ -179,6 +224,29 @@ struct ContentView: View {
179224
.accessibilityElement(children: .combine)
180225
}
181226

227+
// MARK: - Bluetooth-connected banner
228+
229+
/// Transient glass capsule announcing a Bluetooth audio device that just
230+
/// paired. Sits below the LIVE pill (same top overlay stack) and auto-dismisses
231+
/// via the `.task(id:)` timer; purely informational, so it never eats taps.
232+
private func bluetoothBannerView(name: String) -> some View {
233+
HStack(spacing: 8) {
234+
Image(systemName: "wave.3.right.circle.fill")
235+
.font(.system(size: 15, weight: .semibold))
236+
.foregroundStyle(.blue)
237+
Text("\(name) connected")
238+
.font(.caption.weight(.semibold))
239+
.lineLimit(1)
240+
}
241+
.padding(.horizontal, 12)
242+
.padding(.vertical, 7)
243+
.glassEffect(.regular, in: .capsule)
244+
.transition(.move(edge: .top).combined(with: .opacity))
245+
.allowsHitTesting(false)
246+
.accessibilityElement(children: .combine)
247+
.accessibilityLabel("\(name) connected")
248+
}
249+
182250
// MARK: - Setup banner
183251

184252
/// Slim call-to-action shown when the connection isn't ready to publish. Tapping
@@ -224,7 +292,7 @@ struct ContentView: View {
224292
/// the sheet to the exact height of whatever content is on screen (measured
225293
/// from the scroll view's real content size), resizing as sections are pushed.
226294
private var settingsSheet: some View {
227-
SettingsView(settings: $settings, chat: chat, capture: capture, onChange: persist)
295+
SettingsView(settings: $settings, chat: chat, capture: capture, onChange: persist, audio: audio)
228296
}
229297
}
230298

Stream/SettingsView.swift

Lines changed: 83 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ struct SettingsView: View {
6666
var onChange: () -> Void
6767

6868
/// Audio input enumeration helper (AVAudioSession-backed, Simulator-safe).
69-
@State private var audio = AudioInputProvider()
69+
/// Owned by the root view so route-change monitoring (and the Bluetooth-connect
70+
/// banner it drives) runs continuously, not only while this sheet is open.
71+
var audio: AudioInputProvider
7072

7173
/// Camera permission + device-capability helper for the facecam.
7274
@State private var camera = CameraSupport()
@@ -889,6 +891,13 @@ private final class MicrophoneLevelMonitor {
889891
@ObservationIgnored private var lastBroadcastCheckAt: UInt64 = 0
890892
@ObservationIgnored private var broadcastIsLive = false
891893

894+
/// Observer for `AVAudioEngineConfigurationChange`. The engine posts it when its
895+
/// I/O format changes underneath the running graph — most importantly when a
896+
/// Bluetooth HFP link finishes its asynchronous handoff after `setPreferredInput`.
897+
/// The tap was installed at the pre-switch format, so without rebuilding it on
898+
/// this notification the meter goes silent once the route lands on the BT mic.
899+
@ObservationIgnored private nonisolated(unsafe) var configChangeObserver: NSObjectProtocol?
900+
892901
func start() {
893902
guard task == nil else { return }
894903
task = Task { [weak self] in
@@ -994,9 +1003,45 @@ private final class MicrophoneLevelMonitor {
9941003
// (the DJI once it's the preferred input) and gives real PCM buffers, unlike
9951004
// the /dev/null AVAudioRecorder metering trick which can read nothing.
9961005
let engine = AVAudioEngine()
1006+
let input = engine.inputNode
1007+
let inputFormat = input.inputFormat(forBus: 0)
1008+
guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else { return }
1009+
// A tap alone does NOT reliably pull a Bluetooth HFP input — the engine only
1010+
// renders its input when the graph drives an output, so an unconnected
1011+
// input node hands the tap silent buffers (the "DJI selected, meter flat"
1012+
// bug). Route input → main mixer to force a full I/O cycle, and mute the
1013+
// mixer output so nothing is monitored back to the speaker/HFP earpiece.
1014+
engine.connect(input, to: engine.mainMixerNode, format: inputFormat)
1015+
engine.mainMixerNode.outputVolume = 0
1016+
guard installMeterTap(on: engine) else { return }
1017+
engine.prepare()
1018+
do {
1019+
try engine.start()
1020+
} catch {
1021+
engine.inputNode.removeTap(onBus: 0)
1022+
return
1023+
}
1024+
self.engine = engine
1025+
1026+
// A Bluetooth HFP link settles asynchronously AFTER setPreferredInput, so the
1027+
// format above may still be the built-in mic's. Rebuild the tap on the new
1028+
// format when the engine reconfigures, else the BT meter reads flat forever.
1029+
observeConfigChanges(of: engine)
1030+
1031+
let route = session.currentRoute.inputs
1032+
.map { "\($0.portName)/\($0.portType.rawValue)" }
1033+
.joined(separator: ",")
1034+
let format = engine.inputNode.inputFormat(forBus: 0)
1035+
Self.log.info("Mic meter started: route=[\(route, privacy: .public)] format=\(format.sampleRate, privacy: .public)Hz/\(format.channelCount, privacy: .public)ch pref=\(self.preferredInputUID ?? "nil", privacy: .public)")
1036+
}
1037+
1038+
/// Reads the input node's CURRENT format and installs the metering tap. Split out
1039+
/// so it can be re-run on a configuration change (when the live route/format has
1040+
/// changed). Returns false when the route has no usable input yet.
1041+
private func installMeterTap(on engine: AVAudioEngine) -> Bool {
9971042
let input = engine.inputNode
9981043
let format = input.inputFormat(forBus: 0)
999-
guard format.sampleRate > 0, format.channelCount > 0 else { return }
1044+
guard format.sampleRate > 0, format.channelCount > 0 else { return false }
10001045
let level = meterLevel
10011046
// @Sendable so the tap runs on the audio render thread — WITHOUT it the
10021047
// closure inherits this @MainActor class's isolation and iOS crashes with
@@ -1016,21 +1061,42 @@ private final class MicrophoneLevelMonitor {
10161061
level.withLock { $0 = rms }
10171062
}
10181063
} catch {
1019-
return
1064+
return false
10201065
}
1021-
engine.prepare()
1022-
do {
1023-
try engine.start()
1024-
} catch {
1025-
input.removeTap(onBus: 0)
1026-
return
1066+
return true
1067+
}
1068+
1069+
/// Registers the configuration-change observer for `engine`, replacing any prior
1070+
/// one. Fires on the main queue; hops back onto the actor to rebuild the tap.
1071+
private func observeConfigChanges(of engine: AVAudioEngine) {
1072+
if let configChangeObserver {
1073+
NotificationCenter.default.removeObserver(configChangeObserver)
10271074
}
1028-
self.engine = engine
1075+
configChangeObserver = NotificationCenter.default.addObserver(
1076+
forName: .AVAudioEngineConfigurationChange,
1077+
object: engine,
1078+
queue: .main
1079+
) { [weak self] _ in
1080+
Task { @MainActor in self?.handleEngineConfigChange() }
1081+
}
1082+
}
10291083

1030-
let route = session.currentRoute.inputs
1084+
/// The engine's I/O reconfigured (typically the BT route finishing its handoff).
1085+
/// Reinstall the tap at the new input format and make sure the engine is running;
1086+
/// a config change stops the engine, and the old tap's format no longer matches.
1087+
private func handleEngineConfigChange() {
1088+
guard let engine else { return }
1089+
engine.inputNode.removeTap(onBus: 0)
1090+
guard installMeterTap(on: engine) else { return }
1091+
if !engine.isRunning {
1092+
engine.prepare()
1093+
try? engine.start()
1094+
}
1095+
let route = AVAudioSession.sharedInstance().currentRoute.inputs
10311096
.map { "\($0.portName)/\($0.portType.rawValue)" }
10321097
.joined(separator: ",")
1033-
Self.log.info("Mic meter started: route=[\(route, privacy: .public)] format=\(format.sampleRate, privacy: .public)Hz/\(format.channelCount, privacy: .public)ch pref=\(self.preferredInputUID ?? "nil", privacy: .public)")
1098+
let format = engine.inputNode.inputFormat(forBus: 0)
1099+
Self.log.info("Mic meter reconfigured: route=[\(route, privacy: .public)] format=\(format.sampleRate, privacy: .public)Hz/\(format.channelCount, privacy: .public)ch")
10341100
}
10351101

10361102
private func stopLocalCapture() {
@@ -1039,6 +1105,10 @@ private final class MicrophoneLevelMonitor {
10391105
// audio server and interrupting ScreenCaptureKit's live mic session.
10401106
// Deactivate exactly once, on the real handover.
10411107
guard let engine else { return }
1108+
if let configChangeObserver {
1109+
NotificationCenter.default.removeObserver(configChangeObserver)
1110+
self.configChangeObserver = nil
1111+
}
10421112
engine.inputNode.removeTap(onBus: 0)
10431113
engine.stop()
10441114
self.engine = nil
@@ -1159,6 +1229,6 @@ private struct LiveStatsCard: View {
11591229
@Previewable @State var settings = StreamSettings.default
11601230
return NavigationStack {
11611231
SettingsView(settings: $settings, chat: RestreamChat(),
1162-
capture: ScreenCaptureController(), onChange: {})
1232+
capture: ScreenCaptureController(), onChange: {}, audio: AudioInputProvider())
11631233
}
11641234
}

0 commit comments

Comments
 (0)