import AudioMatrix import AVFoundation import Foundation import MusubiCore import NDIKit import Observation /// App-side glue for the audio matrix: NDI audio receivers, meters, talk key and stats. @MainActor @Observable final class AudioController { let matrix = AudioMatrix() let talkKey = TalkKey() /// Smoothed peak meters in dBFS (route ID → level), updated 20× per second. private(set) var meters: [UUID: Float] = [:] private(set) var channelMeters: [String: Float] = [:] // "#" private(set) var dropouts: [UUID: Int] = [:] /// Highest peak per route since the last stats line. private(set) var minutePeaks: [UUID: Float] = [:] var selectedDeviceUID: String? /// Called after routes or their settings changed (for autosave and scenes). var onChange: (() -> Void)? /// Talk key pressed or released (also used by the intercom talkback). var onTalkChange: ((Bool) -> Void)? private var ndiReceivers: [String: NDIReceiver] = [:] private var meterTimer: Timer? private var diagnosticsTimer: Timer? private let recorder = AudioStatsRecorder() init() { selectedDeviceUID = Self.preferredOutput(in: matrix.outputDevices)?.uid talkKey.onChange = { [weak self] active in self?.matrix.talkActive = active self?.onTalkChange?(active) } talkKey.register() meterTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in MainActor.assumeIsolated { self?.updateMeters() } } diagnosticsTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in MainActor.assumeIsolated { self?.logDiagnostics() } } } var selectedDevice: AudioDevice? { matrix.outputDevices.first { $0.uid == selectedDeviceUID } ?? Self.preferredOutput(in: matrix.outputDevices) } func add(_ source: AudioMatrix.SourceRef, channel: Int) { guard let device = selectedDevice else { return } if case .input = source { AVCaptureDevice.requestAccess(for: .audio) { _ in } } matrix.addRoute(source, deviceUID: device.uid, channel: channel) syncNDIReceivers() onChange?() } func remove(_ id: UUID) { matrix.removeRoute(id) syncNDIReceivers() onChange?() } func setGain(_ id: UUID, db: Float) { matrix.setGain(id, db: db) onChange?() } func setMuted(_ id: UUID, _ muted: Bool) { matrix.setMuted(id, muted) onChange?() } func setSelection(_ id: UUID, _ selection: ChannelSelection) { matrix.setSelection(id, selection) onChange?() } // MARK: - Scenes /// The current routes as a scene configuration. func currentAudio() -> [AudioAssignment] { matrix.routes.map(assignment(for:)) } /// Replaces all routes: keeps identical ones (no gap), updates settings, adds and removes the rest. /// Devices are matched by UID, then by name; missing devices start as soon as they are plugged in. func apply(_ audio: [AudioAssignment]) { let desired = Dictionary(audio.map(resolve).map { ($0.key, $0) }, uniquingKeysWith: { first, _ in first }) let current = Dictionary(matrix.routes.map { (assignment(for: $0).key, $0) }, uniquingKeysWith: { first, _ in first }) for (key, route) in current where desired[key] == nil { matrix.removeRoute(route.id) } for (key, assignment) in desired { let id: UUID if let existing = current[key] { id = existing.id } else { let source: AudioMatrix.SourceRef = switch assignment.source { case .ndi(let name): .ndi(name: name) case .input(let uid, _): .input(deviceUID: uid) } id = matrix.addRoute(source, deviceUID: assignment.deviceUID, channel: assignment.channel, selection: assignment.selection) } matrix.setSelection(id, assignment.selection) matrix.setGain(id, db: assignment.gainDB) matrix.setMuted(id, assignment.muted) } if let first = audio.first { selectedDeviceUID = resolve(first).deviceUID } syncNDIReceivers() onChange?() } private func assignment(for route: AudioMatrix.Route) -> AudioAssignment { let source: AudioAssignment.SourceSpec = switch route.source { case .ndi(let name): .ndi(name: name) case .input(let uid): .input(uid: uid, name: matrix.devices.first { $0.uid == uid }?.name ?? uid) } let deviceName = matrix.devices.first { $0.uid == route.deviceUID }?.name ?? route.deviceUID return AudioAssignment(source: source, deviceUID: route.deviceUID, deviceName: deviceName, channel: route.channel, selection: route.selection, gainDB: route.gainDB, muted: route.muted) } private func resolve(_ assignment: AudioAssignment) -> AudioAssignment { var resolved = assignment if !matrix.devices.contains(where: { $0.uid == assignment.deviceUID }), let byName = matrix.outputDevices.first(where: { $0.name == assignment.deviceName }) { resolved.deviceUID = byName.uid } if case .input(let uid, let name) = assignment.source, !matrix.devices.contains(where: { $0.uid == uid }), let byName = matrix.inputDevices.first(where: { $0.name == name }) { resolved.source = .input(uid: byName.uid, name: name) } return resolved } func label(for source: AudioMatrix.SourceRef) -> String { switch source { case .ndi(let name): Source.defaultLabel(for: name) case .input(let uid): matrix.devices.first { $0.uid == uid }?.name ?? uid } } /// One audio-only NDI receiver per NDI source that has routes. private func syncNDIReceivers() { let wanted = Set(matrix.ndiFanouts.keys) for name in ndiReceivers.keys where !wanted.contains(name) { ndiReceivers.removeValue(forKey: name)?.stop() } for name in wanted where ndiReceivers[name] == nil { guard let fanout = matrix.ndiFanouts[name] else { continue } ndiReceivers[name] = NDIReceiver( source: Source(ndiName: name), receiverName: "Musubi Ton", bandwidth: .audioOnly, onAudio: { chunk in fanout.push(planar: chunk.data, channelStride: chunk.channelStride, channels: chunk.channels, frames: chunk.frames, sampleRate: chunk.sampleRate) } ) Log.audio.info("NDI audio receiver for \(name, privacy: .public)") } } private func updateMeters() { let fall: Float = 1.2 // dB per tick for route in matrix.routes { let peak = matrix.takePeak(route.id) meters[route.id] = max(peak, (meters[route.id] ?? Level.floorDB) - fall) minutePeaks[route.id] = max(peak, minutePeaks[route.id] ?? Level.floorDB) dropouts[route.id] = matrix.dropouts(route.id) } meters = meters.filter { id, _ in matrix.routes.contains { $0.id == id } } if let device = selectedDevice { for channel in 0.. NDIReceiver.Snapshot? { ndiReceivers[name]?.snapshot } /// MOTU first, then any multi-channel interface, then the system default. private static func preferredOutput(in devices: [AudioDevice]) -> AudioDevice? { devices.first { $0.name.localizedCaseInsensitiveContains("MOTU") || $0.name.localizedCaseInsensitiveContains("M2") } ?? devices.first { $0.name.localizedCaseInsensitiveContains("USB") } ?? devices.first } } /// Appends one CSV line per audio route and minute to ~/Library/Logs/Musubi/audio-.csv. @MainActor final class AudioStatsRecorder { private var lastWrite = Date() private let directory = FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/Logs/Musubi") func recordIfDue(controller: AudioController) { let now = Date() guard now.timeIntervalSince(lastWrite) >= 60 else { return } lastWrite = now let routes = controller.matrix.routes guard !routes.isEmpty else { return } var lines = "" for route in routes { let source: String = switch route.source { case .ndi(let name): "ndi:\(name)" case .input(let uid): "input:\(uid)" } var rate = 0 if case .ndi(let name) = route.source { rate = controller.ndiAudioInfo(name)?.audioSampleRate ?? 0 } let peak = controller.minutePeaks[route.id] ?? Level.floorDB lines += "\(now.ISO8601Format()),\"\(source)\",\"\(route.deviceUID)\",\(route.channel + 1),\(rate),\(controller.dropouts[route.id] ?? 0),\(String(format: "%.1f", peak)),\(route.gainDB),\(route.muted)\n" } controller.resetMinutePeaks() let file = directory.appending(path: "audio-\(now.formatted(.iso8601.year().month().day())).csv") do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) if !FileManager.default.fileExists(atPath: file.path) { try "time,source,device,channel,source_rate,dropouts,peak_dbfs,gain_db,muted\n".write(to: file, atomically: true, encoding: .utf8) } let handle = try FileHandle(forWritingTo: file) defer { try? handle.close() } try handle.seekToEnd() try handle.write(contentsOf: Data(lines.utf8)) } catch { Log.audio.error("Audio CSV: \(error.localizedDescription, privacy: .public)") } } }