From 3c7ece3d0c99f1bb91ebeec8484a2093e3b9eccb Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Thu, 9 Jul 2026 06:01:09 -0700 Subject: [PATCH] feat: banner when a Bluetooth mic pairs + fix silent BT mic level meter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (" 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) --- Stream/AudioInputProvider.swift | 32 +++++++++-- Stream/ContentView.swift | 76 ++++++++++++++++++++++++-- Stream/SettingsView.swift | 96 ++++++++++++++++++++++++++++----- 3 files changed, 183 insertions(+), 21 deletions(-) diff --git a/Stream/AudioInputProvider.swift b/Stream/AudioInputProvider.swift index 9b71b69..8aea91f 100644 --- a/Stream/AudioInputProvider.swift +++ b/Stream/AudioInputProvider.swift @@ -38,6 +38,18 @@ final class AudioInputProvider { /// the new route. Not fired for a manual `refresh()` (the caller already knows). var onInputsChanged: (() -> Void)? + /// Fired when a Bluetooth audio input appears that wasn't present on the + /// previous enumeration — i.e. a device paired/connected mid-session. Carries + /// the new device's display name so the UI can surface a " connected" + /// banner. Deliberately NOT fired for devices already connected when monitoring + /// starts (the first `refresh()` only seeds the baseline), only for arrivals. + var onBluetoothConnected: ((String) -> Void)? + + /// UIDs of the Bluetooth inputs seen on the most recent enumeration. A later + /// route change diffs against this to tell which device is *newly* connected + /// (fire the banner) versus one that was already present (stay quiet). + private var knownBluetoothUIDs: Set = [] + /// Observer token for `AVAudioSession.routeChangeNotification`. Marked /// `nonisolated(unsafe)` so `deinit` (which is nonisolated on a `@MainActor` /// type) can remove it; an `NSObjectProtocol` token is safe to touch there. @@ -117,7 +129,7 @@ final class AudioInputProvider { switch reason { case .newDeviceAvailable, .oldDeviceUnavailable: guard permission == .granted else { return } - configureAndEnumerate() + configureAndEnumerate(announceNewBluetooth: true) onInputsChanged?() default: break @@ -126,7 +138,10 @@ final class AudioInputProvider { // MARK: - Private - private func configureAndEnumerate() { + /// - Parameter announceNewBluetooth: when true, fire `onBluetoothConnected` + /// for each Bluetooth input not seen on the previous enumeration. False for + /// the initial `refresh()`, which only seeds the baseline set. + private func configureAndEnumerate(announceNewBluetooth: Bool = false) { let session = AVAudioSession.sharedInstance() // Setting the category is sufficient to enumerate Bluetooth inputs. Do // not activate merely for enumeration; activation is asynchronous on @@ -139,10 +154,10 @@ final class AudioInputProvider { continue } } - enumerate(from: session) + enumerate(from: session, announceNewBluetooth: announceNewBluetooth) } - private func enumerate(from session: AVAudioSession) { + private func enumerate(from session: AVAudioSession, announceNewBluetooth: Bool) { let available = session.availableInputs ?? [] inputs = available.map { port in let isBT = port.portType == .bluetoothHFP || port.portType == .bluetoothLE @@ -152,6 +167,15 @@ final class AudioInputProvider { isBluetooth: isBT ) } + // Diff the Bluetooth set against the previous enumeration so a device that + // paired mid-session surfaces once; already-connected devices stay quiet. + let bluetooth = inputs.filter(\.isBluetooth) + if announceNewBluetooth { + for input in bluetooth where !knownBluetoothUIDs.contains(input.uid) { + onBluetoothConnected?(input.displayName) + } + } + knownBluetoothUIDs = Set(bluetooth.map(\.uid)) } /// Activates a record-capable session that surfaces Bluetooth inputs. diff --git a/Stream/ContentView.swift b/Stream/ContentView.swift index 287e4a9..eb62a3f 100644 --- a/Stream/ContentView.swift +++ b/Stream/ContentView.swift @@ -1,6 +1,14 @@ import SwiftUI import StreamCore +/// A Bluetooth audio device that paired mid-session, backing the transient +/// "connected" banner. The `id` is fresh per arrival so replacing the banner +/// restarts its dismiss timer even when the same device reconnects. +private struct BluetoothConnection: Equatable, Identifiable { + let id = UUID() + let name: String +} + /// Root view. The main page shows the live chat feed; the toolbar carries a "Live" /// recording button (top right) that starts/stops capture and a gear button that /// presents every setting — connection, video, backup, audio, and chat — in a @@ -18,6 +26,17 @@ struct ContentView: View { /// sheet's chat section so both observe one connection. @State private var chat = RestreamChat() + /// Audio input helper, owned here so its `AVAudioSession` route-change monitor + /// runs for the whole app lifetime. Drives the Bluetooth-connect banner below + /// and is threaded into the Settings sheet so both share one instance/observer. + @State private var audio = AudioInputProvider() + + /// The Bluetooth device that most recently paired mid-session, shown as a + /// transient banner. Set from `audio.onBluetoothConnected`; auto-cleared after + /// a few seconds by a `.task` keyed on its `id` (a fresh `id` restarts the + /// timer, so a second device replaces the banner cleanly). + @State private var bluetoothBanner: BluetoothConnection? + @State private var showingSettings = false /// Gates the "Stop Broadcast" confirmation. A live stream is easy to kill by a @@ -59,9 +78,16 @@ struct ContentView: View { if capture.isLive { micFAB } } .overlay(alignment: .top) { - if capture.isLive { livePill } + VStack(spacing: 8) { + if capture.isLive { livePill } + if let banner = bluetoothBanner { + bluetoothBannerView(name: banner.name) + } + } + .padding(.top, 8) } .animation(.spring(duration: 0.3, bounce: 0.2), value: capture.isLive) + .animation(.spring(duration: 0.35, bounce: 0.25), value: bluetoothBanner) .navigationTitle("Stream") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -83,7 +109,27 @@ struct ContentView: View { } .sheet(isPresented: $showingSettings) { settingsSheet } } - .task { chat.autoConnect() } + .task { + chat.autoConnect() + // Surface a banner when a Bluetooth audio device pairs mid-session. + audio.onBluetoothConnected = { name in + bluetoothBanner = BluetoothConnection(name: name) + Haptics.tap() + } + // Seed the baseline set + start the route-change observer without + // prompting for mic access here (Settings owns the permission ask). + // Already-connected devices only populate the baseline; they don't + // trigger a banner — only devices that arrive afterward do. + audio.refresh(requestPermission: false) + } + // Auto-dismiss the Bluetooth banner. Keyed on the connection id so a new + // device restarts the timer; cancellation (id change) skips the stale clear. + .task(id: bluetoothBanner?.id) { + guard bluetoothBanner != nil else { return } + try? await Task.sleep(for: .seconds(3)) + guard !Task.isCancelled else { return } + bluetoothBanner = nil + } .confirmationDialog( "Stop the broadcast?", isPresented: $showingStopConfirmation, @@ -170,7 +216,6 @@ struct ContentView: View { .padding(.horizontal, 12) .padding(.vertical, 7) .glassEffect(.regular, in: .capsule) - .padding(.top, 8) .transition(.scale.combined(with: .opacity)) // Purely informational: never intercept taps/scroll on the chat beneath it. .allowsHitTesting(false) @@ -179,6 +224,29 @@ struct ContentView: View { .accessibilityElement(children: .combine) } + // MARK: - Bluetooth-connected banner + + /// Transient glass capsule announcing a Bluetooth audio device that just + /// paired. Sits below the LIVE pill (same top overlay stack) and auto-dismisses + /// via the `.task(id:)` timer; purely informational, so it never eats taps. + private func bluetoothBannerView(name: String) -> some View { + HStack(spacing: 8) { + Image(systemName: "wave.3.right.circle.fill") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.blue) + Text("\(name) connected") + .font(.caption.weight(.semibold)) + .lineLimit(1) + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .glassEffect(.regular, in: .capsule) + .transition(.move(edge: .top).combined(with: .opacity)) + .allowsHitTesting(false) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(name) connected") + } + // MARK: - Setup banner /// Slim call-to-action shown when the connection isn't ready to publish. Tapping @@ -224,7 +292,7 @@ struct ContentView: View { /// the sheet to the exact height of whatever content is on screen (measured /// from the scroll view's real content size), resizing as sections are pushed. private var settingsSheet: some View { - SettingsView(settings: $settings, chat: chat, capture: capture, onChange: persist) + SettingsView(settings: $settings, chat: chat, capture: capture, onChange: persist, audio: audio) } } diff --git a/Stream/SettingsView.swift b/Stream/SettingsView.swift index ee1a2e2..b86d96a 100644 --- a/Stream/SettingsView.swift +++ b/Stream/SettingsView.swift @@ -66,7 +66,9 @@ struct SettingsView: View { var onChange: () -> Void /// Audio input enumeration helper (AVAudioSession-backed, Simulator-safe). - @State private var audio = AudioInputProvider() + /// Owned by the root view so route-change monitoring (and the Bluetooth-connect + /// banner it drives) runs continuously, not only while this sheet is open. + var audio: AudioInputProvider /// Camera permission + device-capability helper for the facecam. @State private var camera = CameraSupport() @@ -889,6 +891,13 @@ private final class MicrophoneLevelMonitor { @ObservationIgnored private var lastBroadcastCheckAt: UInt64 = 0 @ObservationIgnored private var broadcastIsLive = false + /// Observer for `AVAudioEngineConfigurationChange`. The engine posts it when its + /// I/O format changes underneath the running graph — most importantly when a + /// Bluetooth HFP link finishes its asynchronous handoff after `setPreferredInput`. + /// The tap was installed at the pre-switch format, so without rebuilding it on + /// this notification the meter goes silent once the route lands on the BT mic. + @ObservationIgnored private nonisolated(unsafe) var configChangeObserver: NSObjectProtocol? + func start() { guard task == nil else { return } task = Task { [weak self] in @@ -994,9 +1003,45 @@ private final class MicrophoneLevelMonitor { // (the DJI once it's the preferred input) and gives real PCM buffers, unlike // the /dev/null AVAudioRecorder metering trick which can read nothing. let engine = AVAudioEngine() + let input = engine.inputNode + let inputFormat = input.inputFormat(forBus: 0) + guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else { return } + // A tap alone does NOT reliably pull a Bluetooth HFP input — the engine only + // renders its input when the graph drives an output, so an unconnected + // input node hands the tap silent buffers (the "DJI selected, meter flat" + // bug). Route input → main mixer to force a full I/O cycle, and mute the + // mixer output so nothing is monitored back to the speaker/HFP earpiece. + engine.connect(input, to: engine.mainMixerNode, format: inputFormat) + engine.mainMixerNode.outputVolume = 0 + guard installMeterTap(on: engine) else { return } + engine.prepare() + do { + try engine.start() + } catch { + engine.inputNode.removeTap(onBus: 0) + return + } + self.engine = engine + + // A Bluetooth HFP link settles asynchronously AFTER setPreferredInput, so the + // format above may still be the built-in mic's. Rebuild the tap on the new + // format when the engine reconfigures, else the BT meter reads flat forever. + observeConfigChanges(of: engine) + + let route = session.currentRoute.inputs + .map { "\($0.portName)/\($0.portType.rawValue)" } + .joined(separator: ",") + let format = engine.inputNode.inputFormat(forBus: 0) + 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)") + } + + /// Reads the input node's CURRENT format and installs the metering tap. Split out + /// so it can be re-run on a configuration change (when the live route/format has + /// changed). Returns false when the route has no usable input yet. + private func installMeterTap(on engine: AVAudioEngine) -> Bool { let input = engine.inputNode let format = input.inputFormat(forBus: 0) - guard format.sampleRate > 0, format.channelCount > 0 else { return } + guard format.sampleRate > 0, format.channelCount > 0 else { return false } let level = meterLevel // @Sendable so the tap runs on the audio render thread — WITHOUT it the // closure inherits this @MainActor class's isolation and iOS crashes with @@ -1016,21 +1061,42 @@ private final class MicrophoneLevelMonitor { level.withLock { $0 = rms } } } catch { - return + return false } - engine.prepare() - do { - try engine.start() - } catch { - input.removeTap(onBus: 0) - return + return true + } + + /// Registers the configuration-change observer for `engine`, replacing any prior + /// one. Fires on the main queue; hops back onto the actor to rebuild the tap. + private func observeConfigChanges(of engine: AVAudioEngine) { + if let configChangeObserver { + NotificationCenter.default.removeObserver(configChangeObserver) } - self.engine = engine + configChangeObserver = NotificationCenter.default.addObserver( + forName: .AVAudioEngineConfigurationChange, + object: engine, + queue: .main + ) { [weak self] _ in + Task { @MainActor in self?.handleEngineConfigChange() } + } + } - let route = session.currentRoute.inputs + /// The engine's I/O reconfigured (typically the BT route finishing its handoff). + /// Reinstall the tap at the new input format and make sure the engine is running; + /// a config change stops the engine, and the old tap's format no longer matches. + private func handleEngineConfigChange() { + guard let engine else { return } + engine.inputNode.removeTap(onBus: 0) + guard installMeterTap(on: engine) else { return } + if !engine.isRunning { + engine.prepare() + try? engine.start() + } + let route = AVAudioSession.sharedInstance().currentRoute.inputs .map { "\($0.portName)/\($0.portType.rawValue)" } .joined(separator: ",") - 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)") + let format = engine.inputNode.inputFormat(forBus: 0) + Self.log.info("Mic meter reconfigured: route=[\(route, privacy: .public)] format=\(format.sampleRate, privacy: .public)Hz/\(format.channelCount, privacy: .public)ch") } private func stopLocalCapture() { @@ -1039,6 +1105,10 @@ private final class MicrophoneLevelMonitor { // audio server and interrupting ScreenCaptureKit's live mic session. // Deactivate exactly once, on the real handover. guard let engine else { return } + if let configChangeObserver { + NotificationCenter.default.removeObserver(configChangeObserver) + self.configChangeObserver = nil + } engine.inputNode.removeTap(onBus: 0) engine.stop() self.engine = nil @@ -1159,6 +1229,6 @@ private struct LiveStatsCard: View { @Previewable @State var settings = StreamSettings.default return NavigationStack { SettingsView(settings: $settings, chat: RestreamChat(), - capture: ScreenCaptureController(), onChange: {}) + capture: ScreenCaptureController(), onChange: {}, audio: AudioInputProvider()) } }