You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Musubi/App/Sources/IntercomController.swift

156 lines
5.5 KiB

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..<frames { mono[i] = open ? stereo[i * 2] : 0 }
mono.withUnsafeBufferPointer { input.pushTalkback(UnsafeBufferPointer(rebasing: $0[0..<frames]), sampleRate: rate) }
}
}

Powered by TurnKey Linux.