|
|
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] = [:] // "<deviceUID>#<channel>"
|
|
|
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..<device.outputChannels {
|
|
|
let key = "\(device.uid)#\(channel)"
|
|
|
let peak = matrix.takeChannelPeak(deviceUID: device.uid, channel: channel)
|
|
|
channelMeters[key] = max(peak, (channelMeters[key] ?? Level.floorDB) - fall)
|
|
|
}
|
|
|
}
|
|
|
recorder.recordIfDue(controller: self)
|
|
|
}
|
|
|
|
|
|
private func logDiagnostics() {
|
|
|
for route in matrix.routes {
|
|
|
guard let d = matrix.diagnostics(route.id) else { continue }
|
|
|
Log.audio.info("\(self.label(for: route.source), privacy: .public): buffered \(d.bufferedFrames) underruns \(d.underruns) overruns \(d.overruns) correction \(String(format: "%+.0f ppm", d.correction * 1_000_000), privacy: .public)")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
func resetMinutePeaks() {
|
|
|
minutePeaks.removeAll()
|
|
|
}
|
|
|
|
|
|
/// Applies `--audio-route` launch arguments.
|
|
|
func applyLaunchRoutes(_ arguments: [String] = ProcessInfo.processInfo.arguments) {
|
|
|
for route in AudioRouteArgument.parse(arguments) {
|
|
|
guard let device = matrix.outputDevices.first(where: { $0.name.localizedCaseInsensitiveContains(route.device) }),
|
|
|
route.channel <= device.outputChannels
|
|
|
else {
|
|
|
Log.audio.error("--audio-route: no output \(route.device, privacy: .public) with channel \(route.channel)")
|
|
|
continue
|
|
|
}
|
|
|
let source: AudioMatrix.SourceRef
|
|
|
switch route.kind {
|
|
|
case .ndi:
|
|
|
source = .ndi(name: route.source)
|
|
|
case .input:
|
|
|
guard let input = matrix.inputDevices.first(where: { $0.name.localizedCaseInsensitiveContains(route.source) }) else {
|
|
|
Log.audio.error("--audio-route: no input \(route.source, privacy: .public)")
|
|
|
continue
|
|
|
}
|
|
|
source = .input(deviceUID: input.uid)
|
|
|
}
|
|
|
selectedDeviceUID = device.uid
|
|
|
add(source, channel: route.channel - 1)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
func ndiAudioInfo(_ name: String) -> 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-<date>.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)")
|
|
|
}
|
|
|
}
|
|
|
}
|