Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions Stream/AudioInputProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<name> 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<String> = []

/// 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.
Expand Down Expand Up @@ -117,7 +129,7 @@ final class AudioInputProvider {
switch reason {
case .newDeviceAvailable, .oldDeviceUnavailable:
guard permission == .granted else { return }
configureAndEnumerate()
configureAndEnumerate(announceNewBluetooth: true)
onInputsChanged?()
default:
break
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down
76 changes: 72 additions & 4 deletions Stream/ContentView.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down
96 changes: 83 additions & 13 deletions Stream/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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() {
Expand All @@ -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
Expand Down Expand Up @@ -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())
}
}
Loading