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? 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 } 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() } func remove(_ id: UUID) { matrix.removeRoute(id) syncNDIReceivers() } 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)") } } }