@@ -17,10 +17,53 @@ final class AudioInputProvider {
1717
1818 /// A selectable audio input surfaced from the audio session.
1919 struct Input : Identifiable , Hashable {
20+ /// How the input is attached. Drives the picker icon and whether a
21+ /// mid-session arrival is worth announcing.
22+ enum Kind : Hashable {
23+ case builtIn
24+ case bluetooth
25+ /// Wired external: a USB-C audio interface (the DJI/Rode-style wireless
26+ /// receivers land here), a headset mic, or line-in.
27+ case wired
28+ }
29+
2030 let uid : String
2131 let displayName : String
22- let isBluetooth : Bool
32+ let kind : Kind
2333 var id : String { uid }
34+
35+ var isBluetooth : Bool { kind == . bluetooth }
36+
37+ /// Anything that isn't the device's own mic — the inputs worth flagging in
38+ /// the picker and announcing when they appear mid-session.
39+ var isExternal : Bool { kind != . builtIn }
40+
41+ var icon : String {
42+ switch kind {
43+ case . builtIn: " iphone "
44+ case . bluetooth: " wave.3.right.circle.fill "
45+ case . wired: " cable.connector "
46+ }
47+ }
48+
49+ /// Compact "name[kind]" for the route log — the fastest way to confirm
50+ /// whether a plugged-in receiver actually reached the audio session.
51+ var debugLabel : String { " \( displayName) [ \( kind) ] " }
52+ }
53+
54+ /// Classifies a port. Unknown/new port types are treated as wired rather than
55+ /// built-in: a port type we don't recognize is far more likely to be an
56+ /// accessory someone just plugged in than the device's own microphone, and
57+ /// guessing that way means a new class of USB-C receiver still gets flagged.
58+ private static func kind( for portType: AVAudioSession . Port ) -> Input . Kind {
59+ switch portType {
60+ case . builtInMic:
61+ return . builtIn
62+ case . bluetoothHFP, . bluetoothLE, . bluetoothA2DP:
63+ return . bluetooth
64+ default :
65+ return . wired
66+ }
2467 }
2568
2669 /// Microphone permission state, mirrored for the UI.
@@ -41,17 +84,20 @@ final class AudioInputProvider {
4184 /// the new route. Not fired for a manual `refresh()` (the caller already knows).
4285 var onInputsChanged : ( ( ) -> Void ) ?
4386
44- /// Fired when a Bluetooth audio input appears that wasn't present on the
45- /// previous enumeration — i.e. a device paired/connected mid-session. Carries
46- /// the new device's display name so the UI can surface a "<name> connected"
47- /// banner. Deliberately NOT fired for devices already connected when monitoring
48- /// starts (the first `refresh()` only seeds the baseline), only for arrivals.
49- var onBluetoothConnected : ( ( String ) -> Void ) ?
87+ /// Fired when an external audio input appears that wasn't present on the
88+ /// previous enumeration — a Bluetooth device that paired, or a USB-C receiver
89+ /// that was plugged in, mid-session. Carries the input so the UI can surface a
90+ /// "<name> connected" banner with the right icon. Deliberately NOT fired for
91+ /// devices already connected when monitoring starts (the first `refresh()` only
92+ /// seeds the baseline), only for arrivals.
93+ var onExternalMicConnected : ( ( Input ) -> Void ) ?
5094
51- /// UIDs of the Bluetooth inputs seen on the most recent enumeration. A later
95+ /// UIDs of the external inputs seen on the most recent enumeration. A later
5296 /// route change diffs against this to tell which device is *newly* connected
5397 /// (fire the banner) versus one that was already present (stay quiet).
54- private var knownBluetoothUIDs : Set < String > = [ ]
98+ private var knownExternalUIDs : Set < String > = [ ]
99+
100+ private static let log = Logger ( subsystem: " com.joeblau.Stream " , category: " audio-inputs " )
55101
56102 /// Observer token for `AVAudioSession.routeChangeNotification`. Marked
57103 /// `nonisolated(unsafe)` so `deinit` (which is nonisolated on a `@MainActor`
@@ -129,49 +175,49 @@ final class AudioInputProvider {
129175 switch reason {
130176 case . newDeviceAvailable, . oldDeviceUnavailable:
131177 guard permission == . granted else { return }
132- configureAndEnumerate ( announceNewBluetooth : true , notifyInputsChanged: true )
178+ configureAndEnumerate ( announceNewExternal : true , notifyInputsChanged: true )
133179 default :
134180 break
135181 }
136182 }
137183
138184 // MARK: - Private
139185
140- /// - Parameter announceNewBluetooth : when true, fire `onBluetoothConnected `
186+ /// - Parameter announceNewExternal : when true, fire `onExternalMicConnected `
141187 /// for each Bluetooth input not seen on the previous enumeration. False for
142188 /// the initial `refresh()`, which only seeds the baseline set.
143- private func configureAndEnumerate( announceNewBluetooth : Bool = false ,
189+ private func configureAndEnumerate( announceNewExternal : Bool = false ,
144190 notifyInputsChanged: Bool = false ) {
145191 Task { [ weak self] in
146192 // Category negotiation may consult media-server synchronously. Keep it
147193 // off the UI actor and ordered with meter/broadcast session handoffs.
148194 await AudioSessionCoordinator . shared. prepareForEnumeration ( )
149195 guard let self else { return }
150196 enumerate ( from: AVAudioSession . sharedInstance ( ) ,
151- announceNewBluetooth : announceNewBluetooth )
197+ announceNewExternal : announceNewExternal )
152198 if notifyInputsChanged { onInputsChanged ? ( ) }
153199 }
154200 }
155201
156- private func enumerate( from session: AVAudioSession , announceNewBluetooth : Bool ) {
202+ private func enumerate( from session: AVAudioSession , announceNewExternal : Bool ) {
157203 let available = session. availableInputs ?? [ ]
158204 inputs = available. map { port in
159- let isBT = port. portType == . bluetoothHFP || port. portType == . bluetoothLE
160- return Input (
205+ Input (
161206 uid: port. uid,
162207 displayName: port. portName,
163- isBluetooth : isBT
208+ kind : Self . kind ( for : port . portType )
164209 )
165210 }
166- // Diff the Bluetooth set against the previous enumeration so a device that
167- // paired mid-session surfaces once; already-connected devices stay quiet.
168- let bluetooth = inputs. filter ( \. isBluetooth)
169- if announceNewBluetooth {
170- for input in bluetooth where !knownBluetoothUIDs. contains ( input. uid) {
171- onBluetoothConnected ? ( input. displayName)
211+ Self . log. info ( " Inputs: \( self . inputs. map ( \. debugLabel) . joined ( separator: " , " ) , privacy: . public) " )
212+ // Diff the external set against the previous enumeration so a device that
213+ // arrived mid-session surfaces once; already-connected devices stay quiet.
214+ let external = inputs. filter ( \. isExternal)
215+ if announceNewExternal {
216+ for input in external where !knownExternalUIDs. contains ( input. uid) {
217+ onExternalMicConnected ? ( input)
172218 }
173219 }
174- knownBluetoothUIDs = Set ( bluetooth . map ( \. uid) )
220+ knownExternalUIDs = Set ( external . map ( \. uid) )
175221 }
176222
177223 /// Activates a record-capable session that surfaces Bluetooth inputs.
@@ -541,16 +587,29 @@ final class MicrophoneLevelMonitor {
541587 do {
542588 try input. __installTap ( onBus: 0 , bufferSize: 1024 , format: format, error: ( ) ) {
543589 @Sendable buffer, _ in
544- guard let channel = buffer. floatChannelData ? [ 0 ] else { return }
590+ guard let channels = buffer. floatChannelData else { return }
545591 let count = Int ( buffer. frameLength)
546592 guard count > 0 else { return }
547- var sumOfSquares : Float = 0
548- for i in 0 ..< count {
549- let sample = channel [ i]
550- sumOfSquares += sample * sample
593+ // Measure EVERY channel and keep the loudest, rather than reading
594+ // channel 0. A multi-track USB-C receiver (DJI/Rode) puts each
595+ // wireless transmitter on its own channel — TX1 left, TX2 right —
596+ // so a channel-0-only meter reads dead flat while the person is
597+ // talking into the transmitter mapped to the right channel. The
598+ // live ScreenCaptureKit meter already sums the whole buffer list;
599+ // this keeps the pre-flight meter honest in the same way.
600+ var loudest : Float = 0
601+ for channel in 0 ..< Int ( buffer. format. channelCount) {
602+ let samples = channels [ channel]
603+ var sumOfSquares : Float = 0
604+ for i in 0 ..< count {
605+ let sample = samples [ i]
606+ sumOfSquares += sample * sample
607+ }
608+ loudest = max ( loudest, ( sumOfSquares / Float( count) ) . squareRoot ( ) )
551609 }
552- let rms = ( sumOfSquares / Float( count) ) . squareRoot ( )
553- level. withLock { $0 = rms }
610+ // `withLock`'s closure is @Sendable, which cannot capture a var.
611+ let peak = loudest
612+ level. withLock { $0 = peak }
554613 }
555614 } catch {
556615 return false
0 commit comments