import AudioMatrix import Foundation import Intercom import MusubiCore import NDIKit import Observation /// App-side glue for the intercom (control only): server on the clinic interface, /// PGM preview from an NDI proxy stream, talkback from the headset mic gated by the talk key. @MainActor @Observable final class IntercomController { let server = IntercomServer() /// NDI source shown on the phones (usually PGM). var previewSourceName: String { didSet { UserDefaults.standard.set(previewSourceName, forKey: "intercomPreviewSource") startPreview() } } /// Input device whose signal the phones hear while the talk key is down. var talkbackDeviceUID: String { didSet { UserDefaults.standard.set(talkbackDeviceUID, forKey: "talkbackDeviceUID") startTalkback() } } private let settings: AppSettings private let audio: AudioController private var preview: NDIReceiver? private var tap: (feed: AudioFeed, deviceUID: String)? private var pump: TalkbackPump? init(settings: AppSettings, audio: AudioController) { self.settings = settings self.audio = audio previewSourceName = UserDefaults.standard.string(forKey: "intercomPreviewSource") ?? "" talkbackDeviceUID = UserDefaults.standard.string(forKey: "talkbackDeviceUID") ?? "" } var isEnabled: Bool { settings.role == .control && !settings.clinicInterfaceName.isEmpty } /// Technical and clinic network on the same interface is fine at home, never on site. var sharesTechnicalInterface: Bool { settings.clinicInterfaceName == settings.interfaceName } /// (Re)starts or stops everything according to role and clinic interface; call after settings changes. func restart() async { stopAll() guard isEnabled else { return } await server.start(interfaceName: settings.clinicInterfaceName) startPreview() startTalkback() } func setTalkActive(_ active: Bool) { pump?.gateOpen = active } private func stopAll() { server.stop() preview?.stop() preview = nil stopTalkback() } private func startPreview() { preview?.stop() preview = nil guard isEnabled, !previewSourceName.isEmpty else { return } let input = server.input preview = NDIReceiver(source: Source(ndiName: previewSourceName), receiverName: "Musubi Intercom", bandwidth: .lowest, onFrame: { input.pushVideo($0) }) Log.intercom.info("Preview from \(self.previewSourceName, privacy: .public)") } private func startTalkback() { stopTalkback() guard isEnabled else { return } let uid = talkbackDeviceUID.isEmpty ? defaultTalkbackDevice() : talkbackDeviceUID guard let uid, audio.matrix.inputDevices.contains(where: { $0.uid == uid }) else { return } let feed = audio.matrix.addInputTap(deviceUID: uid) tap = (feed, uid) let pump = TalkbackPump(feed: feed, input: server.input) pump.gateOpen = audio.talkKey.isActive pump.start() self.pump = pump } private func stopTalkback() { pump?.stop() pump = nil if let tap { audio.matrix.removeInputTap(tap.feed, deviceUID: tap.deviceUID) } tap = nil } /// The mic already routed in the sound matrix, else the system's default input. func defaultTalkbackDevice() -> String? { for route in audio.matrix.routes { if case .input(let uid) = route.source { return uid } } return AudioDevices.defaultInput()?.uid ?? audio.matrix.inputDevices.first?.uid } /// Name of the device "Automatisch" currently resolves to. var automaticTalkbackName: String? { defaultTalkbackDevice().flatMap { uid in audio.matrix.inputDevices.first { $0.uid == uid }?.name } } } /// Moves mic audio from the tap to the Opus encoder every 10 ms on its own queue. /// Sends silence while the talk key is up, so the phones' audio clock keeps running. private final class TalkbackPump: @unchecked Sendable { private let feed: AudioFeed private let input: IntercomInput private let queue = DispatchQueue(label: "studio.zielke.musubi.intercom.talkback", qos: .userInitiated) private var timer: DispatchSourceTimer? private let lock = NSLock() private var open = false private var stereo = [Float](repeating: 0, count: 4096 * 2) private var mono = [Float](repeating: 0, count: 4096) init(feed: AudioFeed, input: IntercomInput) { self.feed = feed self.input = input } var gateOpen: Bool { get { lock.withLock { open } } set { lock.withLock { open = newValue } } } func start() { let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now(), repeating: .milliseconds(10), leeway: .milliseconds(2)) timer.setEventHandler { [weak self] in self?.drain() } timer.resume() self.timer = timer } func stop() { timer?.cancel() timer = nil } private func drain() { let rate = feed.currentSampleRate let frames = stereo.withUnsafeMutableBufferPointer { feed.read(into: $0.baseAddress!, frames: 4096) } guard frames > 0, rate > 0 else { return } let open = gateOpen for i in 0..