import Foundation // BroadcastAudioPump โ€” host side of iOS screen-audio sharing (docs/voice.md ยง9, iOS detail). // // Drains the App Group shared-memory ring written by the broadcast upload extension and hands // whole 20 ms frames to a feed closure (which calls VoiceCatClient.feedPcm). The host app owns // the SCREEN_AUDIO stream, so screen audio appears as a second stream of the SAME user โ€” exactly // like the Windows/macOS desktop-audio share, and a single session (no separate connection). // // It reacts to the extension's Darwin notifications for prompt start/stop, and the drain timer // also watches the ring's active flag as a safety net if a notification is missed. The ring // always carries stereo; we downmix to mono when the stream's effective config is mono. // // Not @MainActor: the drain runs on a background queue (feedPcm is thread-safe). The start/stop // callbacks are dispatched to main so they can drive @MainActor SessionState. final class BroadcastAudioPump { /// The host should start the SCREEN_AUDIO stream (a broadcast became active). var onBroadcastStarted: (() -> Void)? /// The host should stop the SCREEN_AUDIO stream (the broadcast ended). var onBroadcastFinished: (() -> Void)? private static let frameSamplesPerChannel = 960 // 20 ms @ 48 kHz private let queue = DispatchQueue(label: "cat.voice.broadcast.pump") private var ring: BroadcastAudioRing? private var timer: DispatchSourceTimer? private var feed: ((UnsafePointer, Int, UInt32) -> Void)? private var streamChannels = 1 private var ringChannels = 2 private var pending: [Int16] = [] // interleaved at ringChannels width private var scratch = [Int16](repeating: 0, count: 8192) private var started = false // MARK: - Lifecycle (host connect/disconnect) func start() { guard !started else { return } ring = try? BroadcastAudioRing() started = true registerDarwin() // Host reconnected while a broadcast is still running โ€” pick it up. if ring?.isActive == true { onBroadcastStarted?() } } func stop() { guard started else { return } started = false unregisterDarwin() endFeeding() ring = nil } // MARK: - Feeding (driven by the host once the stream is live) /// Begin draining the ring into `feed`. Called after the SCREEN_AUDIO stream's /// `.streamStarted` event, when its effective channel count is known. func beginFeeding(streamChannels: UInt32, feed: @escaping (UnsafePointer, Int, UInt32) -> Void) { queue.async { self.streamChannels = max(1, min(2, Int(streamChannels))) self.ringChannels = max(1, Int(self.ring?.channels ?? 2)) self.feed = feed self.pending.removeAll(keepingCapacity: true) self.ring?.drainStale() // discard pre-roll buffered before we were ready self.startTimer() } } func endFeeding() { queue.async { self.timer?.cancel() self.timer = nil self.feed = nil self.pending.removeAll(keepingCapacity: true) } } // MARK: - Drain private func startTimer() { let t = DispatchSource.makeTimerSource(queue: queue) t.schedule(deadline: .now(), repeating: .milliseconds(10), leeway: .milliseconds(2)) t.setEventHandler { [weak self] in self?.drain() } timer = t t.resume() } private func drain() { guard let ring, let feed else { return } // Safety net: broadcast ended but we missed the Darwin note. if !ring.isActive { DispatchQueue.main.async { [weak self] in self?.onBroadcastFinished?() } return } while true { let got = scratch.withUnsafeMutableBufferPointer { ring.read(into: $0) } if got == 0 { break } pending.append(contentsOf: scratch[0.., Int, UInt32) -> Void) { let n = Self.frameSamplesPerChannel let rc = ringChannels let sc = streamChannels let inFrame = n * rc while pending.count >= inFrame { if sc == rc { pending.withUnsafeBufferPointer { feed($0.baseAddress!, n, UInt32(sc)) } } else if sc == 1 && rc == 2 { var mono = [Int16](repeating: 0, count: n) pending.withUnsafeBufferPointer { buf in let p = buf.baseAddress! for i in 0...fromOpaque(observer).takeUnretainedValue() let raw = name.rawValue as String DispatchQueue.main.async { if raw == BroadcastNotification.started { pump.onBroadcastStarted?() } else if raw == BroadcastNotification.finished { pump.onBroadcastFinished?() } } } CFNotificationCenterAddObserver(center, observer, callback, BroadcastNotification.started as CFString, nil, .deliverImmediately) CFNotificationCenterAddObserver(center, observer, callback, BroadcastNotification.finished as CFString, nil, .deliverImmediately) } private func unregisterDarwin() { CFNotificationCenterRemoveEveryObserver( CFNotificationCenterGetDarwinNotifyCenter(), Unmanaged.passUnretained(self).toOpaque()) } deinit { if started { unregisterDarwin() } } }