Skip to content

Commit ba34510

Browse files
authored
Merge pull request #42 from joeblau/feat/live-production-controls
feat: improve live camera and microphone controls
2 parents ccf4a86 + 70dbf71 commit ba34510

11 files changed

Lines changed: 1226 additions & 84 deletions

Stream/AudioInputProvider.swift

Lines changed: 90 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)