oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import os
3
4#if os(macOS)
5 import AppKit
6#else
7 import UIKit
8#endif
9
10// "Copy audio diagnostics": what the app knows about its audio, as text a
11// person can paste into a message. Added for 0.3.8, when Paul's Test audio
12// showed an empty meter for his AirPods and nothing on screen said whether
13// no audio was arriving at all or silent audio was (2026-09-26).
14//
15// It holds names, device UIDs, formats, engine state, the audio log's own
16// lines and the meters' levels in dBFS. It never holds audio.
17
18/// The subsystem Paul's `log show` reads:
19/// `log show --last 1h --predicate 'subsystem == "net.amadan.oficina.calls" && category == "audio"' --info`.
20/// Every line is also kept in memory (the last 200), for the diagnostics.
21enum AudioLog {
22 static let logger = Logger(subsystem: "net.amadan.oficina.calls", category: "audio")
23
24 static func info(_ message: String) {
25 logger.info("\(message, privacy: .public)")
26 AudioDiagnosticsStore.shared.log("info", message)
27 }
28
29 static func error(_ message: String) {
30 logger.error("\(message, privacy: .public)")
31 AudioDiagnosticsStore.shared.log("error", message)
32 }
33}
34
35/// The recent audio log lines and meter readings. Written from any thread
36/// (never per buffer: the audio thread writes here only on a capture hook
37/// initialise, and on the first buffer after one), read when copied.
38public final class AudioDiagnosticsStore: @unchecked Sendable {
39 public static let shared = AudioDiagnosticsStore()
40
41 public struct Level: Equatable, Sendable {
42 /// Uptime seconds.
43 public var at: Double
44 /// Peak before the suppressor, 0...1, and buffers behind it.
45 public var input: Float
46 public var inputBuffers: Int
47 /// Peak (or RMS, where only that was read) after it: what is sent.
48 public var output: Float
49 public var outputBuffers: Int
50 public var source: String
51 }
52
53 private struct State {
54 var lines: [String] = []
55 var levels: [Level] = []
56 }
57 private let lock = OSAllocatedUnfairLock(initialState: State())
58 static let lineLimit = 200
59 static let levelLimit = 100
60
61 init() {}
62
63 func log(_ kind: String, _ message: String) {
64 let line = "\(AudioDiagnosticsStore.stamp(Date())) \(kind) \(message)"
65 lock.withLock { s in
66 s.lines.append(line)
67 if s.lines.count > AudioDiagnosticsStore.lineLimit {
68 s.lines.removeFirst(s.lines.count - AudioDiagnosticsStore.lineLimit)
69 }
70 }
71 }
72
73 /// One meter reading. The self-test and the call each record theirs
74 /// (10 Hz); only the last ten seconds are kept.
75 public func record(_ level: Level) {
76 lock.withLock { s in
77 s.levels.append(level)
78 if s.levels.count > AudioDiagnosticsStore.levelLimit {
79 s.levels.removeFirst(s.levels.count - AudioDiagnosticsStore.levelLimit)
80 }
81 }
82 }
83
84 public func lines(last n: Int) -> [String] {
85 lock.withLock { s in Array(s.lines.suffix(n)) }
86 }
87
88 public func levels() -> [Level] { lock.withLock { $0.levels } }
89
90 /// The level history as lines: one per reading, in dBFS, with the
91 /// buffers behind each ("no buffers" says nothing arrived).
92 public static func describe(_ levels: [Level], since now: Double) -> [String] {
93 levels.map { l in
94 let age = String(format: "%5.1f", l.at - now)
95 func side(_ value: Float, _ buffers: Int) -> String {
96 buffers == 0 ? "no buffers" : "\(String(format: "%6.1f", MeterScale.decibels(value))) dBFS (\(buffers) buf)"
97 }
98 return "\(age) s \(l.source) in \(side(l.input, l.inputBuffers)) sent \(side(l.output, l.outputBuffers))"
99 }
100 }
101
102 static func stamp(_ date: Date) -> String {
103 let formatter = DateFormatter()
104 formatter.locale = Locale(identifier: "en_US_POSIX")
105 formatter.dateFormat = "HH:mm:ss.SSS"
106 return formatter.string(from: date)
107 }
108}
109
110/// The text "Copy audio diagnostics" puts on the clipboard.
111enum AudioDiagnosticsReport {
112 /// - Parameters:
113 /// - context: where it was copied from ("Test audio before a call").
114 /// - sections: titled blocks of lines, from whatever holds the audio.
115 static func make(context: String, sections: [(String, [String])], now: Double = CaptureDenoiser.now())
116 -> String
117 {
118 let info = Bundle.main.infoDictionary
119 let version = info?["CFBundleShortVersionString"] as? String ?? "dev"
120 let build = info?["CFBundleVersion"] as? String ?? "dev"
121 let os = ProcessInfo.processInfo.operatingSystemVersionString
122 var out = [
123 "Calls diagnostics — \(context)",
124 "Calls \(version) (\(build)), \(os), \(ISO8601DateFormatter().string(from: Date()))",
125 "No audio or pictures are included: names, formats, states, counts and levels only.",
126 ]
127 for (title, lines) in sections where !lines.isEmpty {
128 out.append("")
129 out.append("## \(title)")
130 out.append(contentsOf: lines)
131 }
132 let store = AudioDiagnosticsStore.shared
133 out.append("")
134 out.append("## Level history (newest last)")
135 let levels = store.levels()
136 out.append(contentsOf: levels.isEmpty ? ["(none measured)"] : AudioDiagnosticsStore.describe(levels, since: now))
137 out.append("")
138 out.append("## Audio log (last 50 lines)")
139 let lines = store.lines(last: 50)
140 out.append(contentsOf: lines.isEmpty ? ["(nothing logged)"] : lines)
141 return out.joined(separator: "\n") + "\n"
142 }
143
144 @MainActor static func copy(_ text: String) {
145 #if os(macOS)
146 NSPasteboard.general.clearContents()
147 NSPasteboard.general.setString(text, forType: .string)
148 #else
149 UIPasteboard.general.string = text
150 #endif
151 }
152}
153