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/AudioController.swift

190 lines
8.2 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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?
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..<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)")
}
}
}

Powered by TurnKey Linux.