Merge branch 'fix/mic-not-picked-up' into main
Test audio opens the microphone on a real Mac (0.3.6 and 0.3.7 never did), tells no audio from silent audio and names the device and rate; Copy audio diagnostics; the input channel map strictly opt-in; chosen-mic rate watch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
19 files changed,
+1159
−82
internal/callstest/native_selftest_test.go+14 −5native/README.md+40 −1native/apple/Sources/CallsInterop/AudioSelfTestHarness.swift+35 −2native/apple/Sources/CallsKit/Audio.swift+164 −24native/apple/Sources/CallsKit/AudioDevices.swift+32 −9native/apple/Sources/CallsKit/AudioDiagnostics.swift+152 −0native/apple/Sources/CallsKit/AudioSelfTest.swift+16 −4native/apple/Sources/CallsKit/AudioSelfTestModel.swift+125 −6native/apple/Sources/CallsKit/AudioSelfTestView.swift+34 −1native/apple/Sources/CallsKit/Call.swift+28 −0native/apple/Sources/CallsKit/CallAudio.swift+170 −11native/apple/Sources/CallsKit/DevicePanel.swift+1 −0native/apple/Sources/CallsKit/InputChannel.swift+23 −4native/apple/Sources/CallsKit/NoiseSuppression.swift+39 −5native/apple/Tests/CallsKitTests/AudioRoutesTests.swift+69 −0native/apple/Tests/CallsKitTests/AudioSelfTestTests.swift+68 −9native/apple/Tests/CallsKitTests/InputChannelTests.swift+40 −0native/apple/Tests/CallsKitTests/NoiseSuppressionTests.swift+108 −0native/ios/project.yml+1 −1
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ok amadan.net/oficina/calls/callscore (cached) ? amadan.net/oficina/calls/cmd/calls [no test files] ok amadan.net/oficina/calls/internal/calls 1.387s ok amadan.net/oficina/calls/internal/calls/apns (cached) ok amadan.net/oficina/calls/internal/calls/hub (cached) ? amadan.net/oficina/calls/internal/calls/icons [no test files] ok amadan.net/oficina/calls/internal/calls/relay (cached) ok amadan.net/oficina/calls/internal/calls/seal (cached) ok amadan.net/oficina/calls/internal/callstest (cached) ? amadan.net/oficina/calls/protocol [no test files] === migration-check === go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree === race === CGO_ENABLED=1 go test -race ./internal/... ok amadan.net/oficina/calls/internal/calls 8.166s ok amadan.net/oficina/calls/internal/calls/apns (cached) ok amadan.net/oficina/calls/internal/calls/hub (cached) ? amadan.net/oficina/calls/internal/calls/icons [no test files] ok amadan.net/oficina/calls/internal/calls/relay (cached) ok amadan.net/oficina/calls/internal/calls/seal (cached) ok amadan.net/oficina/calls/internal/callstest (cached)
diff --git a/internal/callstest/native_selftest_test.go b/internal/callstest/native_selftest_test.go| index 37d5375..402f50f 100644 |
| --- a/internal/callstest/native_selftest_test.go |
| +++ b/internal/callstest/native_selftest_test.go |
| @@ -20,9 +20,14 @@ import ( |
| // server and no browser; it sits with the native gates because it runs |
| // the same CallsInterop build. |
| // |
| -// Three runs must each print PASS: noise reduction on, off, and a control |
| -// with a silent microphone that must be called silent. A run that prints |
| -// nothing is a failure, not a pass. |
| +// Five runs must each print PASS: noise reduction on, off, and a control |
| +// with a silent microphone that must be called silent; then the test's |
| +// start on the real device module (not manual rendering), which must ask |
| +// the module to record, and its control, 0.3.7's start, which never did. |
| +// Manual rendering runs the input whenever the output runs, so the first |
| +// three pass whether or not the test opens a microphone; the last two are |
| +// what would have caught the 0.3.6/0.3.7 test that never opened one. A run |
| +// that prints nothing is a failure, not a pass. |
| func TestNativeMacAudioSelfTest(t *testing.T) { |
| root, err := filepath.Abs("../..") |
| if err != nil { |
| @@ -47,10 +52,14 @@ func TestNativeMacAudioSelfTest(t *testing.T) { |
| for _, line := range append(passes, fails...) { |
| t.Log(line) |
| } |
| - if err != nil || len(fails) > 0 || len(passes) != 3 { |
| + if err != nil || len(fails) > 0 || len(passes) != 5 { |
| t.Fatalf("audio self-test: %d passed, %d failed, exit %v\n%s", len(passes), len(fails), err, out) |
| } |
| - for _, want := range []string{"(noise reduction on)", "(noise reduction off)", "control (a silent microphone)"} { |
| + for _, want := range []string{ |
| + "(noise reduction on)", "(noise reduction off)", "control (a silent microphone)", |
| + "control (0.3.7's start on a real device never opens the microphone)", |
| + "on a real device (asks the module to record)", |
| + } { |
| found := false |
| for _, line := range passes { |
| found = found || strings.Contains(line, want) |
diff --git a/native/README.md b/native/README.md| index d3dded6..a6cd8bc 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -237,7 +237,16 @@ The Mac's choices follow these rules (`AudioRoutes`, held by |
| connection in place (`InputChannel.swift`). Changing it mid-call takes the |
| module's input away and gives it back (`setEngineAvailability`), which |
| rewires the microphone with the new map — a moment's gap, as a device switch |
| - has. Input 1 on a device never mapped is left exactly as the module had it. |
| + has. Since 0.3.8 the map is strictly opt-in (`InputChannelMap.channel`): it is |
| + set only for an input other than the first, chosen on a microphone chosen by |
| + name that has more than one input. On the system default, or any ordinary |
| + microphone, the unit is left exactly as the module wired it — 0.3.3's |
| + capture — and the picker is not shown. |
| +- **A chosen microphone changing rate** (a Bluetooth headset moves to its |
| + hands-free 16 or 24 kHz the moment its microphone opens) is listened to, and |
| + a running engine wired at another rate is rebuilt (`RateRule`, at most once |
| + every three seconds). On the system default the module follows the device |
| + itself, as it always has. |
| **How the engine is put on a device** (`AudioDevices.swift`, 0.3.7). Up to |
| 0.3.6 the app chose through LiveKit's module (`inputDevice = …`). Paul's |
| @@ -276,6 +285,36 @@ engine (both sides taken away and given back), a moment's gap in both |
| directions. The device the unit is actually on is read back as the engine |
| starts, and is what the readout names. |
| +**Test audio** (home screen, and the device panel in a call) meters the |
| +capture hook — before noise reduction and after it — and records from the |
| +hook's sink. Before a call it builds a CallAudio as a call does and sends its |
| +microphone track between two in-process peer connections that are never |
| +connected, then asks the engine module to record (`initRecording`, |
| +`startRecording`: what WebRTC's first send stream calls). That last step is |
| +0.3.8's. WebRTC starts a send stream, and with it the microphone, only on a |
| +connection whose transport has been writable (`BaseChannel::IsReadyToSendMedia_w`, |
| +`was_ever_writable_`), so in 0.3.6 and 0.3.7 the test never opened the |
| +microphone on any Mac: an empty meter and "We can't hear anything from …" for |
| +every device (Paul's AirPods, 2026-09-26). The interop harness passed because |
| +in manual rendering the module runs its input whenever its output runs; it now |
| +also runs the start on the real device module, with 0.3.7's start as a control |
| +that must never ask for the microphone. Calls themselves were not affected: |
| +their connections are connected. |
| + |
| +The verdict names the device the microphone really is, with its connection |
| +and rate ("Paul's AirPods Pro (Bluetooth, 24 kHz)"), and tells apart "no sound |
| +is arriving" (no buffers at all: the microphone was asked for and nothing came |
| +back) from "sound is arriving, but it is silent". Bluetooth rates, which the |
| +suppressor passes through, are metered and recorded like any other. |
| + |
| +**Copy audio diagnostics**, in Test audio and in the panel's ⓘ, puts text on |
| +the clipboard to paste into a message: the devices chosen and what they |
| +resolve to, every CoreAudio device with its rate and transport, the engine |
| +module's state and last few engine events with their formats, the capture |
| +hook's rate and buffer counts, the processing report, the last ten seconds of |
| +meter readings in dBFS, and the last 50 lines of the audio log (which is also |
| +kept in memory, the last 200 lines). Never any audio. |
| + |
| Calls 0.2.1 (build 3) routed audio differently: its own CoreAudio audio |
| device (`CallsAudio`, one pair of HAL units per call) behind WebRTC's |
| custom-audio-device constructor. That constructor takes no audio processing |
diff --git a/native/apple/Sources/CallsInterop/AudioSelfTestHarness.swift b/native/apple/Sources/CallsInterop/AudioSelfTestHarness.swift| index d7d567d..88ff799 100644 |
| --- a/native/apple/Sources/CallsInterop/AudioSelfTestHarness.swift |
| +++ b/native/apple/Sources/CallsInterop/AudioSelfTestHarness.swift |
| @@ -17,8 +17,9 @@ import SwiftUI |
| /// gone afterwards. Run once with noise reduction on (whichever suppressor |
| /// the build has; DeepFilterNet with CALLS_DEEPFILTER_MODEL set) and once |
| /// with it off. Then a control, so a pass means something: a silent |
| -/// microphone must be called silent, and its recording silent. Prints one |
| -/// PASS or FAIL line per run; exits 0 only if all three passed. |
| +/// microphone must be called silent, and its recording silent. Then the |
| +/// start on a real device (deviceStart), with its own control. Prints one |
| +/// PASS or FAIL line per run; exits 0 only if all five passed. |
| @MainActor enum AudioSelfTestHarness { |
| /// -40 dBFS RMS. The synthetic voice is about -20 dBFS; silence is |
| /// below -90. |
| @@ -30,6 +31,38 @@ import SwiftUI |
| passed = await runOnce(reduceNoise: reduceNoise) && passed |
| } |
| passed = await silentControl() && passed |
| + passed = await deviceStart() && passed |
| + return passed |
| + } |
| + |
| + /// The test on the real audio device module, not manual rendering: it |
| + /// must ask the module to record. Manual rendering runs its input |
| + /// whenever its output runs, so the runs above pass whether or not the |
| + /// test ever opens a microphone; on a device it has to be asked, and up |
| + /// to 0.3.7 it never was (the loopback is never connected, and WebRTC |
| + /// opens the microphone only for a send stream on a connection that has |
| + /// been). This process has no microphone permission, so the module |
| + /// refuses right after being asked; being asked is what is checked. |
| + /// First the control, 0.3.7's start, which must come back not asked, |
| + /// so a pass means the ask is what made the difference. |
| + private static func deviceStart() async -> Bool { |
| + var passed = true |
| + let control = await AudioSelfTest.deviceStartProbe(askToRecord: false) |
| + let controlLabel = "audio self-test control (0.3.7's start on a real device never opens the microphone)" |
| + if control.asked { |
| + print("FAIL \(controlLabel): the module was asked to record anyway; \(control.detail)") |
| + passed = false |
| + } else { |
| + print("PASS \(controlLabel): \(control.detail)") |
| + } |
| + let fixed = await AudioSelfTest.deviceStartProbe(askToRecord: true) |
| + let label = "audio self-test on a real device (asks the module to record)" |
| + if fixed.asked { |
| + print("PASS \(label): \(fixed.detail)") |
| + } else { |
| + print("FAIL \(label): the microphone was never asked for; \(fixed.detail)") |
| + passed = false |
| + } |
| return passed |
| } |
diff --git a/native/apple/Sources/CallsKit/Audio.swift b/native/apple/Sources/CallsKit/Audio.swift| index 207b7b2..7e7bfe6 100644 |
| --- a/native/apple/Sources/CallsKit/Audio.swift |
| +++ b/native/apple/Sources/CallsKit/Audio.swift |
| @@ -91,6 +91,11 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| private var lastInventory: AudioInventory? |
| private var lastDecision = "" |
| private var listener: AudioObjectPropertyListenerBlock? |
| + /// The chosen microphone's nominal rate, listened to: a Bluetooth |
| + /// headset moves to its hands-free rate (16 or 24 kHz) the moment its |
| + /// microphone opens, under an engine wired at the old rate. |
| + private var rateWatch: (device: AudioObjectID, uid: String, block: AudioObjectPropertyListenerBlock)? |
| + private var lastRateRebuild = -Double.infinity |
| #endif |
| #if os(macOS) |
| @@ -130,8 +135,8 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| failed = [:] |
| } |
| lastInventory = inventory |
| - AudioLog.audio.info( |
| - "CoreAudio devices: \(inventory.devices.map(\.describe).joined(separator: "; "), privacy: .public); default in \(inventory.defaultInput ?? "none", privacy: .public), out \(inventory.defaultOutput ?? "none", privacy: .public)") |
| + AudioLog.info( |
| + "CoreAudio devices: \(inventory.devices.map(\.describe).joined(separator: "; ")); default in \(inventory.defaultInput ?? "none"), out \(inventory.defaultOutput ?? "none")") |
| control.logModuleLists() |
| } |
| let input = AudioRouteRules.resolve( |
| @@ -143,11 +148,11 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| let decision = "input \(input.current): \(input.reason); output \(output.current): \(output.reason)" |
| if decision != lastDecision { |
| lastDecision = decision |
| - AudioLog.audio.info("routes: \(decision, privacy: .public)") |
| + AudioLog.info("routes: \(decision)") |
| } |
| if control.pin(input.explicit, output.explicit) { |
| - AudioLog.audio.info( |
| - "routes: engine asked for input \(input.explicit ?? "default", privacy: .public), output \(output.explicit ?? "default", privacy: .public); rebuilding a running engine") |
| + AudioLog.info( |
| + "routes: engine asked for input \(input.explicit ?? "default"), output \(output.explicit ?? "default"); rebuilding a running engine") |
| control.rebuild() |
| } |
| update(\.routeNote, [input.note, output.note].compactMap { $0 }.joined(separator: " ")) |
| @@ -156,7 +161,8 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| update(\.currentInput, input.current) |
| update(\.currentOutput, output.current) |
| update(\.outputName, outputs.first { $0.id == currentOutput }?.name ?? "") |
| - readChannels(input.explicit ?? inventory.defaultInput) |
| + readChannels(input.explicit ?? inventory.defaultInput, explicit: input.explicit != nil) |
| + watchRate(of: input.explicit) |
| #endif |
| } |
| @@ -178,8 +184,8 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| return [head] + inventory.list(input).map { AudioDevice(id: $0.uid, name: $0.name, selectable: true) } |
| } |
| - func attach(_ module: RTCAudioDeviceModule, pin: AudioEnginePin) { |
| - attach(AudioEngineControl(module, pin: pin)) |
| + func attach(_ module: RTCAudioDeviceModule, pin: AudioEnginePin, inputRate: @escaping () -> Double?) { |
| + attach(AudioEngineControl(module, pin: pin, inputRate: inputRate)) |
| listenForDevices() |
| } |
| @@ -219,8 +225,52 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| func engineStarted(input uid: String?) { |
| guard uid != engineInput else { return } |
| engineInput = uid |
| - AudioLog.audio.info("engine running with input \(uid ?? "unknown", privacy: .public)") |
| - readChannels(currentInput == AudioRouteRules.defaultID ? lastInventory?.defaultInput : currentInput) |
| + AudioLog.info("engine running with input \(uid ?? "unknown")") |
| + let chosen = currentInput != AudioRouteRules.defaultID |
| + readChannels(chosen ? currentInput : lastInventory?.defaultInput, explicit: chosen) |
| + } |
| + |
| + /// Listens to the chosen microphone's sample rate (nil stops). With |
| + /// the system default the module follows the device itself, as it did |
| + /// in 0.3.3, and nothing is added. |
| + private func watchRate(of uid: String?) { |
| + guard uid != rateWatch?.uid else { return } |
| + if let old = rateWatch { |
| + var address = AudioRoutes.rateAddress |
| + AudioObjectRemovePropertyListenerBlock(old.device, &address, DispatchQueue.main, old.block) |
| + rateWatch = nil |
| + } |
| + guard let uid, let device = CoreAudioInput.device(uid: uid) else { return } |
| + let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in |
| + DispatchQueue.main.async { MainActor.assumeIsolated { self?.rateChanged(uid) } } |
| + } |
| + var address = AudioRoutes.rateAddress |
| + if AudioObjectAddPropertyListenerBlock(device, &address, DispatchQueue.main, block) == noErr { |
| + rateWatch = (device, uid, block) |
| + } |
| + } |
| + |
| + private static var rateAddress: AudioObjectPropertyAddress { |
| + AudioObjectPropertyAddress( |
| + mSelector: kAudioDevicePropertyNominalSampleRate, mScope: kAudioObjectPropertyScopeGlobal, |
| + mElement: kAudioObjectPropertyElementMain) |
| + } |
| + |
| + /// The chosen microphone changed rate. A running engine wired at |
| + /// another rate is rebuilt, so the microphone is wired in again at the |
| + /// new one (at most once every three seconds). |
| + private func rateChanged(_ uid: String) { |
| + guard let control, let device = CoreAudioInput.device(uid: uid) else { return } |
| + let rate = CoreAudioDevices.sampleRate(device) |
| + let now = CaptureDenoiser.now() |
| + let rebuild = RateRule.rebuild( |
| + deviceRate: rate, engineRate: control.inputRate(), recording: control.isRecording(), |
| + pair: control.pinned().isPair, lastRebuild: lastRateRebuild, now: now) |
| + AudioLog.info( |
| + "\(uid) now runs at \(Int(rate)) Hz; engine input \(control.inputRate().map { "\(Int($0)) Hz" } ?? "not wired"): \(rebuild ? "rebuilding the engine" : "left as it is")") |
| + guard rebuild else { return } |
| + lastRateRebuild = now |
| + control.rebuild() |
| } |
| /// The engine did not start on the devices it was put on. They are |
| @@ -234,8 +284,8 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| } |
| let (input, output) = control?.choices() ?? (nil, nil) |
| for uid in uids where uid == input || uid == output { failed[uid] = reason } |
| - AudioLog.audio.error( |
| - "engine failed on \(target.description, privacy: .public): \(reason, privacy: .public); falling back to the system default") |
| + AudioLog.error( |
| + "engine failed on \(target.description): \(reason); falling back to the system default") |
| refresh() |
| } |
| @@ -256,26 +306,34 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| guard let control else { throw AudioRoutes.gone(what) } |
| if id.isEmpty || id == AudioRouteRules.defaultID { |
| AudioPreference(id: "", name: "").save(input: input, defaults) |
| - AudioLog.audio.info("chosen \(what, privacy: .public): system default") |
| + AudioLog.info("chosen \(what): system default") |
| refresh() |
| return |
| } |
| guard let device = control.inventory().list(input).first(where: { $0.uid == id }) else { |
| - AudioLog.audio.error("chosen \(what, privacy: .public) \(id, privacy: .public) is not connected; refused") |
| + AudioLog.error("chosen \(what) \(id) is not connected; refused") |
| throw AudioRoutes.gone(what) |
| } |
| failed[device.uid] = nil |
| AudioPreference(id: device.uid, name: device.name).save(input: input, defaults) |
| - AudioLog.audio.info("chosen \(what, privacy: .public): \(device.describe, privacy: .public)") |
| + AudioLog.info("chosen \(what): \(device.describe)") |
| refresh() |
| } |
| /// The current microphone's channels and the remembered one of them. |
| - private func readChannels(_ uid: String?) { |
| + /// Inputs are a choice only on a microphone chosen by name: on the |
| + /// system default the call sends what the module wires, as in 0.3.3 |
| + /// (InputChannelMap.channel). |
| + private func readChannels(_ uid: String?, explicit: Bool) { |
| let device = control.flatMap { $0.channels(uid) } |
| - inputDevice = device |
| - let channel = device.map { InputChannelPreference.load(uid: $0.uid, channels: $0.count, defaults) } ?? 0 |
| - update(\.inputChannels, device.map { d in d.count > 1 ? (0..<d.count).map(d.label) : [] } ?? []) |
| + inputDevice = explicit ? device : nil |
| + let channel = |
| + device.map { |
| + InputChannelMap.channel( |
| + saved: InputChannelPreference.load(uid: $0.uid, channels: $0.count, defaults), device: $0.uid, |
| + explicit: explicit ? $0.uid : nil, inputs: $0.count) |
| + } ?? 0 |
| + update(\.inputChannels, explicit ? device.map { d in d.count > 1 ? (0..<d.count).map(d.label) : [] } ?? [] : []) |
| update(\.inputChannel, channel) |
| update(\.inputSource, device?.describe(channel: channel) ?? "") |
| } |
| @@ -299,6 +357,68 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| refresh() |
| } |
| + /// The microphone actually in use, named with how it is connected and |
| + /// the rate it runs at now: "Paul's AirPods Pro (Bluetooth, 24 kHz)". |
| + /// The engine's own readback when there is one, else the choice, with |
| + /// the system default resolved to the device it is. |
| + public func inputDescription() -> String { |
| + guard let control else { return "" } |
| + let inventory = control.inventory() |
| + let uid = |
| + engineInput ?? (currentInput == AudioRouteRules.defaultID || currentInput == nil ? inventory.defaultInput : currentInput) |
| + guard let device = inventory.devices.first(where: { $0.uid == uid }) else { return "" } |
| + return AudioRoutes.describe(device) |
| + } |
| + |
| + nonisolated static func describe(_ device: SystemAudioDevice) -> String { |
| + var parts: [String] = [] |
| + switch device.transport { |
| + case "bluetooth": parts.append("Bluetooth") |
| + case "usb": parts.append("USB") |
| + case "built-in": parts.append("built-in") |
| + case "virtual": parts.append("virtual") |
| + case "aggregate": parts.append("aggregate") |
| + case "thunderbolt": parts.append("Thunderbolt") |
| + case "display": parts.append("display") |
| + case "airplay": parts.append("AirPlay") |
| + default: break |
| + } |
| + if device.sampleRate > 0 { parts.append(AudioRoutes.kHz(device.sampleRate)) } |
| + return parts.isEmpty ? device.name : "\(device.name) (\(parts.joined(separator: ", ")))" |
| + } |
| + |
| + /// "16 kHz", "44.1 kHz". |
| + nonisolated static func kHz(_ rate: Double) -> String { |
| + let k = rate / 1000 |
| + return k == k.rounded() ? "\(Int(k)) kHz" : String(format: "%.1f kHz", k) |
| + } |
| + |
| + /// For "Copy audio diagnostics": the choices, what they resolve to, |
| + /// and everything CoreAudio has. |
| + public func diagnostics() -> [String] { |
| + guard let control else { return ["routes: no audio attached"] } |
| + let inventory = control.inventory() |
| + let saved = [true, false].map { input -> String in |
| + guard let p = AudioPreference.load(input: input, defaults) else { return "nothing chosen" } |
| + return p.id.isEmpty ? "system default (chosen)" : "\(p.name) [\(p.id)]" |
| + } |
| + var lines = [ |
| + "chosen: microphone \(saved[0]); speaker \(saved[1])", |
| + "resolved: microphone \(currentInput ?? "none") -> \(inputDescription()); speaker \(currentOutput ?? "none") (\(outputName))", |
| + "macOS defaults: input \(inventory.defaultInput ?? "none"), output \(inventory.defaultOutput ?? "none")", |
| + "engine input read back: \(engineInput ?? "not yet")", |
| + "input sent: \(inputSource.isEmpty ? "-" : inputSource); inputs offered \(inputChannels.count)", |
| + ] |
| + if !routeNote.isEmpty { lines.append("note: \(routeNote)") } |
| + if !failed.isEmpty { |
| + lines.append("set aside: " + failed.map { "\($0.key): \($0.value)" }.sorted().joined(separator: "; ")) |
| + } |
| + let pinned = control.pinned() |
| + lines.append("engine target: \(pinned.target), last applied \(pinned.applied)") |
| + for device in inventory.devices { lines.append("device: \(device.describe)") } |
| + return lines |
| + } |
| + |
| private static func gone(_ what: String) -> NSError { |
| NSError( |
| domain: "Calls", code: 5, |
| @@ -414,6 +534,11 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| var rebuildInput: () -> Void |
| /// For the log: what the engine module lists, beside CoreAudio. |
| var logModuleLists: () -> Void |
| + /// The input rate the engine last started with (nil: not recording). |
| + var inputRate: () -> Double? |
| + var isRecording: () -> Bool |
| + /// Where the engine would be put now, and where it last was. |
| + var pinned: () -> (target: String, applied: String, isPair: Bool) |
| init( |
| inventory: @escaping () -> AudioInventory, |
| @@ -422,7 +547,10 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| rebuild: @escaping () -> Void = {}, |
| channels: @escaping (_ uid: String?) -> InputDeviceChannels? = { _ in nil }, |
| rebuildInput: @escaping () -> Void = {}, |
| - logModuleLists: @escaping () -> Void = {} |
| + logModuleLists: @escaping () -> Void = {}, |
| + inputRate: @escaping () -> Double? = { nil }, |
| + isRecording: @escaping () -> Bool = { false }, |
| + pinned: @escaping () -> (target: String, applied: String, isPair: Bool) = { ("-", "-", false) } |
| ) { |
| self.inventory = inventory |
| self.pin = pin |
| @@ -431,10 +559,22 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| self.channels = channels |
| self.rebuildInput = rebuildInput |
| self.logModuleLists = logModuleLists |
| + self.inputRate = inputRate |
| + self.isRecording = isRecording |
| + self.pinned = pinned |
| } |
| - init(_ module: RTCAudioDeviceModule, pin enginePin: AudioEnginePin) { |
| + init(_ module: RTCAudioDeviceModule, pin enginePin: AudioEnginePin, inputRate: @escaping () -> Double?) { |
| inventory = CoreAudioDevices.inventory |
| + self.inputRate = inputRate |
| + isRecording = { [weak module] in module?.isRecording ?? false } |
| + pinned = { |
| + let target = enginePin.target() |
| + if case .pair = target { |
| + return (target.description, enginePin.lastApplied?.description ?? "none yet", true) |
| + } |
| + return (target.description, enginePin.lastApplied?.description ?? "none yet", false) |
| + } |
| choices = { enginePin.choices } |
| // A running engine is rebuilt when what it would be put on now is |
| // not what it was put on. Before an engine exists (the module makes |
| @@ -455,7 +595,7 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| let saved = module.engineAvailability |
| _ = module.setEngineAvailability(RTCAudioEngineAvailability(isInputAvailable: false, isOutputAvailable: false)) |
| let result = module.setEngineAvailability(saved) |
| - AudioLog.audio.info("engine rebuilt (result \(result), running \(module.isEngineRunning))") |
| + AudioLog.info("engine rebuilt (result \(result), running \(module.isEngineRunning))") |
| } |
| channels = { uid in CoreAudioInput.device(forModuleID: uid).flatMap(CoreAudioInput.describe) } |
| // Taking the input away and giving it back rewires the microphone, |
| @@ -474,8 +614,8 @@ public struct AudioDevice: Identifiable, Hashable, Sendable { |
| func line(_ list: [RTCIODevice]) -> String { |
| list.map { "\($0.name) [\($0.deviceId)]" }.joined(separator: "; ") |
| } |
| - AudioLog.audio.info( |
| - "engine module lists (not used to choose): inputs \(line(module.inputDevices), privacy: .public); outputs \(line(module.outputDevices), privacy: .public)") |
| + AudioLog.info( |
| + "engine module lists (not used to choose): inputs \(line(module.inputDevices)); outputs \(line(module.outputDevices))") |
| } |
| } |
| } |
diff --git a/native/apple/Sources/CallsKit/AudioDevices.swift b/native/apple/Sources/CallsKit/AudioDevices.swift| index 51acaad..af67e08 100644 |
| --- a/native/apple/Sources/CallsKit/AudioDevices.swift |
| +++ b/native/apple/Sources/CallsKit/AudioDevices.swift |
| @@ -37,11 +37,7 @@ |
| // speaker that are different devices are joined in a private aggregate |
| // device, as macOS itself does for the two system defaults. |
| - /// The subsystem Paul's `log show` reads: |
| - /// `log show --last 1h --predicate 'subsystem == "net.amadan.oficina.calls"'`. |
| - enum AudioLog { |
| - static let audio = Logger(subsystem: "net.amadan.oficina.calls", category: "audio") |
| - } |
| + // The audio log (AudioLog) is in AudioDiagnostics.swift. |
| /// One CoreAudio device, as the routing rules need it. |
| struct SystemAudioDevice: Equatable, Sendable { |
| @@ -280,6 +276,26 @@ |
| } |
| } |
| + /// When a chosen microphone's change of rate rebuilds the engine. Pure; |
| + /// the tests hold it. |
| + enum RateRule { |
| + static let minimumGap: Double = 3 |
| + |
| + /// - Parameters: |
| + /// - engineRate: the input rate the running engine was wired at |
| + /// (nil: it is not capturing, and the next start reads the rate |
| + /// afresh). |
| + /// - pair: the engine is on one of our aggregates, whose clock is |
| + /// the speaker's, so its input rate says nothing about the |
| + /// microphone's: any change of the microphone's rate rebuilds it. |
| + static func rebuild( |
| + deviceRate: Double, engineRate: Double?, recording: Bool, pair: Bool, lastRebuild: Double, now: Double |
| + ) -> Bool { |
| + guard recording, let engineRate, deviceRate > 0, now - lastRebuild >= minimumGap else { return false } |
| + return pair || abs(engineRate - deviceRate) >= 1 |
| + } |
| + } |
| + |
| /// Where the engine's one audio unit should be: left to AVAudioEngine's |
| /// own default, on one device, or on a pair joined in an aggregate. |
| enum EngineTarget: Equatable, Sendable, CustomStringConvertible { |
| @@ -467,12 +483,12 @@ |
| var id = AudioDeviceID(kAudioObjectUnknown) |
| let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &id) |
| guard status == noErr, id != kAudioObjectUnknown else { |
| - AudioLog.audio.error("aggregate of \(input, privacy: .public) + \(output, privacy: .public) refused, OSStatus \(status)") |
| + AudioLog.error("aggregate of \(input) + \(output) refused, OSStatus \(status)") |
| return nil |
| } |
| let outputOffset = CoreAudioInput.device(uid: input).map { CoreAudioDevices.channels($0, input: false) } ?? 0 |
| - AudioLog.audio.info( |
| - "aggregate \(uid, privacy: .public): in \(input, privacy: .public), out \(output, privacy: .public) from channel \(outputOffset + 1); in=\(CoreAudioDevices.channels(id, input: true)) out=\(CoreAudioDevices.channels(id, input: false))") |
| + AudioLog.info( |
| + "aggregate \(uid): in \(input), out \(output) from channel \(outputOffset + 1); in=\(CoreAudioDevices.channels(id, input: true)) out=\(CoreAudioDevices.channels(id, input: false))") |
| return (id, uid, 0, outputOffset) |
| } |
| @@ -496,7 +512,14 @@ |
| let status = AudioUnitSetProperty( |
| unit, kAudioOutputUnitProperty_ChannelMap, kAudioUnitScope_Input, 0, &map, |
| UInt32(MemoryLayout<Int32>.size * map.count)) |
| - AudioLog.audio.info("output channel map past \(offset) microphone outputs: OSStatus \(status)") |
| + AudioLog.info("output channel map past \(offset) microphone outputs: OSStatus \(status)") |
| + } |
| + |
| + /// The UID of the aggregate this pin has made, if it has one now. |
| + var aggregateUID: String? { |
| + lock.lock() |
| + defer { lock.unlock() } |
| + return aggregate?.uid |
| } |
| /// The aggregate goes with the call's audio. |
diff --git a/native/apple/Sources/CallsKit/AudioDiagnostics.swift b/native/apple/Sources/CallsKit/AudioDiagnostics.swift| new file mode 100644 |
| index 0000000..a0f009f |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/AudioDiagnostics.swift |
| @@ -0,0 +1,152 @@ |
| +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 audio diagnostics — \(context)", |
| + "Calls \(version) (\(build)), \(os), \(ISO8601DateFormatter().string(from: Date()))", |
| + "No audio is included: names, formats, states 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 |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/AudioSelfTest.swift b/native/apple/Sources/CallsKit/AudioSelfTest.swift| index 5bdf07a..2d22e3e 100644 |
| --- a/native/apple/Sources/CallsKit/AudioSelfTest.swift |
| +++ b/native/apple/Sources/CallsKit/AudioSelfTest.swift |
| @@ -15,9 +15,13 @@ public enum MicVerdict: Equatable, Sendable { |
| case hearing |
| /// Speech-level audio has left the capture hook — what a call sends. |
| case working |
| - /// Five seconds with nothing above -90 dBFS before the suppressor, or |
| - /// no audio at all. |
| + /// Five seconds of audio arriving with nothing above -90 dBFS before |
| + /// the suppressor: the device is delivering silence. |
| case silent |
| + /// Five seconds with no audio arriving at all: the microphone was asked |
| + /// for and nothing came back. Up to 0.3.7 this was called `silent` too, |
| + /// and the two needed telling apart (Paul's AirPods, 2026-09-26). |
| + case noAudio |
| /// In a call that has not opened the microphone: WebRTC starts it only |
| /// once there is someone to send to, so silence here says nothing about |
| /// the device. |
| @@ -77,7 +81,11 @@ public struct MicJudge: Sendable { |
| let quietFor = now - (lastSound ?? started ?? now) |
| if quietFor >= MicJudge.silenceWindow { |
| heardSpeech = false |
| - verdict = !captureExpected && !everHadBuffers ? .notStarted : .silent |
| + if everHadBuffers { |
| + verdict = .silent |
| + } else { |
| + verdict = captureExpected ? .noAudio : .notStarted |
| + } |
| } else if heardSpeech { |
| verdict = .working |
| } else if lastSound != nil { |
| @@ -104,7 +112,8 @@ public struct MicJudge: Sendable { |
| case .listening: return "Listening to \(name). Say something." |
| case .hearing: return "Calls can hear \(name), but not speech yet. Try speaking normally." |
| case .working: return "Your microphone is working." |
| - case .silent: return "We can’t hear anything from \(name)." |
| + case .silent: return "Sound is arriving from \(name), but it is silent." |
| + case .noAudio: return "No sound is arriving from \(name). Calls asked for it and nothing came back." |
| case .notStarted: |
| return |
| "The microphone starts when someone else joins the call. Test it then, or from the home screen before a call." |
| @@ -117,6 +126,9 @@ public struct MicJudge: Sendable { |
| case .silent: |
| return |
| "Check that it is connected and switched on, that it isn’t muted on the device, and that it is the microphone chosen here. On an audio interface with several inputs, check which input it is plugged into." |
| + case .noAudio: |
| + return |
| + "Try choosing it again, or another microphone. If it keeps happening, use Copy audio diagnostics and send what it copies." |
| default: return nil |
| } |
| } |
diff --git a/native/apple/Sources/CallsKit/AudioSelfTestModel.swift b/native/apple/Sources/CallsKit/AudioSelfTestModel.swift| index 5dc81c0..a89d3a9 100644 |
| --- a/native/apple/Sources/CallsKit/AudioSelfTestModel.swift |
| +++ b/native/apple/Sources/CallsKit/AudioSelfTestModel.swift |
| @@ -45,6 +45,10 @@ import LiveKitWebRTC |
| @MainActor protocol AudioSelfTestCapture: AnyObject { |
| var denoiser: CaptureDenoiser { get } |
| var microphoneName: String { get } |
| + /// The device the microphone actually is, with how it is connected and |
| + /// its rate now: "Paul's AirPods Pro (Bluetooth, 24 kHz)". Empty where |
| + /// that is not known. |
| + var microphoneDetail: String { get } |
| var speakerName: String { get } |
| /// The chosen speaker, as the module lists it (a CoreAudio UID on Mac). |
| var speakerID: String? { get } |
| @@ -61,6 +65,8 @@ import LiveKitWebRTC |
| func stop() |
| /// The meters since the last read: before the suppressor, and after. |
| func levels() -> (input: CaptureLevel.Reading, output: CaptureLevel.Reading) |
| + /// For "Copy audio diagnostics": titled blocks of lines. |
| + func diagnostics() -> [(String, [String])] |
| } |
| /// Plays mono audio to the chosen speaker. Public so the interop harness |
| @@ -105,6 +111,8 @@ public struct RecordingSummary: Equatable, Sendable { |
| /// 0...1 in tenths while recording. |
| @Published public private(set) var recordingProgress = 0.0 |
| @Published public private(set) var microphoneName = "" |
| + /// "Paul's AirPods Pro (Bluetooth, 24 kHz)": what the verdict names. |
| + @Published public private(set) var microphoneDetail = "" |
| @Published public private(set) var speakerName = "" |
| public private(set) var lastRecording: RecordingSummary? |
| @@ -133,6 +141,8 @@ public struct RecordingSummary: Equatable, Sendable { |
| /// readings are its own sound and are not judged. |
| private var playingSince: Double? |
| private var ticker: Task<Void, Never>? |
| + private var ticks = 0 |
| + private var lastVerdict: MicVerdict? |
| init( |
| capture: AudioSelfTestCapture, player: AudioSelfTestPlayer, routes: AudioRoutes?, |
| @@ -157,6 +167,38 @@ public struct RecordingSummary: Equatable, Sendable { |
| return AudioSelfTest(capture: capture, player: EnginePlayer(), routes: routes) |
| } |
| + #if os(macOS) |
| + /// For the interop harness: the standalone test's capture on the real |
| + /// audio device module (device rendering, no synthetic voice), started |
| + /// as the test starts it, and whether that asked the module to record. |
| + /// Asking is what is checked, because a test process has no microphone |
| + /// permission and the module refuses right after being asked; without |
| + /// the ask (0.3.7) the microphone was never opened on any Mac. |
| + /// |
| + /// - Parameter askToRecord: false is the control: the 0.3.7 start, |
| + /// which must come back not asked. |
| + public static func deviceStartProbe(askToRecord: Bool) async -> (asked: Bool, detail: String) { |
| + let routes = AudioRoutes(defaults: UserDefaults(suiteName: "calls-selftest-probe-\(UUID().uuidString)")!) |
| + let capture = StandaloneCapture(routes: routes, input: nil, reduceNoise: true, askToRecord: askToRecord) |
| + do { |
| + try await capture.start() |
| + } catch { |
| + // Refused (in a test process, for want of permission): what |
| + // matters is whether it was asked. |
| + let asked = capture.audio.recordingRequested |
| + return (asked, "recording requested \(asked); the module refused: \(error.localizedDescription)") |
| + } |
| + let deadline = Date().addingTimeInterval(3) |
| + while !capture.audio.recordingRequested, Date() < deadline { |
| + try? await Task.sleep(for: .milliseconds(100)) |
| + } |
| + let asked = capture.audio.recordingRequested |
| + let recording = capture.audio.factory.audioDeviceModule.isRecording |
| + capture.stop() |
| + return (asked, "recording requested \(asked), module recording \(recording) (no permission refuses it)") |
| + } |
| + #endif |
| + |
| /// In a call: its meters, read and nothing else. Standalone if the call |
| /// has no media up. |
| public static func during(_ call: Call) -> AudioSelfTest { |
| @@ -189,6 +231,8 @@ public struct RecordingSummary: Equatable, Sendable { |
| guard !closed else { return } |
| await startCapture() |
| refreshNames() |
| + AudioLog.info( |
| + "test opened \(inCall ? "in a call" : "before a call"): microphone \(microphoneName) = \(microphoneDetail.isEmpty ? "unknown" : microphoneDetail); capturing \(capturing)") |
| ticker = Task { [weak self] in |
| while !Task.isCancelled { |
| try? await Task.sleep(for: .milliseconds(100)) |
| @@ -223,7 +267,10 @@ public struct RecordingSummary: Equatable, Sendable { |
| _ = capture.levels() |
| } catch { |
| capturing = false |
| - micProblem = "The microphone would not start: \(error.localizedDescription)" |
| + let said = error.localizedDescription |
| + micProblem = said.hasPrefix("The microphone") || said == AudioSelfTest.noPermission |
| + ? said : "The microphone would not start: \(said)" |
| + AudioLog.error("test: \(micProblem ?? said)") |
| } |
| } |
| @@ -235,9 +282,28 @@ public struct RecordingSummary: Equatable, Sendable { |
| private func refreshNames() { |
| if microphoneName != capture.microphoneName { microphoneName = capture.microphoneName } |
| + let detail = capture.microphoneDetail |
| + if microphoneDetail != detail { microphoneDetail = detail } |
| if speakerName != capture.speakerName { speakerName = capture.speakerName } |
| } |
| + /// What "Copy audio diagnostics" copies from the test. |
| + public func diagnosticsText() -> String { |
| + var test = [ |
| + "where: \(inCall ? "during a call (reads the call's microphone)" : "before a call (its own microphone)")", |
| + "microphone: \(microphoneName) = \(microphoneDetail.isEmpty ? "unknown" : microphoneDetail)", |
| + "speaker: \(speakerName)", |
| + "capturing: \(capturing); verdict \(String(describing: micVerdict)); meter step \(micStep)", |
| + "says: \(micSentence)", |
| + "reduce background noise: \(reduceNoise)", |
| + "recording test: \(String(describing: recording)); speaker test: \(String(describing: speaker))", |
| + ] |
| + if let problem = micProblem { test.append("problem: \(problem)") } |
| + return AudioDiagnosticsReport.make( |
| + context: inCall ? "Test audio in a call" : "Test audio before a call", |
| + sections: [("Test audio", test)] + capture.diagnostics()) |
| + } |
| + |
| // MARK: The 10 Hz step |
| /// One read of the meters. Publishes only what changed, so a view is |
| @@ -265,9 +331,25 @@ public struct RecordingSummary: Equatable, Sendable { |
| let verdict = judge.observe( |
| input: input, output: output, at: clock(), captureExpected: capture.captureExpected) |
| set(\.micVerdict, verdict) |
| - refreshNames() |
| + AudioDiagnosticsStore.shared.record( |
| + .init( |
| + at: clock(), input: input.peak, inputBuffers: input.buffers, output: output.peak, |
| + outputBuffers: output.buffers, source: "test")) |
| + if verdict != lastVerdict { |
| + lastVerdict = verdict |
| + AudioLog.info("test verdict: \(String(describing: verdict)) — \(MicJudge.sentence(verdict, device: verdictName))") |
| + } |
| + // The device's name is cheap; its rate means reading CoreAudio, so |
| + // once a second (a Bluetooth headset changes rate as its microphone |
| + // opens). |
| + ticks += 1 |
| + if ticks % 10 == 0 { refreshNames() } |
| } |
| + /// The microphone as the verdict names it: the device it resolves to, |
| + /// with its connection and rate, where that is known. |
| + var verdictName: String { microphoneDetail.isEmpty ? microphoneName : microphoneDetail } |
| + |
| private func set<Value: Equatable>(_ key: ReferenceWritableKeyPath<AudioSelfTest, Value>, _ value: Value) { |
| if self[keyPath: key] != value { self[keyPath: key] = value } |
| } |
| @@ -309,7 +391,7 @@ public struct RecordingSummary: Equatable, Sendable { |
| guard !closed else { return recorder.discard() } |
| var taken = recorder.take() |
| guard !taken.samples.isEmpty, taken.rate > 0 else { |
| - recording = .failed("Nothing was recorded. \(MicJudge.sentence(.silent, device: microphoneName))") |
| + recording = .failed("Nothing was recorded. \(MicJudge.sentence(.noAudio, device: verdictName))") |
| return |
| } |
| let rms = SampleLevel.rms(taken.samples) |
| @@ -361,7 +443,7 @@ public struct RecordingSummary: Equatable, Sendable { |
| "Not during a call: everyone in it would hear the sound too. Test your speaker and hear yourself from the home screen before a call." |
| public var micSentence: String { |
| - micProblem ?? MicJudge.sentence(micVerdict, device: microphoneName) |
| + micProblem ?? MicJudge.sentence(micVerdict, device: verdictName) |
| } |
| public var micAdvice: String? { micProblem == nil ? MicJudge.advice(micVerdict) : nil } |
| @@ -384,7 +466,7 @@ public struct RecordingSummary: Equatable, Sendable { |
| case .playing: return "Playing back what others would hear." |
| case .played(let silent): |
| return silent |
| - ? "The recording was silent. \(MicJudge.sentence(.silent, device: microphoneName))" |
| + ? "The recording was silent. \(MicJudge.sentence(.silent, device: verdictName))" |
| : "That is how you sound to others. The recording has been deleted." |
| case .failed(let why): return why |
| case .unavailableInCall: return nil |
| @@ -408,15 +490,17 @@ extension Call { |
| let audio: CallAudio |
| let reduceNoise: Bool |
| private let synthetic: Bool |
| + private let askToRecord: Bool |
| private var track: RTCAudioTrack? |
| private var loopback: SelfTestLoopback? |
| #if os(iOS) |
| private var session: SavedSession? |
| #endif |
| - init(routes: AudioRoutes, input: AudioInputSource?, reduceNoise: Bool) { |
| + init(routes: AudioRoutes, input: AudioInputSource?, reduceNoise: Bool, askToRecord: Bool = true) { |
| self.routes = routes |
| self.reduceNoise = reduceNoise |
| + self.askToRecord = askToRecord |
| synthetic = input != nil |
| #if os(macOS) |
| audio = CallAudio(routes: routes, audioDevice: nil, input: input, reduceNoise: reduceNoise) |
| @@ -436,9 +520,19 @@ extension Call { |
| #endif |
| var microphoneName: String { AudioSelfTest.inputName(routes) } |
| + var microphoneDetail: String { AudioSelfTest.inputDetail(routes) } |
| var speakerName: String { routes.outputName } |
| var speakerID: String? { routes.currentOutput } |
| + func diagnostics() -> [(String, [String])] { |
| + #if os(macOS) |
| + let routeLines = routes.diagnostics() |
| + #else |
| + let routeLines = ["microphone: \(microphoneName)", "speaker: \(speakerName)"] |
| + #endif |
| + return [("Devices", routeLines), ("Audio engine and processing", audio.diagnostics())] |
| + } |
| + |
| func permitted() async -> Bool { |
| if synthetic { return true } |
| #if os(macOS) |
| @@ -467,12 +561,23 @@ extension Call { |
| let loop = try await SelfTestLoopback(factory: audio.factory, track: track) |
| guard self.track === track else { return loop.close() } |
| loopback = loop |
| + // The loopback is never connected, and WebRTC opens the microphone |
| + // only for a connection that has been (CallAudio.startRecordingAlone): |
| + // so the microphone is opened here, as a call's first send stream |
| + // opens it. |
| + let result = audio.startRecordingAlone(ask: askToRecord) |
| routes.refresh() |
| + if result != 0, !synthetic { |
| + stop() |
| + throw NSError( |
| + domain: "Calls", code: result, userInfo: [NSLocalizedDescriptionKey: CallAudio.recordingError(result)]) |
| + } |
| } |
| func stop() { |
| loopback?.close() |
| loopback = nil |
| + audio.stopRecordingAlone() |
| track = nil |
| #if os(iOS) |
| session?.restore() |
| @@ -513,7 +618,9 @@ extension Call { |
| var muted: Bool { call?.muted ?? false } |
| var stopsForPlayback: Bool { false } |
| var microphoneName: String { call.map { AudioSelfTest.inputName($0.audio) } ?? "" } |
| + var microphoneDetail: String { call.map { AudioSelfTest.inputDetail($0.audio) } ?? "" } |
| var speakerName: String { call?.audio.outputName ?? "" } |
| + func diagnostics() -> [(String, [String])] { call?.audioDiagnosticSections() ?? [] } |
| var speakerID: String? { call?.audio.currentOutput } |
| func permitted() async -> Bool { true } |
| func start() async throws { |
| @@ -532,6 +639,18 @@ extension AudioSelfTest { |
| static func inputName(_ routes: AudioRoutes) -> String { |
| routes.inputs.first { $0.id == routes.currentInput }?.name ?? "" |
| } |
| + |
| + /// The device the microphone resolves to, with connection and rate. |
| + static func inputDetail(_ routes: AudioRoutes) -> String { |
| + #if os(macOS) |
| + return routes.inputDescription() |
| + #else |
| + let session = AVAudioSession.sharedInstance() |
| + guard let port = session.currentRoute.inputs.first else { return "" } |
| + let kind = port.portType == .bluetoothHFP ? "Bluetooth, " : "" |
| + return "\(port.portName) (\(kind)\(Int(session.sampleRate / 1000)) kHz)" |
| + #endif |
| + } |
| } |
| #if os(iOS) |
diff --git a/native/apple/Sources/CallsKit/AudioSelfTestView.swift b/native/apple/Sources/CallsKit/AudioSelfTestView.swift| index fb4a4bc..7b07005 100644 |
| --- a/native/apple/Sources/CallsKit/AudioSelfTestView.swift |
| +++ b/native/apple/Sources/CallsKit/AudioSelfTestView.swift |
| @@ -74,6 +74,8 @@ import SwiftUI |
| speaker |
| Divider() |
| hearYourself |
| + Divider() |
| + CopyAudioDiagnosticsButton { test.diagnosticsText() } |
| } |
| .frame(maxWidth: .infinity, alignment: .leading) |
| } |
| @@ -94,6 +96,11 @@ import SwiftUI |
| #else |
| if !test.microphoneName.isEmpty { Text(test.microphoneName).foregroundStyle(.secondary) } |
| #endif |
| + if !test.microphoneDetail.isEmpty { |
| + Text("Using \(test.microphoneDetail)").font(.footnote).foregroundStyle(.secondary) |
| + .fixedSize(horizontal: false, vertical: true) |
| + .accessibilityIdentifier("testMicrophoneDevice") |
| + } |
| LevelMeter(step: test.micStep, label: "Microphone level") |
| Label(test.micSentence, systemImage: micSymbol) |
| .font(.callout) |
| @@ -117,7 +124,7 @@ import SwiftUI |
| if test.micProblem != nil { return "exclamationmark.triangle" } |
| switch test.micVerdict { |
| case .working: return "checkmark.circle" |
| - case .silent: return "exclamationmark.triangle" |
| + case .silent, .noAudio: return "exclamationmark.triangle" |
| case .listening, .hearing, .notStarted: return "ear" |
| } |
| } |
| @@ -245,6 +252,32 @@ import SwiftUI |
| } |
| #endif |
| +/// "Copy audio diagnostics": what the app knows about its audio — the |
| +/// devices chosen and resolved, formats, the engine's state, the last 50 |
| +/// audio log lines and the recent levels — as text on the clipboard, to |
| +/// paste into a message. Never any audio. |
| +@MainActor struct CopyAudioDiagnosticsButton: View { |
| + let text: () -> String |
| + @State private var copied = false |
| + |
| + var body: some View { |
| + HStack(spacing: 8) { |
| + Button("Copy audio diagnostics", systemImage: "doc.on.clipboard") { |
| + AudioDiagnosticsReport.copy(text()) |
| + copied = true |
| + } |
| + .buttonStyle(.borderless) |
| + .font(.footnote) |
| + .accessibilityIdentifier("copyAudioDiagnostics") |
| + .accessibilityHint("Copies device names, formats and levels, never audio, to paste into a message.") |
| + if copied { |
| + Text("Copied").font(.footnote).foregroundStyle(.secondary) |
| + .accessibilityIdentifier("copyAudioDiagnosticsDone") |
| + } |
| + } |
| + } |
| +} |
| + |
| /// A level as a row of bars: the lit count is the level, in steps |
| /// (MeterSteps), so it only redraws when a bar changes. With Reduce |
| /// Motion the bars switch without animating. VoiceOver hears the level in |
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index 9f959a9..6484edb 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -128,6 +128,30 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| /// the capture hook, nil outside a call, and whether WebRTC has opened it. |
| var captureDenoiser: CaptureDenoiser? { bridge == nil ? nil : media.audio.denoiser } |
| var captureStarted: Bool { bridge != nil && media.audio.capturing } |
| + |
| + /// For "Copy audio diagnostics" in the panel's ⓘ and in Test audio |
| + /// during a call: this call's microphone, devices, engine and hook. |
| + func audioDiagnosticSections() -> [(String, [String])] { |
| + var lines = [ |
| + "in a call: \(bridge != nil); muted \(muted); microphone track \(bridge != nil && media.localAudio != nil)", |
| + "people here: \(self.state?.peers.count ?? 0)", |
| + "microphone meter: \(levels.mic.silent ? "silent" : "step \(levels.mic.step)"), speaking \(levels.mic.speaking), sending from \(levels.mic.source.isEmpty ? "-" : levels.mic.source)", |
| + ] |
| + if let health = audioHealth { lines.append("audio health: \(health.summary)") } |
| + #if os(macOS) |
| + let routeLines = audio.diagnostics() |
| + #else |
| + let routeLines = audio.inputs.map { "input: \($0.name) [\($0.id)]\($0.id == audio.currentInput ? " (current)" : "")" } |
| + + ["output: \(audio.outputName)"] |
| + #endif |
| + var sections: [(String, [String])] = [("Call", lines), ("Devices", routeLines)] |
| + if bridge != nil { sections.append(("Audio engine and processing", media.audio.diagnostics())) } |
| + return sections |
| + } |
| + |
| + public func audioDiagnosticsText() -> String { |
| + AudioDiagnosticsReport.make(context: "a call's Audio details", sections: audioDiagnosticSections()) |
| + } |
| private var ice: ICEConfig? |
| private var pendingMedia: [Message] = [] |
| fileprivate var invalidEvents: UInt64 = 0 |
| @@ -430,6 +454,10 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| muted: muted || media.localAudio == nil, capturing: capturing, |
| inputPeak: heard.peak, inputBuffers: heard.buffers, sentPeak: sent.peak), |
| now: now, source: source, channels: channels) |
| + AudioDiagnosticsStore.shared.record( |
| + .init( |
| + at: now, input: heard.peak, inputBuffers: heard.buffers, output: sent.peak, outputBuffers: sent.buffers, |
| + source: muted ? "call (muted)" : capturing ? "call" : "call (mic not open)")) |
| } |
| private func samplePeers(stats: Bool, now: Double) async { |
diff --git a/native/apple/Sources/CallsKit/CallAudio.swift b/native/apple/Sources/CallsKit/CallAudio.swift| index b3f6a25..5c24b5f 100644 |
| --- a/native/apple/Sources/CallsKit/CallAudio.swift |
| +++ b/native/apple/Sources/CallsKit/CallAudio.swift |
| @@ -176,12 +176,14 @@ public struct ProcessingReport: Equatable, Sendable { |
| let pin = AudioEnginePin() |
| devices = EngineWatch( |
| routes: routes, module: module, input: input, pin: pin, |
| - channels: InputChannelMapper(defaults: routes.defaults, layout: pin.inputLayout)) |
| + channels: InputChannelMapper( |
| + defaults: routes.defaults, layout: pin.inputLayout, explicitInput: { pin.choices.input })) |
| module.observer = devices |
| if input != nil { _ = module.setManualRenderingMode(true) } |
| // Attached in manual rendering too, so the interop harness drives the |
| // same routing code as the app; no device is opened either way. |
| - routes.attach(module, pin: pin) |
| + let watch = devices |
| + routes.attach(module, pin: pin, inputRate: { watch.lastInputRate }) |
| #endif |
| // The listener first, then the delegate: attaching the delegate can |
| // initialise it at once, and that report used to go nowhere. |
| @@ -224,6 +226,77 @@ public struct ProcessingReport: Equatable, Sendable { |
| /// microphone nobody has opened would be wrong. |
| var hookFlowing: Bool { denoiser.buffersSeen > buffersAtStart } |
| + /// Set while this CallAudio asked the module to record by itself (the |
| + /// audio test before a call); undone by `stopRecordingAlone`. |
| + private var recordingAlone = false |
| + |
| + /// Opens the microphone outside a call, as a call's first sending stream |
| + /// does: the module's `initRecording` then `startRecording`, which is |
| + /// exactly what WebRTC's AudioState::AddSendingStream calls. |
| + /// |
| + /// The audio test before a call needs this. Its in-memory loopback is |
| + /// never connected, and WebRTC only starts a send stream on a |
| + /// connection whose transport has been writable (BaseChannel:: |
| + /// IsReadyToSendMedia_w, `was_ever_writable_`), so up to 0.3.7 the test |
| + /// never opened the microphone on a real device: an empty meter and |
| + /// "We can't hear anything", for every microphone (Paul's AirPods, |
| + /// 2026-09-26). The interop harness did not see it: in manual rendering |
| + /// the module runs its input whenever its output runs. |
| + /// |
| + /// - Parameter ask: false only for the harness's control run, which |
| + /// shows that without it the module is never asked to record. |
| + /// - Returns: 0, or the module's first error (-9000: no microphone |
| + /// permission, as the module sees it). |
| + @discardableResult |
| + func startRecordingAlone(ask: Bool = true) -> Int { |
| + let module = factory.audioDeviceModule |
| + guard ask else { |
| + AudioLog.info("test: not asking the module to record (control run)") |
| + return 0 |
| + } |
| + guard !module.isRecording else { |
| + AudioLog.info("test: the module is already recording") |
| + return 0 |
| + } |
| + let initialised = module.initRecording() |
| + let started = initialised == 0 ? module.startRecording() : 0 |
| + recordingAlone = true |
| + AudioLog.info( |
| + "test: asked the module to record (init \(initialised), start \(started)); recording \(module.isRecording)") |
| + return initialised != 0 ? initialised : started |
| + } |
| + |
| + /// A module error, in words, for the audio test. |
| + static func recordingError(_ code: Int) -> String { |
| + switch code { |
| + case -9000, -4005, -2003: return AudioSelfTest.noPermission |
| + case -4010, -2000, -2001, -2002: |
| + return "The microphone would not start: macOS says it isn’t available (error \(code))." |
| + case -2004: return "The microphone would not start: another app is using it (error \(code))." |
| + default: return "The microphone would not start (error \(code))." |
| + } |
| + } |
| + |
| + func stopRecordingAlone() { |
| + guard recordingAlone else { return } |
| + recordingAlone = false |
| + let result = factory.audioDeviceModule.stopRecording() |
| + AudioLog.info("test: stopped the module's recording (\(result))") |
| + } |
| + |
| + /// Whether the module has been asked to enable its microphone since |
| + /// this CallAudio was made (its "will enable" hook ran with recording |
| + /// on). Asked, not granted: without microphone permission the module |
| + /// refuses right after. The harness's proof that the test opens the |
| + /// microphone on a real device. |
| + var recordingRequested: Bool { |
| + #if os(macOS) |
| + return devices.recordingRequested |
| + #else |
| + return true |
| + #endif |
| + } |
| + |
| /// The suppressor a call starts on. See `upgrade`. |
| static func makeSuppressor() -> NoiseSuppressor { RNNoiseSuppressor() } |
| @@ -299,6 +372,33 @@ public struct ProcessingReport: Equatable, Sendable { |
| highPassFilterOptions: component(plan.highPass ? .software : .off)) |
| } |
| + /// For "Copy audio diagnostics": the module, the engine, the hook. |
| + func diagnostics() -> [String] { |
| + var lines: [String] = [] |
| + let module = factory.audioDeviceModule |
| + #if os(macOS) |
| + lines.append( |
| + "module: manual rendering \(module.isManualRenderingMode), engine running \(module.isEngineRunning), playout initialised \(module.isPlayoutInitialized), playing \(module.isPlaying), recording initialised \(module.isRecordingInitialized), recording \(module.isRecording), microphone muted \(module.isMicrophoneMuted)" |
| + ) |
| + let choices = enginePin.choices |
| + lines.append( |
| + "engine pin: chosen input \(choices.input ?? "default"), output \(choices.output ?? "default"); last applied \(enginePin.lastApplied?.description ?? "none yet"); would apply now \(enginePin.target().description)" |
| + ) |
| + lines.append("recording requested since made: \(devices.recordingRequested)") |
| + lines.append(contentsOf: devices.history()) |
| + #else |
| + lines.append("module: playing \(module.isPlaying), recording \(module.isRecording)") |
| + #endif |
| + let hook = denoiser.format |
| + lines.append( |
| + "capture hook: \(hook.initialised ? "initialised" : "not initialised") at \(hook.rate) Hz, \(hook.channels) ch; buffers \(denoiser.buffersSeen) in all, \(denoiser.buffersSeen - buffersAtStart) since this microphone track; frames suppressed \(denoiser.framesProcessed)" |
| + ) |
| + lines.append("suppressor: \(denoiser.engineName), \(String(describing: denoiser.state)); reduce background noise \(reduceNoise)") |
| + lines.append("plan: \(String(describing: plan))") |
| + lines.append("processing: \(report().summary)") |
| + return lines |
| + } |
| + |
| /// What is actually running, as WebRTC reports it — not what was asked. |
| func report() -> ProcessingReport { |
| let state = factory.audioProcessingState |
| @@ -340,6 +440,38 @@ public struct ProcessingReport: Equatable, Sendable { |
| /// running engine nor a deliberate stop is known to have failed. |
| private var starts = 0 |
| private var stops = 0 |
| + private var requested = false |
| + /// The input format of the last engine start, for the rate watch. |
| + private var inputRate: Double? |
| + /// The last few engine events, for the diagnostics. |
| + private var events: [String] = [] |
| + |
| + var recordingRequested: Bool { |
| + lock.lock() |
| + defer { lock.unlock() } |
| + return requested |
| + } |
| + |
| + /// The input rate the engine last started with; nil when it last |
| + /// started for playout only. |
| + var lastInputRate: Double? { |
| + lock.lock() |
| + defer { lock.unlock() } |
| + return inputRate |
| + } |
| + |
| + func history() -> [String] { |
| + lock.lock() |
| + defer { lock.unlock() } |
| + return events.isEmpty ? ["engine: no events yet"] : events.map { "engine: \($0)" } |
| + } |
| + |
| + private func note(_ event: String) { |
| + lock.lock() |
| + events.append("\(AudioDiagnosticsStore.stamp(Date())) \(event)") |
| + if events.count > 12 { events.removeFirst(events.count - 12) } |
| + lock.unlock() |
| + } |
| init( |
| routes: AudioRoutes, module: RTCAudioDeviceModule, input: AudioInputSource?, pin: AudioEnginePin, |
| channels: InputChannelMapper |
| @@ -361,15 +493,25 @@ public struct ProcessingReport: Equatable, Sendable { |
| _ audioDeviceModule: RTCAudioDeviceModule, willEnableEngine engine: AVAudioEngine, |
| isPlayoutEnabled: Bool, isRecordingEnabled: Bool, isVoiceProcessingEnabled: Bool |
| ) -> Int { |
| - guard input == nil, !engine.isInManualRenderingMode else { return 0 } |
| + if isRecordingEnabled { |
| + lock.lock() |
| + requested = true |
| + lock.unlock() |
| + } |
| + note("will enable: playout \(isPlayoutEnabled), recording \(isRecordingEnabled), voice processing \(isVoiceProcessingEnabled)") |
| + guard input == nil, !engine.isInManualRenderingMode else { |
| + AudioLog.info( |
| + "engine will enable (playout \(isPlayoutEnabled), recording \(isRecordingEnabled)): manual rendering, no device") |
| + return 0 |
| + } |
| let outcome = pin.apply(to: engine, recording: isRecordingEnabled) |
| switch outcome { |
| case .applied(let target): |
| - AudioLog.audio.info( |
| - "engine will enable (playout \(isPlayoutEnabled), recording \(isRecordingEnabled)): on \(target.description, privacy: .public)") |
| + AudioLog.info( |
| + "engine will enable (playout \(isPlayoutEnabled), recording \(isRecordingEnabled)): on \(target.description)") |
| case .failed(let target, let why): |
| - AudioLog.audio.error( |
| - "engine will enable: could not put it on \(target.description, privacy: .public): \(why, privacy: .public)") |
| + AudioLog.error( |
| + "engine will enable: could not put it on \(target.description): \(why)") |
| DispatchQueue.main.async { [weak self] in |
| MainActor.assumeIsolated { self?.routes?.engineFailed(target, reason: why) } |
| } |
| @@ -396,13 +538,27 @@ public struct ProcessingReport: Equatable, Sendable { |
| if isPlayoutEnabled, let unit { pin.mapOutput(on: unit) } |
| let actual = unit.flatMap(pin.actualInput) |
| let target = pin.lastApplied ?? .systemDefault |
| + let output = engine.outputNode.outputFormat(forBus: 0) |
| + let device = unit.flatMap(CoreAudioDevices.current(of:)) |
| + let on = device.flatMap(CoreAudioDevices.uid) ?? "unknown" |
| + let rate = device.map(CoreAudioDevices.sampleRate) ?? 0 |
| + let line: String |
| if isRecordingEnabled { |
| let format = engine.inputNode.inputFormat(forBus: 0) |
| - AudioLog.audio.info( |
| - "engine starting on \(target.description, privacy: .public): microphone \(actual ?? "unknown", privacy: .public), \(format.channelCount) ch at \(Int(format.sampleRate)) Hz") |
| + line = |
| + "engine starting on \(target.description) (unit on \(on), \(Int(rate)) Hz): microphone \(actual ?? "unknown"), \(format.channelCount) ch at \(Int(format.sampleRate)) Hz; output \(output.channelCount) ch at \(Int(output.sampleRate)) Hz" |
| + lock.lock() |
| + inputRate = format.sampleRate |
| + lock.unlock() |
| } else { |
| - AudioLog.audio.info("engine starting on \(target.description, privacy: .public) for playout only") |
| + line = |
| + "engine starting on \(target.description) (unit on \(on), \(Int(rate)) Hz) for playout only; output \(output.channelCount) ch at \(Int(output.sampleRate)) Hz" |
| + lock.lock() |
| + inputRate = nil |
| + lock.unlock() |
| } |
| + AudioLog.info(line) |
| + note(line) |
| lock.lock() |
| starts += 1 |
| let seen = (starts, stops) |
| @@ -422,7 +578,8 @@ public struct ProcessingReport: Equatable, Sendable { |
| let now = (self.starts, self.stops) |
| self.lock.unlock() |
| guard now == seen, !module.isEngineRunning else { return } |
| - AudioLog.audio.error("engine did not start on \(target.description, privacy: .public)") |
| + AudioLog.error("engine did not start on \(target.description)") |
| + self.note("did not start on \(target.description)") |
| self.routes?.engineFailed(target, reason: "the audio engine would not start on it") |
| } |
| } |
| @@ -432,6 +589,7 @@ public struct ProcessingReport: Equatable, Sendable { |
| _ audioDeviceModule: RTCAudioDeviceModule, didStopEngine engine: AVAudioEngine, |
| isPlayoutEnabled: Bool, isRecordingEnabled: Bool |
| ) -> Int { |
| + note("stopped (playout \(isPlayoutEnabled), recording \(isRecordingEnabled) next)") |
| stopped() |
| return 0 |
| } |
| @@ -442,6 +600,7 @@ public struct ProcessingReport: Equatable, Sendable { |
| func audioDeviceModule( |
| _ audioDeviceModule: RTCAudioDeviceModule, willReleaseEngine engine: AVAudioEngine |
| ) -> Int { |
| + note("released") |
| stopped() |
| return 0 |
| } |
diff --git a/native/apple/Sources/CallsKit/DevicePanel.swift b/native/apple/Sources/CallsKit/DevicePanel.swift| index f3b06a2..eacfeb3 100644 |
| --- a/native/apple/Sources/CallsKit/DevicePanel.swift |
| +++ b/native/apple/Sources/CallsKit/DevicePanel.swift |
| @@ -320,6 +320,7 @@ import SwiftUI |
| .fixedSize(horizontal: false, vertical: true) |
| .accessibilityIdentifier("audioProcessing") |
| } |
| + CopyAudioDiagnosticsButton { call.audioDiagnosticsText() }.padding(.top, 6) |
| if call.sharingScreen { |
| Text("Screen sharing").font(.headline).padding(.top, 6) |
| Text(call.screenReport?.summary ?? "Screen: starting…") |
diff --git a/native/apple/Sources/CallsKit/InputChannel.swift b/native/apple/Sources/CallsKit/InputChannel.swift| index f9de6a5..49360a7 100644 |
| --- a/native/apple/Sources/CallsKit/InputChannel.swift |
| +++ b/native/apple/Sources/CallsKit/InputChannel.swift |
| @@ -74,6 +74,17 @@ |
| /// The pure parts of the channel map, apart from any device. |
| public enum InputChannelMap { |
| + /// The input to send from, 0-based, or 0 to leave the unit as the |
| + /// module wired it. Only a microphone the person chose by name (not |
| + /// the system default) with more than one input, and only an input |
| + /// they chose other than the first: 0.3.8 made the map strictly |
| + /// opt-in, so a default or ordinary microphone is wired exactly as it |
| + /// was in 0.3.3, before there was a map at all. |
| + public static func channel(saved: Int, device: String, explicit: String?, inputs: Int) -> Int { |
| + guard let explicit, explicit == device, inputs > 1, saved > 0, saved < inputs else { return 0 } |
| + return saved |
| + } |
| + |
| /// The map for an input unit whose client side has `clientChannels` |
| /// channels: every one of them carries device input `channel` |
| /// (0-based), so a mono mixdown of them — or the first of them — is |
| @@ -220,14 +231,20 @@ |
| /// channels start (AudioEnginePin.inputLayout). Nil for a device that |
| /// is the microphone itself. |
| private let layout: (AudioDeviceID) -> (uid: String, offset: Int)? |
| + /// The microphone the person chose, or nil for the system default. |
| + private let explicitInput: () -> String? |
| private let lock = NSLock() |
| private var mapped = false |
| /// What was last applied, for the readout: the device and the channel. |
| private(set) var applied: (uid: String, channel: Int)? |
| - init(defaults: UserDefaults, layout: @escaping (AudioDeviceID) -> (uid: String, offset: Int)? = { _ in nil }) { |
| + init( |
| + defaults: UserDefaults, layout: @escaping (AudioDeviceID) -> (uid: String, offset: Int)? = { _ in nil }, |
| + explicitInput: @escaping () -> String? = { nil } |
| + ) { |
| self.defaults = defaults |
| self.layout = layout |
| + self.explicitInput = explicitInput |
| } |
| func apply(to node: AVAudioNode?) { |
| @@ -237,7 +254,9 @@ |
| else { return } |
| let (uid, offset) = layout(device) ?? (unitUID, 0) |
| let count = CoreAudioInput.device(uid: uid).map(CoreAudioInput.inputChannelCount) ?? 0 |
| - let channel = InputChannelPreference.load(uid: uid, channels: count, defaults) |
| + let channel = InputChannelMap.channel( |
| + saved: InputChannelPreference.load(uid: uid, channels: count, defaults), device: uid, |
| + explicit: explicitInput(), inputs: count) |
| lock.lock() |
| defer { lock.unlock() } |
| guard |
| @@ -253,8 +272,8 @@ |
| mapped = channel + offset > 0 |
| applied = (uid, channel) |
| } |
| - AudioLog.audio.info( |
| - "input channel map \(map.map(String.init).joined(separator: ","), privacy: .public) for \(uid, privacy: .public) input \(channel + 1): OSStatus \(status)") |
| + AudioLog.info( |
| + "input channel map \(map.map(String.init).joined(separator: ",")) for \(uid) input \(channel + 1): OSStatus \(status)") |
| } |
| } |
| #endif |
diff --git a/native/apple/Sources/CallsKit/NoiseSuppression.swift b/native/apple/Sources/CallsKit/NoiseSuppression.swift| index b8fb4d6..fc066f4 100644 |
| --- a/native/apple/Sources/CallsKit/NoiseSuppression.swift |
| +++ b/native/apple/Sources/CallsKit/NoiseSuppression.swift |
| @@ -211,6 +211,8 @@ public final class CaptureDenoiser: NSObject, RTCAudioCustomProcessingDelegate, |
| /// Every buffer WebRTC has handed over, processed or not: proof that |
| /// the microphone is capturing. |
| var buffers: UInt64 = 0 |
| + /// Buffers since the last initialise; the first is logged. |
| + var buffersSinceInit: UInt64 = 0 |
| var busySeconds: Double = 0 |
| var lastFrame: Double = -.infinity |
| var cpu: Double = 0 |
| @@ -273,7 +275,10 @@ public final class CaptureDenoiser: NSObject, RTCAudioCustomProcessingDelegate, |
| s.rate = sampleRateHz |
| s.channels = channels |
| s.initialised = true |
| + s.buffersSinceInit = 0 |
| } |
| + AudioLog.info( |
| + "capture hook initialised at \(sampleRateHz) Hz, \(channels) ch; noise reduction \(sampleRateHz == CaptureDenoiser.supportedRate ? "can run" : "passes it through (meters still read it)")") |
| // Every time, not only on a change. WebRTC initialises its processing |
| // at 16 kHz when the module is made, long before any microphone opens, |
| // and that first call can arrive before anyone is listening; a |
| @@ -286,13 +291,42 @@ public final class CaptureDenoiser: NSObject, RTCAudioCustomProcessingDelegate, |
| public var buffersSeen: UInt64 { lock.withLock { $0.buffers } } |
| + /// Counts one buffer; true for the first since the last initialise. |
| + /// Apart from WebRTC's buffer type so the tests can drive it. |
| + @discardableResult |
| + public func countBuffer() -> Bool { |
| + lock.withLock { s in |
| + s.buffers += 1 |
| + s.buffersSinceInit += 1 |
| + return s.buffersSinceInit == 1 |
| + } |
| + } |
| + |
| + /// The rate and channels WebRTC last initialised the hook with (0 |
| + /// before it has): for the diagnostics. |
| + public var format: (rate: Int, channels: Int, initialised: Bool) { |
| + lock.withLock { s in (s.rate, s.channels, s.initialised) } |
| + } |
| + |
| public func audioProcessingProcess(audioBuffer: RTCAudioBuffer) { |
| - lock.withLock { $0.buffers += 1 } |
| + if countBuffer() { |
| + AudioLog.info( |
| + "capture hook: first buffer since initialise, \(audioBuffer.frames) frames x \(audioBuffer.channels) ch") |
| + } |
| guard audioBuffer.channels >= 1 else { return } |
| - let samples = audioBuffer.rawBuffer(forChannel: 0) |
| - inputLevel.record(samples, frames: audioBuffer.frames, at: clock()) |
| - if audioBuffer.channels == 1 { process(samples, frames: audioBuffer.frames) } |
| - observe(samples, frames: audioBuffer.frames) |
| + handle(audioBuffer.rawBuffer(forChannel: 0), frames: audioBuffer.frames, channels: audioBuffer.channels) |
| + } |
| + |
| + /// Everything the hook does with one buffer, apart from WebRTC's buffer |
| + /// type so the tests drive this exact path: the meter before, the |
| + /// suppressor (mono at 48 kHz only), then the meter after and the sink — |
| + /// at every rate and channel count, so a Bluetooth headset's 16 or 24 |
| + /// kHz, which the suppressor passes through, is metered and recorded |
| + /// all the same. |
| + public func handle(_ samples: UnsafeMutablePointer<Float>, frames: Int, channels: Int) { |
| + inputLevel.record(samples, frames: frames, at: clock()) |
| + if channels == 1 { process(samples, frames: frames) } |
| + observe(samples, frames: frames) |
| } |
| /// The same buffers measured on the way *in*, before the suppressor: |
diff --git a/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift b/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift| index 4973f72..3fb5adb 100644 |
| --- a/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/AudioRoutesTests.swift |
| @@ -345,6 +345,40 @@ |
| XCTAssertEqual(plan(nil, "headset", di: nil), .device("headset"), "no microphone at all") |
| } |
| + /// A Bluetooth headset (or anything else with its own microphone and |
| + /// speaker) used for both is one device: never an aggregate, chosen |
| + /// on either side or both. |
| + func testOneDeviceForBothIsNeverAPair() { |
| + func plan(_ i: String?, _ o: String?, di: String?, do d: String?) -> EngineTarget { |
| + EngineTarget.plan(input: i, output: o, defaultInput: di, defaultOutput: d) |
| + } |
| + XCTAssertEqual(plan(nil, nil, di: "airpods", do: "airpods"), .systemDefault, "the default is macOS's, untouched") |
| + XCTAssertEqual(plan("airpods", nil, di: "airpods", do: "airpods"), .device("airpods")) |
| + XCTAssertEqual(plan(nil, "airpods", di: "airpods", do: "airpods"), .device("airpods")) |
| + XCTAssertEqual(plan("airpods", "airpods", di: "mic", do: "spk"), .device("airpods")) |
| + } |
| + |
| + /// When a chosen microphone's change of rate rebuilds the engine: a |
| + /// headset going to its hands-free rate under an engine wired at 48 |
| + /// kHz does; a change that matches what the engine has, an engine not |
| + /// capturing, or a second change within three seconds does not. |
| + func testARateChangeRebuildsOnlyWhenTheEngineIsWiredAtAnother() { |
| + func rule(_ device: Double, _ engine: Double?, recording: Bool = true, pair: Bool = false, last: Double = -100) |
| + -> Bool |
| + { |
| + RateRule.rebuild(deviceRate: device, engineRate: engine, recording: recording, pair: pair, lastRebuild: last, now: 10) |
| + } |
| + XCTAssertTrue(rule(24_000, 48_000), "HFP engaged under a 48 kHz engine") |
| + XCTAssertFalse(rule(24_000, 24_000), "already wired at the new rate") |
| + XCTAssertFalse(rule(24_000, nil), "not capturing: the next start reads the rate afresh") |
| + XCTAssertFalse(rule(24_000, 48_000, recording: false)) |
| + XCTAssertFalse(rule(24_000, 48_000, last: 8), "at most once every three seconds") |
| + XCTAssertTrue(rule(24_000, 48_000, last: 7)) |
| + XCTAssertTrue(rule(24_000, 48_000, pair: true)) |
| + XCTAssertTrue(rule(48_000, 48_000, pair: true), "an aggregate's rate is the speaker's, so any change rebuilds it") |
| + XCTAssertFalse(rule(0, 48_000), "a device that has gone says 0") |
| + } |
| + |
| func testTheAggregateOffsetMapsTheMicrophonesOwnInputs() { |
| // A speaker with two inputs of its own ahead of the microphone. |
| XCTAssertEqual(InputChannelMap.map(channel: 0, clientChannels: 1, mappedBefore: false, offset: 2), [2]) |
| @@ -406,5 +440,40 @@ |
| XCTAssertEqual(audio.enginePin.lastApplied, .systemDefault, "rebuilt on the default") |
| XCTAssertTrue(module.isEngineRunning) |
| } |
| + |
| + /// A device that is both microphone and speaker (as AirPods are), |
| + /// chosen for both: the engine runs on the device itself, and no |
| + /// aggregate is made. Uses whatever such device this Mac has (a |
| + /// virtual one here, "Microsoft Teams Audio"); playing only, which |
| + /// needs no microphone permission. |
| + @MainActor func testOneDeviceForBothRunsOnItWithNoAggregate() throws { |
| + let inventory = CoreAudioDevices.inventory() |
| + guard let both = inventory.devices.first(where: { $0.inputChannels > 0 && $0.outputChannels > 0 }) else { |
| + throw XCTSkip("this machine has no device with both a microphone and a speaker") |
| + } |
| + print("same-device route on \(both.describe)") |
| + let suite = "calls-engine-same-\(UUID().uuidString)" |
| + let store = UserDefaults(suiteName: suite)! |
| + defer { store.removePersistentDomain(forName: suite) } |
| + let routes = AudioRoutes(defaults: store) |
| + let audio = CallAudio(routes: routes, audioDevice: nil) |
| + let module = audio.factory.audioDeviceModule |
| + try routes.use(input: both.uid) |
| + try routes.use(output: both.uid) |
| + let pc = audio.factory.peerConnection( |
| + with: RTCConfiguration(), |
| + constraints: RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil), delegate: nil) |
| + defer { pc?.close() } |
| + XCTAssertEqual(module.initPlayout(), 0) |
| + XCTAssertEqual(module.startPlayout(), 0) |
| + defer { _ = module.stopPlayout() } |
| + XCTAssertTrue(module.isEngineRunning, "the engine runs on the one device") |
| + XCTAssertEqual(audio.enginePin.lastApplied, .device(both.uid)) |
| + XCTAssertNil(audio.enginePin.aggregateUID, "no aggregate for one device") |
| + XCTAssertFalse( |
| + CoreAudioDevices.allDeviceIDs().compactMap(CoreAudioDevices.uid).contains { |
| + $0.hasPrefix(CoreAudioDevices.ownAggregatePrefix) |
| + }, "and none left anywhere on the system") |
| + } |
| } |
| #endif |
diff --git a/native/apple/Tests/CallsKitTests/AudioSelfTestTests.swift b/native/apple/Tests/CallsKitTests/AudioSelfTestTests.swift| index 4151fbe..5cb76de 100644 |
| --- a/native/apple/Tests/CallsKitTests/AudioSelfTestTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/AudioSelfTestTests.swift |
| @@ -27,14 +27,25 @@ final class MicJudgeTests: XCTestCase { |
| judge.start(at: 0) |
| XCTAssertEqual(judge.observe(input: reading(dbfs(-100)), output: reading(0), at: 4.9), .listening) |
| XCTAssertEqual(judge.observe(input: reading(dbfs(-100)), output: reading(0), at: 5), .silent) |
| - XCTAssertEqual(MicJudge.sentence(.silent, device: "Scarlett 18i8 USB"), "We can’t hear anything from Scarlett 18i8 USB.") |
| + XCTAssertEqual( |
| + MicJudge.sentence(.silent, device: "Scarlett 18i8 USB"), "Sound is arriving from Scarlett 18i8 USB, but it is silent.") |
| XCTAssertNotNil(MicJudge.advice(.silent)) |
| } |
| - func testNoBuffersAtAllIsSilentToo() { |
| + /// Nothing arriving at all is not the same as silence arriving, and is |
| + /// said differently: up to 0.3.7 both were "We can't hear anything", |
| + /// and Paul's AirPods could not tell which (2026-09-26). |
| + func testNoBuffersAtAllIsNoAudioNotSilent() { |
| var judge = MicJudge() |
| judge.start(at: 0) |
| - XCTAssertEqual(judge.observe(input: reading(0, buffers: 0), output: reading(0, buffers: 0), at: 6), .silent) |
| + XCTAssertEqual(judge.observe(input: reading(0, buffers: 0), output: reading(0, buffers: 0), at: 4.9), .listening) |
| + XCTAssertEqual(judge.observe(input: reading(0, buffers: 0), output: reading(0, buffers: 0), at: 6), .noAudio) |
| + XCTAssertEqual( |
| + MicJudge.sentence(.noAudio, device: "Paul’s AirPods Pro (Bluetooth, 24 kHz)"), |
| + "No sound is arriving from Paul’s AirPods Pro (Bluetooth, 24 kHz). Calls asked for it and nothing came back.") |
| + XCTAssertNotNil(MicJudge.advice(.noAudio)) |
| + // Buffers of silence arriving later are the device's silence. |
| + XCTAssertEqual(judge.observe(input: reading(0), output: reading(0), at: 12), .silent) |
| } |
| /// A quiet room DeepFilterNet has taken down 30 dB: dead after the |
| @@ -180,6 +191,7 @@ private final class TestClock: @unchecked Sendable { |
| @MainActor private final class FakeCapture: AudioSelfTestCapture { |
| let denoiser = CaptureDenoiser(suppressor: PassThrough()) |
| var microphoneName = "Scarlett 18i8 USB" |
| + var microphoneDetail = "" |
| var speakerName = "MacBook Pro Speakers" |
| var speakerID: String? = "speakers" |
| var inCall = false |
| @@ -199,14 +211,14 @@ private final class TestClock: @unchecked Sendable { |
| func levels() -> (input: CaptureLevel.Reading, output: CaptureLevel.Reading) { |
| (denoiser.inputLevel.take(), denoiser.level.take()) |
| } |
| + func diagnostics() -> [(String, [String])] { [("Fake", ["fake device lines"])] } |
| /// One 10 ms buffer through the hook, as WebRTC hands it over. |
| - func feed(_ amplitude: Float) { |
| - var samples = (0..<480).map { amplitude * sin(Float($0) * 2 * .pi * 440 / 48_000) } |
| + func feed(_ amplitude: Float, rate: Int = 48_000) { |
| + let frames = rate / 100 |
| + var samples = (0..<frames).map { amplitude * sin(Float($0) * 2 * .pi * 440 / Float(rate)) } |
| samples.withUnsafeMutableBufferPointer { b in |
| - denoiser.inputLevel.record(b.baseAddress!, frames: 480, at: 0) |
| - denoiser.process(b.baseAddress!, frames: 480) |
| - denoiser.observe(b.baseAddress!, frames: 480) |
| + denoiser.handle(b.baseAddress!, frames: frames, channels: 1) |
| } |
| } |
| } |
| @@ -258,7 +270,54 @@ private final class TestClock: @unchecked Sendable { |
| } |
| XCTAssertEqual(test.micVerdict, .silent) |
| XCTAssertEqual(test.micStep, 0) |
| - XCTAssertEqual(test.micSentence, "We can’t hear anything from Scarlett 18i8 USB.") |
| + XCTAssertEqual(test.micSentence, "Sound is arriving from Scarlett 18i8 USB, but it is silent.") |
| + } |
| + |
| + /// The verdict names the device the microphone really is, with its |
| + /// connection and rate, and says when nothing arrives at all. |
| + func testNoAudioNamesTheActualDeviceAndRate() async { |
| + let (test, _, _, clock) = make { $0.microphoneDetail = "Paul’s AirPods Pro (Bluetooth, 24 kHz)" } |
| + await test.open() |
| + defer { test.close() } |
| + for _ in 0..<60 { |
| + clock.now += 0.1 |
| + test.tick() |
| + } |
| + XCTAssertEqual(test.micVerdict, .noAudio) |
| + XCTAssertEqual( |
| + test.micSentence, |
| + "No sound is arriving from Paul’s AirPods Pro (Bluetooth, 24 kHz). Calls asked for it and nothing came back.") |
| + } |
| + |
| + /// A Bluetooth microphone at 24 kHz, which the suppressor passes through, |
| + /// still moves the meter and is judged working. |
| + func testATwentyFourKilohertzMicrophoneIsMeteredAndWorking() async { |
| + let (test, capture, _, clock) = make { $0.denoiser.audioProcessingInitialize(sampleRate: 24_000, channels: 1) } |
| + await test.open() |
| + defer { test.close() } |
| + capture.feed(8_000, rate: 24_000) |
| + clock.now += 0.1 |
| + test.tick() |
| + XCTAssertEqual(test.micVerdict, .working) |
| + XCTAssertGreaterThan(test.micStep, 0) |
| + } |
| + |
| + /// "Copy audio diagnostics": names, verdict and levels, and no audio. |
| + func testDiagnosticsSayWhatWasMeasuredAndHoldNoAudio() async { |
| + let (test, capture, _, clock) = make { $0.microphoneDetail = "Paul’s AirPods Pro (Bluetooth, 24 kHz)" } |
| + await test.open() |
| + defer { test.close() } |
| + capture.feed(8_000) |
| + clock.now += 0.1 |
| + test.tick() |
| + let text = test.diagnosticsText() |
| + XCTAssertTrue(text.contains("Paul’s AirPods Pro (Bluetooth, 24 kHz)"), text) |
| + XCTAssertTrue(text.contains("verdict working"), text) |
| + XCTAssertTrue(text.contains("## Level history"), text) |
| + XCTAssertTrue(text.contains("## Audio log (last 50 lines)"), text) |
| + XCTAssertTrue(text.contains("fake device lines"), text) |
| + XCTAssertTrue(text.contains("dBFS"), text) |
| + XCTAssertLessThan(text.count, 40_000, "names and numbers, not samples") |
| } |
| func testNoPermissionSaysSoAndStartsNothing() async { |
diff --git a/native/apple/Tests/CallsKitTests/InputChannelTests.swift b/native/apple/Tests/CallsKitTests/InputChannelTests.swift| index f34076e..711591b 100644 |
| --- a/native/apple/Tests/CallsKitTests/InputChannelTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/InputChannelTests.swift |
| @@ -55,6 +55,26 @@ |
| XCTAssertEqual(InputChannelMap.map(channel: 2, clientChannels: 0, mappedBefore: false), [2]) |
| } |
| + /// 0.3.8: the map is strictly opt-in. Only an input other than the |
| + /// first, chosen on a microphone chosen by name that has more than |
| + /// one input; anything else leaves the unit as the module wired it, |
| + /// which is what 0.3.3 did for every microphone. |
| + func testTheMapIsOnlyForAnInputChosenOnAChosenInterface() { |
| + func channel(_ saved: Int, _ device: String, explicit: String?, inputs: Int) -> Int { |
| + InputChannelMap.channel(saved: saved, device: device, explicit: explicit, inputs: inputs) |
| + } |
| + XCTAssertEqual(channel(1, "scarlett", explicit: "scarlett", inputs: 18), 1, "chosen: input 2") |
| + XCTAssertEqual(channel(0, "scarlett", explicit: "scarlett", inputs: 18), 0) |
| + XCTAssertEqual(channel(1, "scarlett", explicit: nil, inputs: 18), 0, "on the system default: never mapped") |
| + XCTAssertEqual(channel(1, "scarlett", explicit: "usb", inputs: 18), 0, "another device chosen") |
| + XCTAssertEqual(channel(1, "airpods", explicit: "airpods", inputs: 1), 0, "a one-input microphone") |
| + XCTAssertEqual(channel(5, "solo", explicit: "solo", inputs: 2), 0, "a remembered input it no longer has") |
| + XCTAssertEqual(channel(1, "solo", explicit: "solo", inputs: 2), 1) |
| + XCTAssertNil( |
| + InputChannelMap.map(channel: channel(1, "scarlett", explicit: nil, inputs: 18), clientChannels: 18, mappedBefore: false), |
| + "so the unit is left alone") |
| + } |
| + |
| func testChannelsAreCountedAcrossAStreamConfiguration() { |
| let list = AudioBufferList.allocate(maximumBuffers: 3) |
| defer { free(list.unsafeMutablePointer) } |
| @@ -117,6 +137,26 @@ |
| XCTAssertEqual(again.inputChannel, 1) |
| } |
| + /// The interface as the system default, with an input remembered for |
| + /// it: no picker, and input 1 as the module wires it. |
| + func testOnTheSystemDefaultThereIsNoPickerAndNoMap() throws { |
| + InputChannelPreference.save(1, uid: "scarlett-uid", store) |
| + let scarlett = InputDeviceChannels(uid: "scarlett-uid", name: "Scarlett 18i8 USB", count: 4) |
| + let routes = AudioRoutes(defaults: store) |
| + routes.attach( |
| + AudioEngineControl( |
| + inventory: { |
| + AudioInventory( |
| + devices: [SystemAudioDevice(uid: "scarlett", name: "Scarlett 18i8 USB", inputChannels: 4, outputChannels: 0)], |
| + defaultInput: "scarlett", defaultOutput: nil) |
| + }, |
| + channels: { _ in scarlett })) |
| + XCTAssertEqual(routes.currentInput, AudioRouteRules.defaultID) |
| + XCTAssertEqual(routes.inputChannels, []) |
| + XCTAssertEqual(routes.inputChannel, 0) |
| + XCTAssertEqual(routes.inputSource, "Scarlett 18i8 USB, input 1") |
| + } |
| + |
| func testAnOrdinaryMicrophoneHasNoPicker() throws { |
| let routes = routes(channels: InputDeviceChannels(uid: "usb", name: "USB Mic", count: 1)) {} |
| try routes.use(input: "scarlett") |
diff --git a/native/apple/Tests/CallsKitTests/NoiseSuppressionTests.swift b/native/apple/Tests/CallsKitTests/NoiseSuppressionTests.swift| index 04e3988..e578a59 100644 |
| --- a/native/apple/Tests/CallsKitTests/NoiseSuppressionTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/NoiseSuppressionTests.swift |
| @@ -558,3 +558,111 @@ final class CaptureAdapterPassthroughTests: XCTestCase { |
| XCTAssertGreaterThan(loudest, MicJudge.speechRMS) |
| } |
| } |
| + |
| +/// A Bluetooth headset used as a microphone runs its hands-free rate: 16 |
| +/// kHz, 24 kHz on AirPods (8 kHz on old ones). The suppressor passes those |
| +/// through, and the meters the audio test and the call read, and the |
| +/// recording's sink, must see them anyway: a meter blind to them would |
| +/// show every Bluetooth microphone as dead (Paul's AirPods, 2026-09-26). |
| +/// Driven through `handle`, the hook's own body. |
| +final class BluetoothRateCaptureTests: XCTestCase { |
| + private final class Sink: @unchecked Sendable { |
| + private let lock = NSLock() |
| + private var got: [(frames: Int, rate: Int, peak: Float)] = [] |
| + func add(_ samples: UnsafePointer<Float>, _ frames: Int, _ rate: Int) { |
| + var peak: Float = 0 |
| + for i in 0..<frames { peak = max(peak, abs(samples[i])) } |
| + lock.withLock { got.append((frames, rate, peak)) } |
| + } |
| + var seen: [(frames: Int, rate: Int, peak: Float)] { lock.withLock { got } } |
| + } |
| + |
| + private final class Reported: @unchecked Sendable { |
| + private let lock = NSLock() |
| + private var got: [Int] = [] |
| + func add(_ rate: Int) { lock.withLock { got.append(rate) } } |
| + var rates: [Int] { lock.withLock { got } } |
| + } |
| + |
| + private func tone(_ frames: Int, rate: Int, amplitude: Float = 8_000) -> [Float] { |
| + (0..<frames).map { amplitude * sin(Float($0) * 2 * .pi * 300 / Float(rate)) } |
| + } |
| + |
| + private func feed(_ denoiser: CaptureDenoiser, _ samples: [Float]) -> [Float] { |
| + var copy = samples |
| + copy.withUnsafeMutableBufferPointer { denoiser.handle($0.baseAddress!, frames: $0.count, channels: 1) } |
| + return copy |
| + } |
| + |
| + func testHandsFreeRatesAreMeteredAndRecordedThoughNotSuppressed() { |
| + for rate in [8_000, 16_000, 24_000, 32_000, 44_100] { |
| + let fake = FakeSuppressor() |
| + let denoiser = CaptureDenoiser(suppressor: fake, clock: { 1 }) |
| + denoiser.audioProcessingInitialize(sampleRate: rate, channels: 1) |
| + let sink = Sink() |
| + denoiser.sink = { sink.add($0, $1, $2) } |
| + let voice = tone(rate / 100, rate: rate) |
| + XCTAssertEqual(feed(denoiser, voice), voice, "\(rate) Hz passes through untouched") |
| + let before = denoiser.inputLevel.take() |
| + let after = denoiser.level.take() |
| + XCTAssertEqual(before.buffers, 1, "\(rate) Hz reaches the meter before the suppressor") |
| + XCTAssertEqual(after.buffers, 1, "\(rate) Hz reaches the meter after it") |
| + XCTAssertGreaterThan(after.peak, 0.2, "\(rate) Hz reads as sound, not silence") |
| + XCTAssertEqual(sink.seen.map(\.rate), [rate], "the recording is told the rate") |
| + XCTAssertEqual(fake.calls, 0) |
| + XCTAssertEqual(denoiser.state, .unsupportedRate(rate)) |
| + } |
| + } |
| + |
| + /// HFP engages when the microphone opens, so WebRTC reinitialises the |
| + /// hook mid-stream: 48 kHz (A2DP, before) to 24 kHz and back. The |
| + /// meters never stop, the sink follows the rate, and the suppressor |
| + /// runs only while the rate is 48 kHz. |
| + func testAMidStreamRateChangeKeepsTheMetersAndFollowsTheRate() { |
| + let fake = FakeSuppressor() |
| + let denoiser = CaptureDenoiser(suppressor: fake, clock: { 1 }) |
| + let sink = Sink() |
| + denoiser.sink = { sink.add($0, $1, $2) } |
| + let reported = Reported() |
| + denoiser.rateChanged = { reported.add($0) } |
| + |
| + denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| + XCTAssertTrue(denoiser.countBuffer(), "the first buffer after an initialise is the one logged") |
| + XCTAssertFalse(denoiser.countBuffer()) |
| + _ = feed(denoiser, tone(480, rate: 48_000)) |
| + XCTAssertEqual(fake.calls, 1, "48 kHz is suppressed") |
| + |
| + denoiser.audioProcessingRelease() |
| + denoiser.audioProcessingInitialize(sampleRate: 24_000, channels: 1) |
| + XCTAssertTrue(denoiser.countBuffer(), "a reinitialise starts the count again") |
| + let voice = tone(240, rate: 24_000) |
| + XCTAssertEqual(feed(denoiser, voice), voice) |
| + XCTAssertEqual(fake.calls, 1, "24 kHz is not") |
| + |
| + denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) |
| + _ = feed(denoiser, tone(480, rate: 48_000)) |
| + XCTAssertEqual(fake.calls, 2, "and 48 kHz is again") |
| + |
| + XCTAssertEqual(denoiser.level.take().buffers, 3, "every buffer metered across both changes") |
| + XCTAssertEqual(denoiser.inputLevel.take().buffers, 3) |
| + XCTAssertEqual(sink.seen.map(\.rate), [48_000, 24_000, 48_000]) |
| + XCTAssertEqual(sink.seen.map(\.frames), [480, 240, 480]) |
| + XCTAssertTrue(sink.seen.allSatisfy { $0.peak > 1_000 }, "the sink hears sound at each rate") |
| + XCTAssertEqual(reported.rates, [48_000, 24_000, 48_000]) |
| + XCTAssertEqual(denoiser.format.rate, 48_000) |
| + } |
| + |
| + /// A recording that spans the change starts again at the new rate, so |
| + /// what plays back is one rate throughout. |
| + func testTheRecordingStartsAgainWhenTheRateChanges() { |
| + let recorder = SelfTestRecorder(seconds: 1) |
| + recorder.arm() |
| + let a = tone(480, rate: 48_000) |
| + let b = tone(240, rate: 24_000) |
| + a.withUnsafeBufferPointer { recorder.append($0.baseAddress!, frames: 480, rate: 48_000) } |
| + b.withUnsafeBufferPointer { recorder.append($0.baseAddress!, frames: 240, rate: 24_000) } |
| + let taken = recorder.take() |
| + XCTAssertEqual(taken.rate, 24_000) |
| + XCTAssertEqual(taken.samples.count, 240) |
| + } |
| +} |
diff --git a/native/ios/project.yml b/native/ios/project.yml| index bb998cd..a74c77d 100644 |
| --- a/native/ios/project.yml |
| +++ b/native/ios/project.yml |
| @@ -10,7 +10,7 @@ settings: |
| base: |
| SWIFT_VERSION: '5.0' |
| CODE_SIGN_STYLE: Automatic |
| - MARKETING_VERSION: '0.3.7' |
| + MARKETING_VERSION: '0.3.8' |
| CURRENT_PROJECT_VERSION: '1' |
| # The App Group the app and its broadcast extension share: the socket |
| # the iPhone's screen crosses lives in its container. One setting |