import Foundation import os #if os(macOS) import AppKit #else import UIKit #endif // "Copy audio diagnostics": what the app knows about its audio, as text a // person can paste into a message. Added for 0.3.8, when Paul's Test audio // showed an empty meter for his AirPods and nothing on screen said whether // no audio was arriving at all or silent audio was (2026-09-26). // // It holds names, device UIDs, formats, engine state, the audio log's own // lines and the meters' levels in dBFS. It never holds audio. /// The subsystem Paul's `log show` reads: /// `log show --last 1h --predicate 'subsystem == "net.amadan.oficina.calls" && category == "audio"' --info`. /// Every line is also kept in memory (the last 200), for the diagnostics. enum AudioLog { static let logger = Logger(subsystem: "net.amadan.oficina.calls", category: "audio") static func info(_ message: String) { logger.info("\(message, privacy: .public)") AudioDiagnosticsStore.shared.log("info", message) } static func error(_ message: String) { logger.error("\(message, privacy: .public)") AudioDiagnosticsStore.shared.log("error", message) } } /// The recent audio log lines and meter readings. Written from any thread /// (never per buffer: the audio thread writes here only on a capture hook /// initialise, and on the first buffer after one), read when copied. public final class AudioDiagnosticsStore: @unchecked Sendable { public static let shared = AudioDiagnosticsStore() public struct Level: Equatable, Sendable { /// Uptime seconds. public var at: Double /// Peak before the suppressor, 0...1, and buffers behind it. public var input: Float public var inputBuffers: Int /// Peak (or RMS, where only that was read) after it: what is sent. public var output: Float public var outputBuffers: Int public var source: String } private struct State { var lines: [String] = [] var levels: [Level] = [] } private let lock = OSAllocatedUnfairLock(initialState: State()) static let lineLimit = 200 static let levelLimit = 100 init() {} func log(_ kind: String, _ message: String) { let line = "\(AudioDiagnosticsStore.stamp(Date())) \(kind) \(message)" lock.withLock { s in s.lines.append(line) if s.lines.count > AudioDiagnosticsStore.lineLimit { s.lines.removeFirst(s.lines.count - AudioDiagnosticsStore.lineLimit) } } } /// One meter reading. The self-test and the call each record theirs /// (10 Hz); only the last ten seconds are kept. public func record(_ level: Level) { lock.withLock { s in s.levels.append(level) if s.levels.count > AudioDiagnosticsStore.levelLimit { s.levels.removeFirst(s.levels.count - AudioDiagnosticsStore.levelLimit) } } } public func lines(last n: Int) -> [String] { lock.withLock { s in Array(s.lines.suffix(n)) } } public func levels() -> [Level] { lock.withLock { $0.levels } } /// The level history as lines: one per reading, in dBFS, with the /// buffers behind each ("no buffers" says nothing arrived). public static func describe(_ levels: [Level], since now: Double) -> [String] { levels.map { l in let age = String(format: "%5.1f", l.at - now) func side(_ value: Float, _ buffers: Int) -> String { buffers == 0 ? "no buffers" : "\(String(format: "%6.1f", MeterScale.decibels(value))) dBFS (\(buffers) buf)" } return "\(age) s \(l.source) in \(side(l.input, l.inputBuffers)) sent \(side(l.output, l.outputBuffers))" } } static func stamp(_ date: Date) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "HH:mm:ss.SSS" return formatter.string(from: date) } } /// The text "Copy audio diagnostics" puts on the clipboard. enum AudioDiagnosticsReport { /// - Parameters: /// - context: where it was copied from ("Test audio before a call"). /// - sections: titled blocks of lines, from whatever holds the audio. static func make(context: String, sections: [(String, [String])], now: Double = CaptureDenoiser.now()) -> String { let info = Bundle.main.infoDictionary let version = info?["CFBundleShortVersionString"] as? String ?? "dev" let build = info?["CFBundleVersion"] as? String ?? "dev" let os = ProcessInfo.processInfo.operatingSystemVersionString var out = [ "Calls diagnostics — \(context)", "Calls \(version) (\(build)), \(os), \(ISO8601DateFormatter().string(from: Date()))", "No audio or pictures are included: names, formats, states, counts and levels only.", ] for (title, lines) in sections where !lines.isEmpty { out.append("") out.append("## \(title)") out.append(contentsOf: lines) } let store = AudioDiagnosticsStore.shared out.append("") out.append("## Level history (newest last)") let levels = store.levels() out.append(contentsOf: levels.isEmpty ? ["(none measured)"] : AudioDiagnosticsStore.describe(levels, since: now)) out.append("") out.append("## Audio log (last 50 lines)") let lines = store.lines(last: 50) out.append(contentsOf: lines.isEmpty ? ["(nothing logged)"] : lines) return out.joined(separator: "\n") + "\n" } @MainActor static func copy(_ text: String) { #if os(macOS) NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) #else UIPasteboard.general.string = text #endif } }