oficina / meet Public

Calls native: Test audio, before a call and in one

A "Test audio" button on the home screen (Mac and iPhone, signed in or
not) and in the device panel. It offers:

- Microphone: a live meter and a verdict in words, read from the capture
  hook — "working" on the level after noise reduction (what a call
  sends), "We can't hear anything from <device>" after 5 s below
  -90 dBFS before the suppressor, so a quiet room DeepFilterNet has
  pulled down is not mistaken for a dead microphone.
- Speaker: a two-note chime generated in code, played on an AVAudioEngine
  of its own pointed at the chosen speaker (CoreAudio UID), with a meter.
- Hear yourself as others will: 5 s recorded from CaptureDenoiser.sink
  and played back once. Held in memory only, zeroed and dropped after
  playback or when the test closes; never written or sent.

Outside a call the microphone is started as a call starts it: a
CallAudio built as a call builds one, its track sent between two
in-process peer connections negotiated in memory and never connected
(iceTransportPolicy none, nothing leaves the device). The module's own
initAndStartRecording was tried first and runs the module without the
hook ever seeing a buffer. In a call nothing is started or played: the
test meters the running call from the hook's sink and leaves the call's
own meters alone; the speaker and recording say they are for before a
call. On iPhone the session is set up as a call's and put back as found.

Adapter tests: 2, 8 and 18 channels, 44.1 and 96 kHz, the toggle off and
part frames all pass through bit for bit and are metered (tone non-zero,
silence zero, sink fed). Self-test logic tests with an injected clock.

CallsInterop --audio-selftest runs the record path in manual rendering
with the synthetic voice (noise reduction on and off) plus a silent-mic
control; TestNativeMacAudioSelfTest wraps it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 58497883fd43275778b33327f3fbea3925564d60 parent 6a3da49
12 files changed, +2092 −0
  • internal/callstest/native_selftest_test.go +62 −0
  • native/apple/Sources/CallsInterop/AudioSelfTestHarness.swift +148 −0
  • native/apple/Sources/CallsInterop/Interop.swift +3 −0
  • native/apple/Sources/CallsKit/AudioSelfTest.swift +303 −0
  • native/apple/Sources/CallsKit/AudioSelfTestLoopback.swift +94 −0
  • native/apple/Sources/CallsKit/AudioSelfTestModel.swift +691 −0
  • native/apple/Sources/CallsKit/AudioSelfTestView.swift +275 −0
  • native/apple/Sources/CallsKit/Call.swift +4 −0
  • native/apple/Sources/CallsKit/CallsLobby.swift +3 −0
  • native/apple/Sources/CallsKit/DevicePanel.swift +2 −0
  • native/apple/Tests/CallsKitTests/AudioSelfTestTests.swift +377 −0
  • native/apple/Tests/CallsKitTests/NoiseSuppressionTests.swift +130 −0
diff --git a/internal/callstest/native_selftest_test.go b/internal/callstest/native_selftest_test.go
new file mode 100644
index 0000000..37d5375
--- /dev/null
+++ b/internal/callstest/native_selftest_test.go
@@ -0,0 +1,62 @@
+//go:build browser && darwin
+
+package callstest
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// The audio test's record-and-play-back path, through the shipping
+// capture chain: LiveKit's engine module in manual rendering with the
+// synthetic voice as its microphone, WebRTC's processing module, and the
+// capture hook's noise reduction (DeepFilterNet when
+// CALLS_DEEPFILTER_MODEL is set, as make native-browser sets it). No
+// 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.
+func TestNativeMacAudioSelfTest(t *testing.T) {
+ root, err := filepath.Abs("../..")
+ if err != nil {
+ t.Fatal(err)
+ }
+ binary := filepath.Join(root, "native/apple/.build/debug/CallsInterop")
+ if _, err := os.Stat(binary); err != nil {
+ t.Fatal("build native interop executable with swift build --package-path native/apple --product CallsInterop first")
+ }
+ ctx, cancel := context.WithTimeout(t.Context(), 90*time.Second)
+ defer cancel()
+ out, err := exec.CommandContext(ctx, binary, "--audio-selftest").CombinedOutput()
+ var passes, fails []string
+ for _, line := range strings.Split(string(out), "\n") {
+ switch {
+ case strings.HasPrefix(line, "PASS audio self-test"):
+ passes = append(passes, line)
+ case strings.HasPrefix(line, "FAIL audio self-test"):
+ fails = append(fails, line)
+ }
+ }
+ for _, line := range append(passes, fails...) {
+ t.Log(line)
+ }
+ if err != nil || len(fails) > 0 || len(passes) != 3 {
+ 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)"} {
+ found := false
+ for _, line := range passes {
+ found = found || strings.Contains(line, want)
+ }
+ if !found {
+ t.Errorf("no PASS line for %s", want)
+ }
+ }
+}
diff --git a/native/apple/Sources/CallsInterop/AudioSelfTestHarness.swift b/native/apple/Sources/CallsInterop/AudioSelfTestHarness.swift
new file mode 100644
index 0000000..d7d567d
--- /dev/null
+++ b/native/apple/Sources/CallsInterop/AudioSelfTestHarness.swift
@@ -0,0 +1,148 @@
+import AppKit
+import CallsKit
+import Foundation
+import SwiftUI
+
+/// `CallsInterop --audio-selftest`: the audio test's record-and-play-back
+/// path, run end to end without a microphone.
+///
+/// The test's own standalone capture is built as on the home screen — a
+/// CallAudio on LiveKit's engine module, WebRTC's processing module, the
+/// CaptureDenoiser in the capture hook — with the module in manual
+/// rendering and the synthetic voice as its microphone (as the call gate
+/// runs it). The microphone is started as the test starts it, by the
+/// in-memory loopback (SelfTestLoopback), and the recording is taken from
+/// the hook's sink. It must be
+/// non-silent, the right length and rate, handed to the player once, and
+/// 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.
+@MainActor enum AudioSelfTestHarness {
+ /// -40 dBFS RMS. The synthetic voice is about -20 dBFS; silence is
+ /// below -90.
+ static let minimumRMS: Float = 0.01
+
+ static func run() async -> Bool {
+ var passed = true
+ for reduceNoise in [true, false] {
+ passed = await runOnce(reduceNoise: reduceNoise) && passed
+ }
+ passed = await silentControl() && passed
+ return passed
+ }
+
+ private static func silentControl() async -> Bool {
+ let label = "audio self-test control (a silent microphone)"
+ let player = CheckingPlayer()
+ let test = AudioSelfTest.harness(input: Silence(), player: player, reduceNoise: true, recordSeconds: 2)
+ await test.open()
+ defer { test.close() }
+ let deadline = Date().addingTimeInterval(8)
+ while test.micVerdict != .silent, Date() < deadline {
+ try? await Task.sleep(for: .milliseconds(100))
+ }
+ var problems: [String] = []
+ if test.micVerdict != .silent { problems.append("microphone verdict \(test.micVerdict), wanted silent") }
+ await test.recordAndPlayBack()
+ if test.recording != .played(silent: true) { problems.append("recording state \(test.recording), wanted played(silent: true)") }
+ if let summary = test.lastRecording, summary.rms >= minimumRMS {
+ problems.append("a silent microphone recorded RMS \(db(summary.rms)) dBFS")
+ }
+ if test.lastRecording == nil { problems.append("the capture ran no buffers at all") }
+ if problems.isEmpty {
+ print("PASS \(label): called silent, and its recording was silent")
+ return true
+ }
+ print("FAIL \(label): \(problems.joined(separator: "; "))")
+ return false
+ }
+
+ private static func runOnce(reduceNoise: Bool) async -> Bool {
+ let label = "audio self-test (noise reduction \(reduceNoise ? "on" : "off"))"
+ let player = CheckingPlayer()
+ let test = AudioSelfTest.harness(
+ input: SyntheticVoice(), player: player, reduceNoise: reduceNoise, recordSeconds: 5)
+ await test.open()
+ defer { test.close() }
+ // The microphone verdict first, as the person would see it.
+ let deadline = Date().addingTimeInterval(8)
+ while test.micVerdict != .working, Date() < deadline {
+ try? await Task.sleep(for: .milliseconds(100))
+ }
+ let verdict = test.micVerdict
+ await test.recordAndPlayBack()
+ // For looking at, not for passing: the test's own view, after the run.
+ if let dir = ProcessInfo.processInfo.environment["CALLS_SELFTEST_SNAPSHOT"] {
+ snapshot(test, to: URL(fileURLWithPath: dir).appendingPathComponent(
+ "audio-selftest-\(reduceNoise ? "on" : "off").png"))
+ }
+ var problems: [String] = []
+ if let problem = test.micProblem { problems.append("microphone: \(problem)") }
+ if verdict != .working { problems.append("microphone verdict \(verdict), wanted working") }
+ guard let summary = test.lastRecording else {
+ print("FAIL \(label): nothing recorded (\(test.recording)) \(problems.joined(separator: "; "))")
+ return false
+ }
+ if test.recording != .played(silent: false) { problems.append("recording state \(test.recording)") }
+ if summary.rms < minimumRMS { problems.append("recording is silent (RMS \(db(summary.rms)) dBFS)") }
+ if abs(summary.seconds - test.recordSeconds) > 0.05 {
+ problems.append("recorded \(summary.seconds) s, wanted \(test.recordSeconds)")
+ }
+ if summary.rate != 48_000 { problems.append("recorded at \(summary.rate) Hz, wanted 48000") }
+ if player.plays != 1 { problems.append("played \(player.plays) times, wanted once") }
+ if abs(player.rms - summary.rms) > 1e-4 { problems.append("the player was not given the recording") }
+ if test.holdsRecording { problems.append("the recording was kept after playback") }
+ let suppressed = test.framesSuppressed
+ if reduceNoise, suppressed == 0 { problems.append("noise reduction was on but processed no frames") }
+ if !reduceNoise, suppressed != 0 { problems.append("noise reduction was off but processed \(suppressed) frames") }
+ let detail =
+ "\(String(format: "%.2f", summary.seconds)) s at \(summary.rate) Hz, RMS \(db(summary.rms)) dBFS, "
+ + "\(test.suppressorName) processed \(suppressed) frames"
+ if problems.isEmpty {
+ print("PASS \(label): \(detail)")
+ return true
+ }
+ print("FAIL \(label): \(problems.joined(separator: "; ")) [\(detail)]")
+ return false
+ }
+
+ private static func snapshot(_ test: AudioSelfTest, to url: URL) {
+ NSApplication.shared.setActivationPolicy(.prohibited)
+ let view = NSHostingView(
+ rootView: AudioSelfTestView(test: test).padding(20).frame(width: 460)
+ .background(Color.white).tint(CallsAppearance.accent).environment(\.colorScheme, .light))
+ view.frame = NSRect(x: 0, y: 0, width: 460, height: view.fittingSize.height)
+ view.layoutSubtreeIfNeeded()
+ guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return }
+ view.cacheDisplay(in: view.bounds, to: bitmap)
+ try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ try? bitmap.representation(using: .png, properties: [:])?.write(to: url)
+ }
+
+ static func db(_ level: Float) -> String { String(format: "%.1f", SampleLevel.db(level)) }
+}
+
+/// A microphone that delivers buffers of zeros: the empty second input
+/// of an interface captured in mono.
+private final class Silence: AudioInputSource, @unchecked Sendable {
+ func render(_ samples: UnsafeMutablePointer<Float>, count: Int, sampleRate: Double) {
+ samples.update(repeating: 0, count: count)
+ }
+}
+
+/// Plays nothing: records what it was given, and feeds the meter as a
+/// player would.
+@MainActor private final class CheckingPlayer: AudioSelfTestPlayer {
+ var plays = 0
+ var rms: Float = 0
+
+ func play(_ samples: [Float], sampleRate: Double, speaker: String?, meter: OutputMeter) async throws {
+ plays += 1
+ rms = SampleLevel.rms(samples)
+ samples.withUnsafeBufferPointer { meter.record($0.baseAddress!, count: $0.count) }
+ try? await Task.sleep(for: .milliseconds(200))
+ }
+ func stop() {}
+}
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift
index 6c3bb93..1ce3a5a 100644
--- a/native/apple/Sources/CallsInterop/Interop.swift
+++ b/native/apple/Sources/CallsInterop/Interop.swift
@@ -30,6 +30,9 @@ struct Input: Decodable {
} catch { exit(1) }
return
}
+ if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--audio-selftest" {
+ exit(await AudioSelfTestHarness.run() ? 0 : 1)
+ }
guard let line = readLine(),
let input = try? JSONDecoder().decode(Input.self, from: Data(line.utf8)),
let url = URL(string: input.origin), url.scheme == "http", url.host == "127.0.0.1"
diff --git a/native/apple/Sources/CallsKit/AudioSelfTest.swift b/native/apple/Sources/CallsKit/AudioSelfTest.swift
new file mode 100644
index 0000000..5bdf07a
--- /dev/null
+++ b/native/apple/Sources/CallsKit/AudioSelfTest.swift
@@ -0,0 +1,303 @@
+import Foundation
+import os
+
+// The audio test's rules, apart from any device: what the microphone's
+// levels mean, what the recording keeps, and what the test sound is. The
+// controller (AudioSelfTestModel.swift) wires these to a real capture; the
+// tests drive them with a clock of their own.
+
+/// What the microphone half of the test says.
+public enum MicVerdict: Equatable, Sendable {
+ /// Started, and nothing decisive yet.
+ case listening
+ /// Something is arriving before the suppressor, but nothing at speech
+ /// level has left it yet: a quiet room, or someone who has not spoken.
+ 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.
+ case silent
+ /// 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.
+ case notStarted
+}
+
+/// Judges the microphone from the capture hook's two meters.
+///
+/// "Working" is asked of `CaptureDenoiser.level`, the audio after noise
+/// reduction, because that is what the far side hears. "Can't hear
+/// anything" is asked of `inputLevel`, before the suppressor: a quiet room
+/// that DeepFilterNet has taken down by 30 dB can look dead afterwards
+/// while the microphone is fine.
+public struct MicJudge: Sendable {
+ /// About conversational speech a metre from a laptop, well below the
+ /// -26 dBFS a call aims for, and well above any room.
+ public static let speechRMS: Float = pow(10, -45 / 20)
+ /// Below this before the suppressor is digital silence, or near it: a
+ /// real microphone in a real room is 20 dB above it or more. A mono
+ /// capture of an empty input on a multichannel interface sits here.
+ public static let silenceRMS: Float = pow(10, -90 / 20)
+ public static let silenceWindow: Double = 5
+
+ private var started: Double?
+ private var lastSound: Double?
+ private var heardSpeech = false
+ private var everHadBuffers = false
+ public private(set) var verdict: MicVerdict = .listening
+
+ public init() {}
+
+ /// Forgets everything; the window starts again at `now`.
+ public mutating func start(at now: Double) {
+ started = now
+ lastSound = nil
+ heardSpeech = false
+ everHadBuffers = false
+ verdict = .listening
+ }
+
+ /// One pair of readings (peak-held since the last), and the verdict.
+ ///
+ /// - Parameter captureExpected: false in a call that has not started its
+ /// microphone yet, where no audio is not the device's fault.
+ @discardableResult
+ public mutating func observe(
+ input: CaptureLevel.Reading, output: CaptureLevel.Reading, at now: Double,
+ captureExpected: Bool = true
+ ) -> MicVerdict {
+ if started == nil { start(at: now) }
+ if input.buffers > 0 || output.buffers > 0 { everHadBuffers = true }
+ if input.buffers > 0, input.rms >= MicJudge.silenceRMS { lastSound = now }
+ if output.buffers > 0, output.rms >= MicJudge.speechRMS {
+ heardSpeech = true
+ lastSound = now
+ }
+ let quietFor = now - (lastSound ?? started ?? now)
+ if quietFor >= MicJudge.silenceWindow {
+ heardSpeech = false
+ verdict = !captureExpected && !everHadBuffers ? .notStarted : .silent
+ } else if heardSpeech {
+ verdict = .working
+ } else if lastSound != nil {
+ verdict = .hearing
+ } else {
+ verdict = .listening
+ }
+ return verdict
+ }
+
+ /// Carries on after a pause of `gap` seconds as though it had not
+ /// happened: the silence window does not run while the test itself is
+ /// playing.
+ public mutating func resume(after gap: Double) {
+ guard gap > 0 else { return }
+ started = started.map { $0 + gap }
+ lastSound = lastSound.map { $0 + gap }
+ }
+
+ /// The verdict in words, naming the microphone.
+ public static func sentence(_ verdict: MicVerdict, device: String) -> String {
+ let name = device.isEmpty ? "your microphone" : device
+ switch verdict {
+ 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 .notStarted:
+ return
+ "The microphone starts when someone else joins the call. Test it then, or from the home screen before a call."
+ }
+ }
+
+ /// What to try, for the verdicts where there is something to try.
+ public static func advice(_ verdict: MicVerdict) -> String? {
+ switch verdict {
+ 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."
+ default: return nil
+ }
+ }
+}
+
+/// A level for a meter to show, in whole steps, so a view is only told
+/// when what it draws would change. -60 dBFS is the bottom step and 0 the
+/// top; each step is 5 dB.
+public enum MeterSteps {
+ public static let count = 12
+
+ public static func step(_ level: Float) -> Int {
+ guard level > 0 else { return 0 }
+ let db = 20 * log10(level)
+ return min(count, max(0, Int(((db + 60) / 5).rounded(.down)) + 1))
+ }
+
+ /// Said to VoiceOver instead of a number that would change ten times a
+ /// second.
+ public static func words(_ step: Int) -> String {
+ switch step {
+ case 0: return "silent"
+ case 1...3: return "very quiet"
+ case 4...6: return "quiet"
+ case 7...9: return "speaking level"
+ default: return "loud"
+ }
+ }
+}
+
+/// The recording for "Hear yourself as others will": a fixed block of
+/// memory filled from the capture hook's sink on the audio thread, read
+/// once for playback and cleared. It is never written anywhere else.
+///
+/// Sized at construction for the longest recording at the highest rate a
+/// device is likely to run at, so the audio thread never allocates; it
+/// copies under an unfair lock and returns.
+public final class SelfTestRecorder: @unchecked Sendable {
+ public static let maxRate = 96_000
+ private struct State {
+ var count = 0
+ var rate = 0
+ var armed = false
+ }
+ private let lock = OSAllocatedUnfairLock(initialState: State())
+ private let storage: UnsafeMutablePointer<Float>
+ private let capacity: Int
+ public let seconds: Double
+
+ public init(seconds: Double) {
+ self.seconds = seconds
+ capacity = Int(seconds * Double(SelfTestRecorder.maxRate))
+ storage = .allocate(capacity: capacity)
+ storage.initialize(repeating: 0, count: capacity)
+ }
+ deinit {
+ storage.update(repeating: 0, count: capacity)
+ storage.deallocate()
+ }
+
+ /// Starts taking audio, from empty.
+ public func arm() {
+ lock.withLock { s in
+ s = State()
+ s.armed = true
+ }
+ }
+
+ /// The sink. Samples in WebRTC's ±32768 scale, mono (the hook's channel
+ /// 0). A change of rate mid-recording starts it again: the samples
+ /// before and after cannot be played as one.
+ public func append(_ samples: UnsafePointer<Float>, frames: Int, rate: Int) {
+ guard rate > 0, frames > 0 else { return }
+ lock.withLockUnchecked { s in
+ guard s.armed else { return }
+ if s.rate != rate {
+ s.rate = rate
+ s.count = 0
+ }
+ let wanted = Int(seconds * Double(rate))
+ let room = min(wanted, capacity) - s.count
+ guard room > 0 else {
+ s.armed = false
+ return
+ }
+ let n = min(room, frames)
+ (storage + s.count).update(from: samples, count: n)
+ s.count += n
+ if s.count >= min(wanted, capacity) { s.armed = false }
+ }
+ }
+
+ /// Seconds recorded so far.
+ public var recorded: Double {
+ lock.withLock { s in s.rate > 0 ? Double(s.count) / Double(s.rate) : 0 }
+ }
+ public var full: Bool {
+ lock.withLock { s in s.rate > 0 && s.count >= min(Int(seconds * Double(s.rate)), capacity) }
+ }
+
+ /// The recording in -1...1, and its rate; the recorder is emptied and
+ /// its memory cleared, so there is one copy and it is the caller's.
+ public func take() -> (samples: [Float], rate: Int) {
+ lock.withLock { s in
+ let out = (0..<s.count).map { storage[$0] / 32768 }
+ storage.update(repeating: 0, count: s.count)
+ let rate = s.rate
+ s = State()
+ return (out, rate)
+ }
+ }
+
+ /// Throws the recording away without reading it.
+ public func discard() {
+ lock.withLock { s in
+ storage.update(repeating: 0, count: s.count)
+ s = State()
+ }
+ }
+}
+
+/// Levels of mono audio in -1...1.
+public enum SampleLevel {
+ public static func rms(_ samples: [Float]) -> Float {
+ guard !samples.isEmpty else { return 0 }
+ var energy: Float = 0
+ for v in samples { energy += v * v }
+ return (energy / Float(samples.count)).squareRoot()
+ }
+
+ public static func db(_ level: Float) -> Float { 20 * log10(max(level, 1e-9)) }
+}
+
+/// The speaker test's sound: a soft two-note chime, E5 then A5, each a
+/// sine with its octave, struck and left to ring out. Made here rather
+/// than carried as a file.
+public enum TestChime {
+ public static let rate = 48_000.0
+
+ public static func samples(rate: Double = TestChime.rate) -> [Float] {
+ let notes: [(start: Double, frequency: Double)] = [(0, 659.26), (0.35, 880)]
+ let length = 1.6
+ var out = [Float](repeating: 0, count: Int(length * rate))
+ for note in notes {
+ let first = Int(note.start * rate)
+ for i in first..<out.count {
+ let t = Double(i - first) / rate
+ // 5 ms attack, then an exponential ring-out.
+ let envelope = min(t / 0.005, 1) * exp(-t * 3.2)
+ let tone = sin(2 * .pi * note.frequency * t) + 0.25 * sin(4 * .pi * note.frequency * t)
+ out[i] += Float(0.22 * envelope * tone)
+ }
+ }
+ // A short fade so the end never clicks.
+ let fade = Int(0.05 * rate)
+ for i in 0..<fade { out[out.count - 1 - i] *= Float(i) / Float(fade) }
+ return out
+ }
+}
+
+/// A meter for audio this app is playing: the level of each buffer as it
+/// goes to the output, peak-held for the view as CaptureLevel is.
+public final class OutputMeter: @unchecked Sendable {
+ private let lock = OSAllocatedUnfairLock(initialState: Float(0))
+ public init() {}
+
+ /// Samples in -1...1.
+ public func record(_ samples: UnsafePointer<Float>, count: Int) {
+ guard count > 0 else { return }
+ var energy: Float = 0
+ for i in 0..<count { energy += samples[i] * samples[i] }
+ let rms = min((energy / Float(count)).squareRoot(), 1)
+ lock.withLock { $0 = max($0, rms) }
+ }
+
+ /// Loudest since the last read.
+ public func take() -> Float {
+ lock.withLock { level in
+ let out = level
+ level = 0
+ return out
+ }
+ }
+}
diff --git a/native/apple/Sources/CallsKit/AudioSelfTestLoopback.swift b/native/apple/Sources/CallsKit/AudioSelfTestLoopback.swift
new file mode 100644
index 0000000..7f06aea
--- /dev/null
+++ b/native/apple/Sources/CallsKit/AudioSelfTestLoopback.swift
@@ -0,0 +1,94 @@
+import Foundation
+import LiveKitWebRTC
+
+/// What starts the microphone for the audio test outside a call: the
+/// test's microphone track, sent from one peer connection to another in
+/// this process, on the test's own factory.
+///
+/// WebRTC opens the microphone when an audio stream starts sending — not
+/// when a track is made — and only then does its audio transport run the
+/// processing module, and with it the capture hook, on each 10 ms of
+/// capture. Asking the audio device module to record on its own
+/// (`initAndStartRecording`) does start the module, but the capture then
+/// never reaches the processing module: tried, and the hook saw no
+/// buffers at all. A negotiated send stream is what a call has, so it is
+/// what the test has.
+///
+/// The two connections are negotiated (offer and answer, both in memory)
+/// and never connected: ICE gathers nothing (`iceTransportPolicy = .none`),
+/// so no socket is opened and nothing leaves the device. Negotiating is
+/// enough to start the stream sending into its transport, which is enough
+/// for WebRTC to open the microphone and process every buffer, as it does
+/// for a call waiting to connect. The receiving side hears nothing — no
+/// packet reaches it — and its track is silenced anyway.
+@MainActor final class SelfTestLoopback {
+ private var sender: RTCPeerConnection?
+ private var receiver: RTCPeerConnection?
+
+ init(factory: RTCPeerConnectionFactory, track: RTCAudioTrack) async throws {
+ let config = RTCConfiguration()
+ config.sdpSemantics = .unifiedPlan
+ config.iceTransportPolicy = .none
+ let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
+ guard let sender = factory.peerConnection(with: config, constraints: constraints, delegate: nil),
+ let receiver = factory.peerConnection(with: config, constraints: constraints, delegate: nil)
+ else { throw SelfTestLoopback.failed("the audio connection could not be made") }
+ self.sender = sender
+ self.receiver = receiver
+ do {
+ guard sender.add(track, streamIds: ["selftest"]) != nil else {
+ throw SelfTestLoopback.failed("the microphone could not be added")
+ }
+ let offer = try await SelfTestLoopback.describe(sender, answer: false, constraints)
+ try await SelfTestLoopback.set(sender, local: true, offer)
+ try await SelfTestLoopback.set(receiver, local: false, offer)
+ for transceiver in receiver.transceivers {
+ (transceiver.receiver.track as? RTCAudioTrack)?.source.volume = 0
+ }
+ let answer = try await SelfTestLoopback.describe(receiver, answer: true, constraints)
+ try await SelfTestLoopback.set(receiver, local: true, answer)
+ try await SelfTestLoopback.set(sender, local: false, answer)
+ } catch {
+ close()
+ throw error
+ }
+ }
+
+ /// Ends both; WebRTC closes the microphone with the last sending stream.
+ func close() {
+ sender?.close()
+ receiver?.close()
+ sender = nil
+ receiver = nil
+ }
+
+ private static func describe(
+ _ pc: RTCPeerConnection, answer: Bool, _ constraints: RTCMediaConstraints
+ ) async throws -> RTCSessionDescription {
+ try await withCheckedThrowingContinuation { c in
+ let completion: @Sendable (RTCSessionDescription?, Error?) -> Void = { desc, error in
+ if let desc { c.resume(returning: desc) } else { c.resume(throwing: error ?? CancellationError()) }
+ }
+ if answer {
+ pc.answer(for: constraints, completionHandler: completion)
+ } else {
+ pc.offer(for: constraints, completionHandler: completion)
+ }
+ }
+ }
+
+ private static func set(_ pc: RTCPeerConnection, local: Bool, _ desc: RTCSessionDescription) async throws {
+ try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, Error>) in
+ let done: @Sendable (Error?) -> Void = { error in
+ if let error { c.resume(throwing: error) } else { c.resume() }
+ }
+ if local { pc.setLocalDescription(desc, completionHandler: done) } else {
+ pc.setRemoteDescription(desc, completionHandler: done)
+ }
+ }
+ }
+
+ private static func failed(_ why: String) -> NSError {
+ NSError(domain: "Calls", code: 6, userInfo: [NSLocalizedDescriptionKey: why])
+ }
+}
diff --git a/native/apple/Sources/CallsKit/AudioSelfTestModel.swift b/native/apple/Sources/CallsKit/AudioSelfTestModel.swift
new file mode 100644
index 0000000..5dc81c0
--- /dev/null
+++ b/native/apple/Sources/CallsKit/AudioSelfTestModel.swift
@@ -0,0 +1,691 @@
+import AVFoundation
+import Foundation
+import LiveKitWebRTC
+
+#if os(macOS)
+ import AudioToolbox
+ import CoreAudio
+#endif
+
+// "Test audio": the microphone, the speaker, and a recording played back,
+// all through the path a call uses.
+//
+// Where the microphone is measured. The meter and the verdict read the
+// capture hook's own meters (CaptureDenoiser.level and .inputLevel), and
+// the recording is taken from its sink: the audio after WebRTC's audio
+// processing (echo canceller, high-pass filter) and after our noise
+// reduction and gain — the audio a call encodes and sends, and nothing
+// nearer the microphone than that.
+//
+// How the microphone is started outside a call. A CallAudio is built
+// exactly as a call builds one (LiveKit's AVAudioEngine module on Mac, the
+// platform module on iPhone, WebRTC's processing module with the
+// CaptureDenoiser as its capture post-processing delegate, the same
+// ProcessingPlan and the person's Reduce background noise setting), a
+// microphone track is made on it as a call makes one, and the track is
+// sent from one peer connection to another in this process
+// (SelfTestLoopback): negotiated in memory, never connected, nothing
+// leaving the device. That starts the microphone the way a call does, and
+// the capture runs through the processing module and the hook exactly as
+// it would in a call. Asking the module to record on its own
+// (`initAndStartRecording`) was tried first and is not enough: the module
+// runs, but the hook sees no buffers. The path is checked end to end by
+// `CallsInterop --audio-selftest`, which runs it in manual rendering with
+// the synthetic voice and requires the recording to be non-silent (and a
+// silent input to be called silent).
+//
+// During a call nothing is started or stopped: the test reads the running
+// call's meters and does not touch its audio. It does not play anything
+// either — a sound played beside a call is heard by its microphone and
+// sent to everyone, so the speaker test and the recording are for before
+// a call, and say so.
+
+/// Where the test's microphone audio comes from. The standalone capture
+/// outside a call, the call's own inside one, and a fake in the tests.
+@MainActor protocol AudioSelfTestCapture: AnyObject {
+ var denoiser: CaptureDenoiser { get }
+ var microphoneName: String { get }
+ var speakerName: String { get }
+ /// The chosen speaker, as the module lists it (a CoreAudio UID on Mac).
+ var speakerID: String? { get }
+ var inCall: Bool { get }
+ /// False in a call whose microphone WebRTC has not opened yet.
+ var captureExpected: Bool { get }
+ var muted: Bool { get }
+ var reduceNoise: Bool { get }
+ /// Whether the capture must stop while the test plays something. On
+ /// iPhone the voice-processing unit ducks other audio while it runs.
+ var stopsForPlayback: Bool { get }
+ func permitted() async -> Bool
+ func start() async throws
+ func stop()
+ /// The meters since the last read: before the suppressor, and after.
+ func levels() -> (input: CaptureLevel.Reading, output: CaptureLevel.Reading)
+}
+
+/// Plays mono audio to the chosen speaker. Public so the interop harness
+/// can stand in a player that only checks what it was given.
+@MainActor public protocol AudioSelfTestPlayer: AnyObject {
+ /// `samples` in -1...1. Returns once they have played, or on `stop`.
+ func play(_ samples: [Float], sampleRate: Double, speaker: String?, meter: OutputMeter) async throws
+ func stop()
+}
+
+public enum SpeakerTestState: Equatable, Sendable {
+ case idle, playing, played
+ case failed(String)
+ case unavailableInCall
+}
+
+public enum RecordingTestState: Equatable, Sendable {
+ case idle, recording, playing
+ /// `silent`: the recording itself was below the silence line.
+ case played(silent: Bool)
+ case failed(String)
+ case unavailableInCall
+}
+
+/// What the last playback of a recording was, without the audio: for the
+/// interop harness, which asserts on it.
+public struct RecordingSummary: Equatable, Sendable {
+ public var seconds: Double
+ public var rate: Int
+ public var rms: Float
+}
+
+@MainActor public final class AudioSelfTest: ObservableObject {
+ @Published public private(set) var micStep = 0
+ @Published public private(set) var micVerdict: MicVerdict = .listening
+ /// Said instead of the verdict when the microphone cannot be tested at
+ /// all: no permission, muted in the call, or it would not start.
+ @Published public private(set) var micProblem: String?
+ @Published public private(set) var outputStep = 0
+ @Published public private(set) var speaker: SpeakerTestState
+ @Published public private(set) var recording: RecordingTestState
+ /// 0...1 in tenths while recording.
+ @Published public private(set) var recordingProgress = 0.0
+ @Published public private(set) var microphoneName = ""
+ @Published public private(set) var speakerName = ""
+ public private(set) var lastRecording: RecordingSummary?
+
+ public let recordSeconds: Double
+ /// The standalone test's devices, for its pickers. Nil in a call, whose
+ /// device panel already has them.
+ public let routes: AudioRoutes?
+ public var inCall: Bool { capture.inCall }
+ public var reduceNoise: Bool { capture.reduceNoise }
+ /// Whether a recording exists right now. The harness checks it is gone
+ /// after playback.
+ public var holdsRecording: Bool { recorder.recorded > 0 }
+ public var suppressorName: String { capture.denoiser.engineName }
+ public var framesSuppressed: UInt64 { capture.denoiser.framesProcessed }
+
+ private let capture: AudioSelfTestCapture
+ private let player: AudioSelfTestPlayer
+ private let clock: () -> Double
+ private let recorder: SelfTestRecorder
+ private let outputMeter = OutputMeter()
+ private var judge = MicJudge()
+ private var capturing = false
+ private var opened = false
+ private var closed = false
+ /// Set while this test is playing something, when the microphone's
+ /// readings are its own sound and are not judged.
+ private var playingSince: Double?
+ private var ticker: Task<Void, Never>?
+
+ init(
+ capture: AudioSelfTestCapture, player: AudioSelfTestPlayer, routes: AudioRoutes?,
+ recordSeconds: Double = 5, clock: @escaping () -> Double = CaptureDenoiser.now
+ ) {
+ self.capture = capture
+ self.player = player
+ self.routes = routes
+ self.recordSeconds = recordSeconds
+ self.clock = clock
+ recorder = SelfTestRecorder(seconds: recordSeconds)
+ speaker = capture.inCall ? .unavailableInCall : .idle
+ recording = capture.inCall ? .unavailableInCall : .idle
+ refreshNames()
+ }
+
+ /// Before a call: a capture of its own, on the person's remembered
+ /// devices and their Reduce background noise setting.
+ public static func standalone() -> AudioSelfTest {
+ let routes = AudioRoutes()
+ let capture = StandaloneCapture(routes: routes, input: nil, reduceNoise: Call.rememberedReduceNoise)
+ return AudioSelfTest(capture: capture, player: EnginePlayer(), routes: routes)
+ }
+
+ /// In a call: its meters, read and nothing else. Standalone if the call
+ /// has no media up.
+ public static func during(_ call: Call) -> AudioSelfTest {
+ guard let capture = LiveCallCapture(call) else { return standalone() }
+ return AudioSelfTest(capture: capture, player: EnginePlayer(), routes: nil)
+ }
+
+ #if os(macOS)
+ /// The interop harness's: the module in manual rendering with `input`
+ /// as the microphone, and devices remembered nowhere real.
+ public static func harness(
+ input: AudioInputSource, player: AudioSelfTestPlayer, reduceNoise: Bool, recordSeconds: Double = 5
+ ) -> AudioSelfTest {
+ let routes = AudioRoutes(defaults: UserDefaults(suiteName: "calls-selftest-\(UUID().uuidString)")!)
+ let capture = StandaloneCapture(routes: routes, input: input, reduceNoise: reduceNoise)
+ return AudioSelfTest(capture: capture, player: player, routes: routes, recordSeconds: recordSeconds)
+ }
+ #endif
+
+ // MARK: Lifecycle
+
+ /// Starts the microphone (outside a call) and the meters.
+ public func open() async {
+ guard !opened, !closed else { return }
+ opened = true
+ guard await capture.permitted() else {
+ micProblem = AudioSelfTest.noPermission
+ return
+ }
+ guard !closed else { return }
+ await startCapture()
+ refreshNames()
+ ticker = Task { [weak self] in
+ while !Task.isCancelled {
+ try? await Task.sleep(for: .milliseconds(100))
+ guard let self, !Task.isCancelled else { return }
+ self.tick()
+ }
+ }
+ }
+
+ /// Stops everything this test started and throws any recording away.
+ /// Called when the view closes; safe to call twice.
+ public func close() {
+ guard !closed else { return }
+ closed = true
+ ticker?.cancel()
+ ticker = nil
+ player.stop()
+ if !capture.inCall { capture.denoiser.sink = nil }
+ if capturing { capture.stop() }
+ capturing = false
+ recorder.discard()
+ }
+
+ private func startCapture() async {
+ do {
+ try await capture.start()
+ guard !closed else { return capture.stop() }
+ capturing = true
+ micProblem = nil
+ judge.start(at: clock())
+ // Whatever was measured before this start is not this microphone's.
+ _ = capture.levels()
+ } catch {
+ capturing = false
+ micProblem = "The microphone would not start: \(error.localizedDescription)"
+ }
+ }
+
+ /// A different microphone was chosen: judge it afresh.
+ public func microphoneChanged() {
+ judge.start(at: clock())
+ refreshNames()
+ }
+
+ private func refreshNames() {
+ if microphoneName != capture.microphoneName { microphoneName = capture.microphoneName }
+ if speakerName != capture.speakerName { speakerName = capture.speakerName }
+ }
+
+ // MARK: The 10 Hz step
+
+ /// One read of the meters. Publishes only what changed, so a view is
+ /// redrawn at most ten times a second and usually less.
+ func tick() {
+ let (input, output) = capture.levels()
+ let out = outputMeter.take()
+ let playing = playingSince != nil
+ set(\.outputStep, playing ? MeterSteps.step(out) : 0)
+ if case .recording = recording {
+ set(\.recordingProgress, (min(recorder.recorded / recordSeconds, 1) * 10).rounded(.down) / 10)
+ }
+ guard capturing, !playing else {
+ set(\.micStep, 0)
+ return
+ }
+ if capture.inCall, capture.muted {
+ set(\.micStep, 0)
+ set(\.micProblem, AudioSelfTest.mutedInCall)
+ judge.start(at: clock())
+ return
+ }
+ if micProblem == AudioSelfTest.mutedInCall { micProblem = nil }
+ set(\.micStep, output.buffers > 0 ? MeterSteps.step(output.rms) : 0)
+ let verdict = judge.observe(
+ input: input, output: output, at: clock(), captureExpected: capture.captureExpected)
+ set(\.micVerdict, verdict)
+ refreshNames()
+ }
+
+ private func set<Value: Equatable>(_ key: ReferenceWritableKeyPath<AudioSelfTest, Value>, _ value: Value) {
+ if self[keyPath: key] != value { self[keyPath: key] = value }
+ }
+
+ // MARK: Speaker
+
+ public func playTestSound() async {
+ guard !capture.inCall, !closed, !busy else { return }
+ speaker = .playing
+ do {
+ try await playing { try await player.play(TestChime.samples(), sampleRate: TestChime.rate, speaker: capture.speakerID, meter: outputMeter) }
+ if !closed { speaker = .played }
+ } catch {
+ if !closed { speaker = .failed(error.localizedDescription) }
+ }
+ }
+
+ // MARK: Record and play back
+
+ /// Records `recordSeconds` from the capture hook's sink — what a call
+ /// would send — and plays it back once. The audio is held in memory
+ /// only, handed to the player, and gone when this returns.
+ public func recordAndPlayBack() async {
+ guard !capture.inCall, !closed, !busy else { return }
+ guard capturing else {
+ recording = .failed(micProblem ?? "The microphone is not running.")
+ return
+ }
+ let recorder = self.recorder
+ recorder.arm()
+ recordingProgress = 0
+ recording = .recording
+ capture.denoiser.sink = { samples, frames, rate in recorder.append(samples, frames: frames, rate: rate) }
+ let deadline = clock() + recordSeconds + 3
+ while !recorder.full, clock() < deadline, !closed {
+ try? await Task.sleep(for: .milliseconds(100))
+ }
+ capture.denoiser.sink = nil
+ 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))")
+ return
+ }
+ let rms = SampleLevel.rms(taken.samples)
+ lastRecording = RecordingSummary(
+ seconds: Double(taken.samples.count) / Double(taken.rate), rate: taken.rate, rms: rms)
+ recordingProgress = 1
+ recording = .playing
+ do {
+ let samples = taken.samples
+ let rate = Double(taken.rate)
+ taken.samples = []
+ try await playing { try await player.play(samples, sampleRate: rate, speaker: capture.speakerID, meter: outputMeter) }
+ if !closed { recording = .played(silent: rms < MicJudge.silenceRMS) }
+ } catch {
+ if !closed { recording = .failed(error.localizedDescription) }
+ }
+ }
+
+ private var busy: Bool {
+ speaker == .playing || recording == .recording || recording == .playing
+ }
+
+ /// Runs `body` with the microphone's readings set aside (they would be
+ /// this test's own sound), stopping the capture where it must stop, and
+ /// judging on from where it left off.
+ private func playing(_ body: () async throws -> Void) async throws {
+ let started = clock()
+ playingSince = started
+ let restart = capturing && capture.stopsForPlayback
+ if restart {
+ capture.stop()
+ capturing = false
+ }
+ var failure: Error?
+ do { try await body() } catch { failure = error }
+ playingSince = nil
+ outputStep = 0
+ if restart, !closed { await startCapture() }
+ judge.resume(after: clock() - started)
+ if let failure { throw failure }
+ }
+
+ // MARK: Words
+
+ static let noPermission =
+ "Calls doesn’t have permission to use the microphone. Allow it in Settings, under Privacy & Security, Microphone."
+ static let mutedInCall = "You’re muted. Unmute to test your microphone."
+ public static let inCallPlayback =
+ "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)
+ }
+ public var micAdvice: String? { micProblem == nil ? MicJudge.advice(micVerdict) : nil }
+
+ public var speakerSentence: String? {
+ let name = speakerName.isEmpty ? "your speaker" : speakerName
+ switch speaker {
+ case .idle: return nil
+ case .playing: return "Playing a test sound through \(name)."
+ case .played:
+ return "Played a test sound through \(name). If you didn’t hear it, check the volume and the speaker chosen here."
+ case .failed(let why): return "The test sound didn’t play: \(why)"
+ case .unavailableInCall: return AudioSelfTest.inCallPlayback
+ }
+ }
+
+ public var recordingSentence: String? {
+ switch recording {
+ case .idle: return nil
+ case .recording: return "Recording. Speak as you would in a call."
+ case .playing: return "Playing back what others would hear."
+ case .played(let silent):
+ return silent
+ ? "The recording was silent. \(MicJudge.sentence(.silent, device: microphoneName))"
+ : "That is how you sound to others. The recording has been deleted."
+ case .failed(let why): return why
+ case .unavailableInCall: return nil
+ }
+ }
+}
+
+extension Call {
+ /// The person's Reduce background noise setting, as a call reads it.
+ static var rememberedReduceNoise: Bool {
+ UserDefaults.standard.object(forKey: Call.reduceNoiseKey) as? Bool ?? true
+ }
+}
+
+// MARK: - Captures
+
+/// The microphone before a call: a CallAudio of the test's own, recording
+/// on its own. See the top of this file.
+@MainActor final class StandaloneCapture: AudioSelfTestCapture {
+ let routes: AudioRoutes
+ let audio: CallAudio
+ let reduceNoise: Bool
+ private let synthetic: Bool
+ private var track: RTCAudioTrack?
+ private var loopback: SelfTestLoopback?
+ #if os(iOS)
+ private var session: SavedSession?
+ #endif
+
+ init(routes: AudioRoutes, input: AudioInputSource?, reduceNoise: Bool) {
+ self.routes = routes
+ self.reduceNoise = reduceNoise
+ synthetic = input != nil
+ #if os(macOS)
+ audio = CallAudio(routes: routes, audioDevice: nil, input: input, reduceNoise: reduceNoise)
+ #else
+ audio = CallAudio(routes: routes, audioDevice: nil, reduceNoise: reduceNoise)
+ #endif
+ }
+
+ var denoiser: CaptureDenoiser { audio.denoiser }
+ var inCall: Bool { false }
+ var captureExpected: Bool { true }
+ var muted: Bool { false }
+ #if os(iOS)
+ var stopsForPlayback: Bool { true }
+ #else
+ var stopsForPlayback: Bool { false }
+ #endif
+
+ var microphoneName: String { AudioSelfTest.inputName(routes) }
+ var speakerName: String { routes.outputName }
+ var speakerID: String? { routes.currentOutput }
+
+ func permitted() async -> Bool {
+ if synthetic { return true }
+ #if os(macOS)
+ switch AVCaptureDevice.authorizationStatus(for: .audio) {
+ case .authorized: return true
+ case .notDetermined: return await AVCaptureDevice.requestAccess(for: .audio)
+ default: return false
+ }
+ #else
+ switch AVAudioApplication.shared.recordPermission {
+ case .granted: return true
+ case .undetermined: return await AVAudioApplication.requestRecordPermission()
+ default: return false
+ }
+ #endif
+ }
+
+ func start() async throws {
+ #if os(iOS)
+ if session == nil { session = try SavedSession.configureForCall() }
+ #endif
+ // Made here, as a call makes its own: making it applies the call's
+ // processing options (CallAudio.apply). CallAudio holds it weakly.
+ let track = audio.makeTrack()
+ self.track = track
+ let loop = try await SelfTestLoopback(factory: audio.factory, track: track)
+ guard self.track === track else { return loop.close() }
+ loopback = loop
+ routes.refresh()
+ }
+
+ func stop() {
+ loopback?.close()
+ loopback = nil
+ track = nil
+ #if os(iOS)
+ session?.restore()
+ session = nil
+ #endif
+ }
+
+ func levels() -> (input: CaptureLevel.Reading, output: CaptureLevel.Reading) {
+ (audio.denoiser.inputLevel.take(), audio.denoiser.level.take())
+ }
+
+}
+
+/// The microphone during a call: the call's own hook, read only.
+///
+/// The hook's two meters are the call's to read — its own microphone
+/// meter takes from them, and a peak-hold read by two readers gives each
+/// half the peaks — so the test measures from the hook's sink instead,
+/// which the call does not use. That is the processed audio, so here the
+/// "can't hear anything" line is drawn after the suppressor too. It is
+/// still -90 dBFS: the suppressor takes at most 30 dB
+/// (noiseAttenuationLimitDb), so a room above -60 dBFS stays above it.
+@MainActor final class LiveCallCapture: AudioSelfTestCapture {
+ private weak var call: Call?
+ let denoiser: CaptureDenoiser
+ let reduceNoise: Bool
+ private let tapped = CaptureLevel()
+
+ init?(_ call: Call) {
+ guard let denoiser = call.captureDenoiser else { return nil }
+ self.call = call
+ self.denoiser = denoiser
+ reduceNoise = call.reduceNoise
+ }
+
+ var inCall: Bool { true }
+ var captureExpected: Bool { call?.captureStarted ?? false }
+ var muted: Bool { call?.muted ?? false }
+ var stopsForPlayback: Bool { false }
+ var microphoneName: String { call.map { AudioSelfTest.inputName($0.audio) } ?? "" }
+ var speakerName: String { call?.audio.outputName ?? "" }
+ var speakerID: String? { call?.audio.currentOutput }
+ func permitted() async -> Bool { true }
+ func start() async throws {
+ let tapped = self.tapped
+ denoiser.sink = { samples, frames, _ in tapped.record(samples, frames: frames, at: 0) }
+ }
+ func stop() { denoiser.sink = nil }
+ func levels() -> (input: CaptureLevel.Reading, output: CaptureLevel.Reading) {
+ let reading = tapped.take()
+ return (reading, reading)
+ }
+}
+
+extension AudioSelfTest {
+ /// The chosen microphone's name as the pickers list it.
+ static func inputName(_ routes: AudioRoutes) -> String {
+ routes.inputs.first { $0.id == routes.currentInput }?.name ?? ""
+ }
+}
+
+#if os(iOS)
+ /// The audio session as the test found it, put back when it is done.
+ /// Outside a call nothing else is using the session, so the test sets
+ /// it up as a call would (play and record, video chat, no hands-free
+ /// Bluetooth) and, at the end, restores the category and deactivates it
+ /// if it was not active before.
+ @MainActor struct SavedSession {
+ let category: AVAudioSession.Category
+ let mode: AVAudioSession.Mode
+ let options: AVAudioSession.CategoryOptions
+ let wasActive: Bool
+
+ static func configureForCall() throws -> SavedSession {
+ let session = RTCAudioSession.sharedInstance()
+ let saved = SavedSession(
+ category: AVAudioSession.Category(rawValue: session.category),
+ mode: AVAudioSession.Mode(rawValue: session.mode), options: session.categoryOptions,
+ wasActive: session.isActive)
+ session.lockForConfiguration()
+ defer { session.unlockForConfiguration() }
+ try session.setCategory(
+ AVAudioSession.Category.playAndRecord, mode: AVAudioSession.Mode.videoChat,
+ options: AudioRoutes.options(bluetoothInput: false))
+ return saved
+ }
+
+ func restore() {
+ let session = RTCAudioSession.sharedInstance()
+ session.lockForConfiguration()
+ defer { session.unlockForConfiguration() }
+ try? session.setCategory(category, mode: mode, options: options)
+ if !wasActive, session.isActive { try? session.setActive(false) }
+ }
+ }
+#endif
+
+// MARK: - Playing
+
+/// Plays through an AVAudioEngine of its own, apart from WebRTC's.
+///
+/// On Mac the engine's output unit is pointed at the chosen speaker (the
+/// UID the call's module lists it by; the system default if it cannot be
+/// found). It only plays outside a call, so it never shares a device with
+/// the call's engine. On iPhone it plays on the session the test set up,
+/// to whatever the route is — the speaker a call would use.
+@MainActor final class EnginePlayer: AudioSelfTestPlayer {
+ private var engine: AVAudioEngine?
+ private var node: AVAudioPlayerNode?
+ private var waiting: CheckedContinuation<Void, Error>?
+ /// Which playback a completion belongs to: one from a sound that was
+ /// stopped must not end the next.
+ private var generation = 0
+ #if os(iOS)
+ /// RTCAudioSession counts activations, and only the last balanced
+ /// deactivation lets the session go; each play is balanced in `finish`.
+ private var activated = false
+ #endif
+
+ func play(_ samples: [Float], sampleRate: Double, speaker: String?, meter: OutputMeter) async throws {
+ stop()
+ guard !samples.isEmpty,
+ let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1),
+ let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(samples.count)),
+ let channel = buffer.floatChannelData?[0]
+ else { return }
+ buffer.frameLength = AVAudioFrameCount(samples.count)
+ samples.withUnsafeBufferPointer { channel.update(from: $0.baseAddress!, count: samples.count) }
+ defer { channel.update(repeating: 0, count: samples.count) }
+
+ let engine = AVAudioEngine()
+ #if os(macOS)
+ if let speaker, let device = EnginePlayer.device(uid: speaker), let unit = engine.outputNode.audioUnit {
+ var id = device
+ AudioUnitSetProperty(
+ unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0, &id,
+ UInt32(MemoryLayout<AudioDeviceID>.size))
+ }
+ #else
+ let session = RTCAudioSession.sharedInstance()
+ session.lockForConfiguration()
+ activated = (try? session.setActive(true)) != nil
+ session.unlockForConfiguration()
+ #endif
+ let node = AVAudioPlayerNode()
+ engine.attach(node)
+ engine.connect(node, to: engine.mainMixerNode, format: format)
+ engine.mainMixerNode.installTap(onBus: 0, bufferSize: 1024, format: nil) { tapped, _ in
+ guard let data = tapped.floatChannelData?[0] else { return }
+ meter.record(data, count: Int(tapped.frameLength))
+ }
+ self.engine = engine
+ self.node = node
+ do { try engine.start() } catch {
+ finish()
+ throw error
+ }
+ generation += 1
+ let playback = generation
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
+ waiting = continuation
+ node.scheduleBuffer(buffer, at: nil, options: [], completionCallbackType: .dataPlayedBack) {
+ [weak self] _ in
+ DispatchQueue.main.async {
+ MainActor.assumeIsolated {
+ guard let self, self.generation == playback else { return }
+ self.finish()
+ }
+ }
+ }
+ node.play()
+ }
+ }
+
+ private func finish() {
+ let waiting = self.waiting
+ self.waiting = nil
+ engine?.mainMixerNode.removeTap(onBus: 0)
+ node?.stop()
+ engine?.stop()
+ node = nil
+ engine = nil
+ #if os(iOS)
+ if activated {
+ activated = false
+ let session = RTCAudioSession.sharedInstance()
+ session.lockForConfiguration()
+ try? session.setActive(false)
+ session.unlockForConfiguration()
+ }
+ #endif
+ waiting?.resume()
+ }
+
+ func stop() { finish() }
+
+ #if os(macOS)
+ /// A CoreAudio device from its UID, or nil.
+ static func device(uid: String) -> AudioDeviceID? {
+ guard !uid.isEmpty else { return nil }
+ var address = AudioObjectPropertyAddress(
+ mSelector: kAudioHardwarePropertyTranslateUIDToDevice, mScope: kAudioObjectPropertyScopeGlobal,
+ mElement: kAudioObjectPropertyElementMain)
+ var device = AudioDeviceID(kAudioObjectUnknown)
+ var size = UInt32(MemoryLayout<AudioDeviceID>.size)
+ let cf = uid as CFString
+ let status = withUnsafePointer(to: cf) { qualifier in
+ AudioObjectGetPropertyData(
+ AudioObjectID(kAudioObjectSystemObject), &address, UInt32(MemoryLayout<CFString>.size),
+ qualifier, &size, &device)
+ }
+ guard status == noErr, device != kAudioObjectUnknown else { return nil }
+ return device
+ }
+ #endif
+}
diff --git a/native/apple/Sources/CallsKit/AudioSelfTestView.swift b/native/apple/Sources/CallsKit/AudioSelfTestView.swift
new file mode 100644
index 0000000..fb4a4bc
--- /dev/null
+++ b/native/apple/Sources/CallsKit/AudioSelfTestView.swift
@@ -0,0 +1,275 @@
+import SwiftUI
+
+/// "Test audio": one button, on the home screen and in the device panel,
+/// opening the test in a sheet. The test runs while the sheet is open and
+/// stops, recording and all, when it closes.
+@MainActor public struct AudioSelfTestButton: View {
+ private let call: Call?
+ @State private var testing = false
+
+ /// `call` in a call, where the test reads that call's microphone and
+ /// plays nothing; nil on the home screen.
+ public init(call: Call? = nil) {
+ self.call = call
+ }
+
+ public var body: some View {
+ Button("Test audio", systemImage: "waveform") { testing = true }
+ .accessibilityIdentifier("testAudio")
+ .accessibilityHint("Checks your microphone and speaker.")
+ .sheet(isPresented: $testing) {
+ AudioSelfTestSheet(call: call) { testing = false }
+ }
+ }
+}
+
+@MainActor private struct AudioSelfTestSheet: View {
+ @StateObject private var test: AudioSelfTest
+ let done: () -> Void
+
+ init(call: Call?, done: @escaping () -> Void) {
+ _test = StateObject(wrappedValue: call.map { AudioSelfTest.during($0) } ?? AudioSelfTest.standalone())
+ self.done = done
+ }
+
+ var body: some View {
+ #if os(iOS)
+ NavigationStack {
+ ScrollView { AudioSelfTestView(test: test).padding(20) }
+ .navigationTitle("Test audio")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done", action: done) } }
+ }
+ .task { await test.open() }
+ .onDisappear { test.close() }
+ #else
+ VStack(alignment: .leading, spacing: 0) {
+ Text("Test audio").font(.title2.bold()).padding([.horizontal, .top], 20)
+ ScrollView { AudioSelfTestView(test: test).padding(20) }
+ HStack {
+ Spacer()
+ Button("Done", action: done).keyboardShortcut(.defaultAction)
+ }.padding(16)
+ }
+ .frame(minWidth: 420, idealWidth: 460, minHeight: 480, idealHeight: 560)
+ .task { await test.open() }
+ .onDisappear { test.close() }
+ #endif
+ }
+}
+
+/// The test itself: microphone, speaker, and hear yourself, each with a
+/// sentence saying what was found. Every meter has its words beside it.
+@MainActor public struct AudioSelfTestView: View {
+ @ObservedObject private var test: AudioSelfTest
+
+ public init(test: AudioSelfTest) {
+ self.test = test
+ }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 24) {
+ microphone
+ Divider()
+ speaker
+ Divider()
+ hearYourself
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ // MARK: Microphone
+
+ private var microphone: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Text("Microphone").font(.headline)
+ #if os(macOS)
+ if let routes = test.routes {
+ DevicePicker(
+ label: "Microphone", routes: routes, input: true, identifier: "testMicrophonePicker",
+ changed: test.microphoneChanged)
+ } else if !test.microphoneName.isEmpty {
+ Text(test.microphoneName).foregroundStyle(.secondary)
+ }
+ #else
+ if !test.microphoneName.isEmpty { Text(test.microphoneName).foregroundStyle(.secondary) }
+ #endif
+ LevelMeter(step: test.micStep, label: "Microphone level")
+ Label(test.micSentence, systemImage: micSymbol)
+ .font(.callout)
+ .fixedSize(horizontal: false, vertical: true)
+ .accessibilityIdentifier("testMicrophoneResult")
+ if let advice = test.micAdvice {
+ Text(advice).font(.footnote).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ Text(
+ test.reduceNoise
+ ? "Measured after background noise reduction, as others hear you."
+ : "Background noise reduction is off, so this is your microphone as it is."
+ )
+ .font(.footnote).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+
+ private var micSymbol: String {
+ if test.micProblem != nil { return "exclamationmark.triangle" }
+ switch test.micVerdict {
+ case .working: return "checkmark.circle"
+ case .silent: return "exclamationmark.triangle"
+ case .listening, .hearing, .notStarted: return "ear"
+ }
+ }
+
+ // MARK: Speaker
+
+ private var speaker: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Text("Speaker").font(.headline)
+ if test.inCall {
+ Text(AudioSelfTest.inCallPlayback).font(.callout).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ } else {
+ #if os(macOS)
+ if let routes = test.routes {
+ DevicePicker(
+ label: "Speaker", routes: routes, input: false, identifier: "testSpeakerPicker", changed: {})
+ }
+ #else
+ if !test.speakerName.isEmpty { Text(test.speakerName).foregroundStyle(.secondary) }
+ #endif
+ Button("Play a test sound", systemImage: "speaker.wave.2") {
+ Task { await test.playTestSound() }
+ }
+ .buttonStyle(.bordered)
+ .disabled(busy)
+ .accessibilityIdentifier("testSpeaker")
+ if test.speaker == .playing {
+ LevelMeter(step: test.outputStep, label: "Speaker level")
+ }
+ if let line = test.speakerSentence {
+ Text(line).font(.callout).fixedSize(horizontal: false, vertical: true)
+ .accessibilityIdentifier("testSpeakerResult")
+ }
+ }
+ }
+ }
+
+ // MARK: Hear yourself
+
+ private var hearYourself: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Text("Hear yourself as others will").font(.headline)
+ if test.inCall {
+ Text(AudioSelfTest.inCallPlayback).font(.callout).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ } else {
+ Text(
+ "Records \(Int(test.recordSeconds)) seconds through the same sound processing a call uses, then plays it back. The recording stays in memory on this device and is deleted after it plays."
+ )
+ .font(.footnote).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ Button("Record and play back", systemImage: "record.circle") {
+ Task { await test.recordAndPlayBack() }
+ }
+ .buttonStyle(.bordered)
+ .disabled(busy || test.micProblem != nil)
+ .accessibilityIdentifier("testRecord")
+ if test.recording == .recording {
+ ProgressView(value: test.recordingProgress)
+ .accessibilityLabel("Recording")
+ .accessibilityValue("\(Int(test.recordingProgress * 100)) percent")
+ }
+ if test.recording == .playing {
+ LevelMeter(step: test.outputStep, label: "Playback level")
+ }
+ if let line = test.recordingSentence {
+ Text(line).font(.callout).fixedSize(horizontal: false, vertical: true)
+ .accessibilityIdentifier("testRecordResult")
+ }
+ }
+ }
+ }
+
+ private var busy: Bool {
+ test.speaker == .playing || test.recording == .recording || test.recording == .playing
+ }
+}
+
+#if os(macOS)
+ /// The test's own device choice before a call. The same remembered
+ /// choice the device panel makes, so what is chosen here is what the
+ /// next call uses.
+ @MainActor private struct DevicePicker: View {
+ let label: String
+ @ObservedObject var routes: AudioRoutes
+ let input: Bool
+ let identifier: String
+ let changed: () -> Void
+ @State private var problem = ""
+
+ var body: some View {
+ let list = input ? routes.inputs : routes.outputs
+ let current = input ? routes.currentInput : routes.currentOutput
+ VStack(alignment: .leading, spacing: 4) {
+ if list.isEmpty {
+ Text("No \(label.lowercased()) found").foregroundStyle(.secondary)
+ } else {
+ Picker(
+ label,
+ selection: Binding(
+ get: { current },
+ set: { id in
+ guard let id, id != current else { return }
+ do {
+ try input ? routes.use(input: id) : routes.use(output: id)
+ problem = ""
+ changed()
+ } catch { problem = error.localizedDescription }
+ })
+ ) {
+ if current == nil { Text("Choose a device").tag(Optional<String>.none) }
+ ForEach(list) { Text($0.name).tag(Optional($0.id)) }
+ }
+ .pickerStyle(.menu)
+ .labelsHidden()
+ .accessibilityLabel(label)
+ .accessibilityIdentifier(identifier)
+ }
+ if !problem.isEmpty {
+ Text(problem).font(.callout).foregroundStyle(.red)
+ }
+ }
+ }
+ }
+#endif
+
+/// 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
+/// words.
+@MainActor struct LevelMeter: View {
+ let step: Int
+ let label: String
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+ var body: some View {
+ HStack(spacing: 3) {
+ ForEach(0..<MeterSteps.count, id: \.self) { i in
+ RoundedRectangle(cornerRadius: 2)
+ .fill(i < step ? color(i) : Color.secondary.opacity(0.2))
+ .frame(height: 10)
+ }
+ }
+ .frame(maxWidth: 320)
+ .animation(reduceMotion ? nil : .easeOut(duration: 0.08), value: step)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(label)
+ .accessibilityValue(MeterSteps.words(step))
+ }
+
+ private func color(_ i: Int) -> Color {
+ i >= MeterSteps.count - 1 ? .orange : CallsAppearance.accent
+ }
+}
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift
index 12c1697..1fce599 100644
--- a/native/apple/Sources/CallsKit/Call.swift
+++ b/native/apple/Sources/CallsKit/Call.swift
@@ -109,6 +109,10 @@ private final class Events: NSObject, BridgeListenerProtocol {
private var bridge: BridgeClient?
private var events: Events?
private lazy var media = MediaEngine(call: self)
+ /// The audio test's view of this call's microphone (AudioSelfTestModel):
+ /// 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 }
private var ice: ICEConfig?
private var pendingMedia: [Message] = []
fileprivate var invalidEvents: UInt64 = 0
diff --git a/native/apple/Sources/CallsKit/CallsLobby.swift b/native/apple/Sources/CallsKit/CallsLobby.swift
index 37de59e..acf5a40 100644
--- a/native/apple/Sources/CallsKit/CallsLobby.swift
+++ b/native/apple/Sources/CallsKit/CallsLobby.swift
@@ -114,6 +114,9 @@ import SwiftUI
in: RoundedRectangle(cornerRadius: 20)
)
.overlay { RoundedRectangle(cornerRadius: 20).strokeBorder(.primary.opacity(0.08)) }
+ // Before a call, signed in or not: "can they hear me?" is asked
+ // before joining, and needs no account to answer.
+ AudioSelfTestButton().buttonStyle(.bordered)
Label("A little closer, wherever you are.", systemImage: "sparkle")
.font(.callout).foregroundStyle(.secondary)
}.frame(maxWidth: 480, alignment: .leading).padding(32)
diff --git a/native/apple/Sources/CallsKit/DevicePanel.swift b/native/apple/Sources/CallsKit/DevicePanel.swift
index 5d1bf8b..74c018f 100644
--- a/native/apple/Sources/CallsKit/DevicePanel.swift
+++ b/native/apple/Sources/CallsKit/DevicePanel.swift
@@ -52,6 +52,8 @@ import SwiftUI
noiseToggle
+ AudioSelfTestButton(call: call).buttonStyle(.bordered)
+
screenSection
audioFoot
diff --git a/native/apple/Tests/CallsKitTests/AudioSelfTestTests.swift b/native/apple/Tests/CallsKitTests/AudioSelfTestTests.swift
new file mode 100644
index 0000000..4151fbe
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/AudioSelfTestTests.swift
@@ -0,0 +1,377 @@
+import Foundation
+import XCTest
+
+@testable import CallsKit
+
+private func reading(_ rms: Float, buffers: Int = 10) -> CaptureLevel.Reading {
+ CaptureLevel.Reading(rms: rms, peak: rms, buffers: buffers, at: 0)
+}
+private func dbfs(_ db: Float) -> Float { pow(10, db / 20) }
+
+/// The microphone verdict: working on the processed level, silent on the
+/// level before the suppressor, over a five-second window.
+final class MicJudgeTests: XCTestCase {
+ func testSpeechAfterTheSuppressorIsWorking() {
+ var judge = MicJudge()
+ judge.start(at: 0)
+ XCTAssertEqual(judge.observe(input: reading(0), output: reading(0), at: 0.1), .listening)
+ XCTAssertEqual(
+ judge.observe(input: reading(dbfs(-30)), output: reading(dbfs(-40)), at: 1), .working)
+ // Stays working between words.
+ XCTAssertEqual(
+ judge.observe(input: reading(dbfs(-60)), output: reading(dbfs(-80)), at: 3), .working)
+ }
+
+ func testFiveSecondsOfNothingBeforeTheSuppressorIsSilent() {
+ var judge = MicJudge()
+ 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.")
+ XCTAssertNotNil(MicJudge.advice(.silent))
+ }
+
+ func testNoBuffersAtAllIsSilentToo() {
+ var judge = MicJudge()
+ judge.start(at: 0)
+ XCTAssertEqual(judge.observe(input: reading(0, buffers: 0), output: reading(0, buffers: 0), at: 6), .silent)
+ }
+
+ /// A quiet room DeepFilterNet has taken down 30 dB: dead after the
+ /// suppressor, alive before it. Not "can't hear anything".
+ func testAQuietRoomPulledDownByTheSuppressorIsNotSilent() {
+ var judge = MicJudge()
+ judge.start(at: 0)
+ for t in stride(from: 0.1, through: 8, by: 0.1) {
+ judge.observe(input: reading(dbfs(-65)), output: reading(dbfs(-95)), at: t)
+ }
+ XCTAssertEqual(judge.verdict, .hearing)
+ XCTAssertTrue(MicJudge.sentence(.hearing, device: "MacBook Pro Microphone").contains("MacBook Pro Microphone"))
+ }
+
+ func testSpeechBelowTheLineIsHearingNotWorking() {
+ var judge = MicJudge()
+ judge.start(at: 0)
+ XCTAssertEqual(judge.observe(input: reading(dbfs(-50)), output: reading(dbfs(-50)), at: 1), .hearing)
+ }
+
+ func testWorkingThatGoesDeadIsSaidAndComesBack() {
+ var judge = MicJudge()
+ judge.start(at: 0)
+ judge.observe(input: reading(dbfs(-20)), output: reading(dbfs(-20)), at: 1)
+ XCTAssertEqual(judge.observe(input: reading(0), output: reading(0), at: 5.9), .working)
+ XCTAssertEqual(judge.observe(input: reading(0), output: reading(0), at: 6), .silent)
+ XCTAssertEqual(judge.observe(input: reading(dbfs(-60)), output: reading(dbfs(-70)), at: 7), .hearing)
+ XCTAssertEqual(judge.observe(input: reading(dbfs(-20)), output: reading(dbfs(-20)), at: 8), .working)
+ }
+
+ /// In a call alone, WebRTC has not opened the microphone: no audio is
+ /// not the device's fault and is not said to be.
+ func testACallThatHasNotStartedTheMicrophoneIsNotCalledSilent() {
+ var judge = MicJudge()
+ judge.start(at: 0)
+ XCTAssertEqual(
+ judge.observe(input: reading(0, buffers: 0), output: reading(0, buffers: 0), at: 6, captureExpected: false),
+ .notStarted)
+ // Buffers of silence, though, are the device.
+ XCTAssertEqual(
+ judge.observe(input: reading(0), output: reading(0), at: 12, captureExpected: false), .silent)
+ }
+
+ func testTimeSpentPlayingDoesNotCountAsSilence() {
+ var judge = MicJudge()
+ judge.start(at: 0)
+ judge.observe(input: reading(dbfs(-20)), output: reading(dbfs(-20)), at: 1)
+ judge.resume(after: 10) // the test played for ten seconds
+ XCTAssertEqual(judge.observe(input: reading(0), output: reading(0), at: 12), .working)
+ }
+}
+
+final class MeterStepsTests: XCTestCase {
+ func testStepsAreFiveDecibelsFromMinusSixty() {
+ XCTAssertEqual(MeterSteps.step(0), 0)
+ XCTAssertEqual(MeterSteps.step(dbfs(-70)), 0)
+ XCTAssertEqual(MeterSteps.step(dbfs(-60)), 1)
+ XCTAssertEqual(MeterSteps.step(dbfs(-26)), 7)
+ XCTAssertEqual(MeterSteps.step(1), MeterSteps.count)
+ XCTAssertEqual(MeterSteps.words(0), "silent")
+ XCTAssertEqual(MeterSteps.words(7), "speaking level")
+ }
+}
+
+final class SelfTestRecorderTests: XCTestCase {
+ private func feed(_ recorder: SelfTestRecorder, value: Float, buffers: Int, frames: Int = 480, rate: Int = 48_000) {
+ let buffer = [Float](repeating: value, count: frames)
+ for _ in 0..<buffers {
+ buffer.withUnsafeBufferPointer { recorder.append($0.baseAddress!, frames: frames, rate: rate) }
+ }
+ }
+
+ func testNothingIsKeptUntilArmed() {
+ let recorder = SelfTestRecorder(seconds: 0.1)
+ feed(recorder, value: 1000, buffers: 5)
+ XCTAssertEqual(recorder.recorded, 0)
+ }
+
+ func testRecordsItsLengthAndNoMore() {
+ let recorder = SelfTestRecorder(seconds: 0.1)
+ recorder.arm()
+ feed(recorder, value: 16_384, buffers: 25)
+ XCTAssertTrue(recorder.full)
+ let (samples, rate) = recorder.take()
+ XCTAssertEqual(rate, 48_000)
+ XCTAssertEqual(samples.count, 4_800)
+ XCTAssertEqual(samples.first, 0.5, "WebRTC's scale in, -1...1 out")
+ }
+
+ func testTakingEmptiesIt() {
+ let recorder = SelfTestRecorder(seconds: 0.1)
+ recorder.arm()
+ feed(recorder, value: 1000, buffers: 3)
+ _ = recorder.take()
+ XCTAssertEqual(recorder.recorded, 0)
+ XCTAssertTrue(recorder.take().samples.isEmpty)
+ feed(recorder, value: 1000, buffers: 3)
+ XCTAssertEqual(recorder.recorded, 0, "not armed again by taking")
+ }
+
+ func testDiscardThrowsItAway() {
+ let recorder = SelfTestRecorder(seconds: 0.1)
+ recorder.arm()
+ feed(recorder, value: 1000, buffers: 3)
+ recorder.discard()
+ XCTAssertEqual(recorder.recorded, 0)
+ XCTAssertTrue(recorder.take().samples.isEmpty)
+ }
+
+ func testARateChangeStartsAgain() {
+ let recorder = SelfTestRecorder(seconds: 0.1)
+ recorder.arm()
+ feed(recorder, value: 1000, buffers: 3, frames: 160, rate: 16_000)
+ feed(recorder, value: 1000, buffers: 2)
+ let (samples, rate) = recorder.take()
+ XCTAssertEqual(rate, 48_000)
+ XCTAssertEqual(samples.count, 960)
+ }
+}
+
+final class TestChimeTests: XCTestCase {
+ func testTheChimeIsAudibleNeverClipsAndEndsInSilence() {
+ let chime = TestChime.samples()
+ XCTAssertEqual(Double(chime.count) / TestChime.rate, 1.6, accuracy: 0.001)
+ XCTAssertLessThan(chime.map(abs).max()!, 0.9)
+ XCTAssertGreaterThan(SampleLevel.db(SampleLevel.rms(chime)), -30)
+ XCTAssertEqual(chime.last, 0)
+ }
+}
+
+// MARK: - The controller
+
+private final class PassThrough: NoiseSuppressor {
+ let name = "Pass"
+ let frameLength = 480
+ func process(_ frame: UnsafeMutablePointer<Float>) -> Float { 1 }
+}
+
+private final class TestClock: @unchecked Sendable {
+ var now = 1000.0
+}
+
+@MainActor private final class FakeCapture: AudioSelfTestCapture {
+ let denoiser = CaptureDenoiser(suppressor: PassThrough())
+ var microphoneName = "Scarlett 18i8 USB"
+ var speakerName = "MacBook Pro Speakers"
+ var speakerID: String? = "speakers"
+ var inCall = false
+ var captureExpected = true
+ var muted = false
+ var reduceNoise = true
+ var stopsForPlayback = false
+ var allowed = true
+ var starts = 0
+ var stops = 0
+
+ init() { denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1) }
+
+ func permitted() async -> Bool { allowed }
+ func start() async throws { starts += 1 }
+ func stop() { stops += 1 }
+ func levels() -> (input: CaptureLevel.Reading, output: CaptureLevel.Reading) {
+ (denoiser.inputLevel.take(), denoiser.level.take())
+ }
+
+ /// 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) }
+ 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)
+ }
+ }
+}
+
+@MainActor private final class FakePlayer: AudioSelfTestPlayer {
+ var played: [[Float]] = []
+ var rates: [Double] = []
+ func play(_ samples: [Float], sampleRate: Double, speaker: String?, meter: OutputMeter) async throws {
+ played.append(samples)
+ rates.append(sampleRate)
+ samples.withUnsafeBufferPointer { meter.record($0.baseAddress!, count: $0.count) }
+ }
+ func stop() {}
+}
+
+@MainActor final class AudioSelfTestModelTests: XCTestCase {
+ private func make(
+ _ configure: (FakeCapture) -> Void = { _ in }, seconds: Double = 0.2
+ ) -> (AudioSelfTest, FakeCapture, FakePlayer, TestClock) {
+ let capture = FakeCapture()
+ configure(capture)
+ let player = FakePlayer()
+ let clock = TestClock()
+ let test = AudioSelfTest(
+ capture: capture, player: player, routes: nil, recordSeconds: seconds, clock: { clock.now })
+ return (test, capture, player, clock)
+ }
+
+ func testSpeechIsWorkingAndMoves() async {
+ let (test, capture, _, clock) = make()
+ await test.open()
+ defer { test.close() }
+ capture.feed(8_000)
+ clock.now += 0.1
+ test.tick()
+ XCTAssertEqual(test.micVerdict, .working)
+ XCTAssertEqual(test.micSentence, "Your microphone is working.")
+ XCTAssertGreaterThan(test.micStep, 0)
+ }
+
+ func testSilenceForFiveSecondsNamesTheDevice() async {
+ let (test, capture, _, clock) = make()
+ await test.open()
+ defer { test.close() }
+ for _ in 0..<50 {
+ capture.feed(0)
+ clock.now += 0.1
+ test.tick()
+ }
+ XCTAssertEqual(test.micVerdict, .silent)
+ XCTAssertEqual(test.micStep, 0)
+ XCTAssertEqual(test.micSentence, "We can’t hear anything from Scarlett 18i8 USB.")
+ }
+
+ func testNoPermissionSaysSoAndStartsNothing() async {
+ let (test, capture, _, _) = make { $0.allowed = false }
+ await test.open()
+ XCTAssertEqual(test.micProblem, AudioSelfTest.noPermission)
+ XCTAssertEqual(capture.starts, 0)
+ test.close()
+ XCTAssertEqual(capture.stops, 0)
+ }
+
+ func testTheSpeakerTestPlaysTheChimeThroughTheChosenSpeaker() async {
+ let (test, _, player, _) = make()
+ await test.open()
+ defer { test.close() }
+ await test.playTestSound()
+ XCTAssertEqual(test.speaker, .played)
+ XCTAssertEqual(player.played.count, 1)
+ XCTAssertEqual(player.played.first?.count, TestChime.samples().count)
+ XCTAssertTrue(test.speakerSentence?.contains("MacBook Pro Speakers") ?? false)
+ }
+
+ func testRecordingPlaysBackWhatWasSentAndKeepsNothing() async {
+ let (test, capture, player, _) = make()
+ await test.open()
+ defer { test.close() }
+ let recording = Task { await test.recordAndPlayBack() }
+ while test.recording != .recording { await Task.yield() }
+ for _ in 0..<30 { capture.feed(8_000) }
+ await recording.value
+ XCTAssertEqual(test.recording, .played(silent: false))
+ XCTAssertEqual(player.played.count, 1)
+ XCTAssertEqual(player.played.first?.count, 9_600)
+ XCTAssertEqual(player.rates.first, 48_000)
+ XCTAssertEqual(test.lastRecording?.seconds ?? 0, 0.2, accuracy: 0.001)
+ XCTAssertFalse(test.holdsRecording)
+ XCTAssertNil(capture.denoiser.sink, "the tap comes off with the recording")
+ }
+
+ func testASilentRecordingIsSaidToBe() async {
+ let (test, capture, _, _) = make()
+ await test.open()
+ defer { test.close() }
+ let recording = Task { await test.recordAndPlayBack() }
+ while test.recording != .recording { await Task.yield() }
+ for _ in 0..<30 { capture.feed(0) }
+ await recording.value
+ XCTAssertEqual(test.recording, .played(silent: true))
+ XCTAssertTrue(test.recordingSentence?.contains("Scarlett 18i8 USB") ?? false)
+ }
+
+ func testNothingRecordedFailsInsteadOfWaitingForever() async {
+ let (test, _, player, clock) = make()
+ await test.open()
+ defer { test.close() }
+ let recording = Task { await test.recordAndPlayBack() }
+ while test.recording != .recording { await Task.yield() }
+ clock.now += 10
+ await recording.value
+ guard case .failed = test.recording else { return XCTFail("\(test.recording)") }
+ XCTAssertTrue(player.played.isEmpty)
+ }
+
+ func testClosingMidRecordingThrowsItAway() async {
+ let (test, capture, player, _) = make()
+ await test.open()
+ let recording = Task { await test.recordAndPlayBack() }
+ while test.recording != .recording { await Task.yield() }
+ for _ in 0..<5 { capture.feed(8_000) }
+ test.close()
+ await recording.value
+ XCTAssertFalse(test.holdsRecording)
+ XCTAssertTrue(player.played.isEmpty)
+ XCTAssertNil(capture.denoiser.sink)
+ XCTAssertEqual(capture.stops, 1)
+ }
+
+ func testInACallNothingPlaysAndMutedIsSaid() async {
+ let (test, capture, player, clock) = make {
+ $0.inCall = true
+ $0.muted = true
+ }
+ await test.open()
+ defer { test.close() }
+ XCTAssertEqual(test.speaker, .unavailableInCall)
+ XCTAssertEqual(test.recording, .unavailableInCall)
+ await test.playTestSound()
+ await test.recordAndPlayBack()
+ XCTAssertTrue(player.played.isEmpty)
+ clock.now += 0.1
+ test.tick()
+ XCTAssertEqual(test.micSentence, AudioSelfTest.mutedInCall)
+ capture.muted = false
+ capture.feed(8_000)
+ test.tick()
+ XCTAssertEqual(test.micVerdict, .working)
+ }
+
+ func testMeterOnlyPublishesChanges() async {
+ let (test, capture, _, clock) = make()
+ await test.open()
+ defer { test.close() }
+ var published = 0
+ let watch = test.objectWillChange.sink { published += 1 }
+ defer { watch.cancel() }
+ capture.feed(8_000)
+ test.tick()
+ let first = published
+ for _ in 0..<10 {
+ capture.feed(8_000)
+ clock.now += 0.1
+ test.tick()
+ }
+ XCTAssertEqual(published, first, "the same level and verdict are not published again")
+ }
+}
diff --git a/native/apple/Tests/CallsKitTests/NoiseSuppressionTests.swift b/native/apple/Tests/CallsKitTests/NoiseSuppressionTests.swift
index 6d0c267..04e3988 100644
--- a/native/apple/Tests/CallsKitTests/NoiseSuppressionTests.swift
+++ b/native/apple/Tests/CallsKitTests/NoiseSuppressionTests.swift
@@ -428,3 +428,133 @@ final class SuppressorFixtureTests: XCTestCase {
}
#endif
}
+
+/// The adapter in every case it does not process: the audio must leave it
+/// bit for bit as it came, and the meters and the sink must still see it.
+/// A meter blind to a passthrough is how a live microphone on a
+/// multichannel interface, or at 44.1 kHz, would read as dead in the audio
+/// test.
+final class CaptureAdapterPassthroughTests: XCTestCase {
+ /// A 440 Hz tone at about -12 dBFS, in WebRTC's ±32768 scale.
+ private static func tone(_ frames: Int, rate: Int) -> [Float] {
+ (0..<frames).map { 8_000 * sin(Float($0) * 2 * .pi * 440 / Float(rate)) }
+ }
+
+ private final class Tap: @unchecked Sendable {
+ private let lock = NSLock()
+ private var energy: Float = 0
+ private var calls = 0
+ private var rates: [Int] = []
+ func add(_ samples: UnsafePointer<Float>, _ frames: Int, _ rate: Int) {
+ lock.withLock {
+ calls += 1
+ rates.append(rate)
+ for i in 0..<frames { energy += samples[i] * samples[i] }
+ }
+ }
+ var seen: (calls: Int, energy: Float, rates: [Int]) { lock.withLock { (calls, energy, rates) } }
+ }
+
+ /// Drives one buffer as `audioProcessingProcess` does (input meter,
+ /// process, observe), and checks the passthrough and the meters.
+ private func check(
+ rate: Int, channels: Int, frames: Int, enabled: Bool = true,
+ file: StaticString = #filePath, line: UInt = #line
+ ) -> CaptureDenoiser {
+ let fake = FakeSuppressor()
+ let denoiser = CaptureDenoiser(suppressor: fake, clock: { 1 })
+ denoiser.enabled = enabled
+ denoiser.audioProcessingInitialize(sampleRate: rate, channels: channels)
+ let tap = Tap()
+ denoiser.sink = { tap.add($0, $1, $2) }
+
+ for (name, source) in [("tone", Self.tone(frames, rate: rate)), ("silence", [Float](repeating: 0, count: frames))] {
+ var samples = source
+ samples.withUnsafeMutableBufferPointer { b in
+ denoiser.inputLevel.record(b.baseAddress!, frames: frames, at: 1)
+ denoiser.process(b.baseAddress!, frames: frames)
+ denoiser.observe(b.baseAddress!, frames: frames)
+ }
+ XCTAssertEqual(
+ samples.map(\.bitPattern), source.map(\.bitPattern), "\(name) passes through bit for bit",
+ file: file, line: line)
+ let after = denoiser.level.take()
+ let before = denoiser.inputLevel.take()
+ XCTAssertEqual(after.buffers, 1, "\(name) is metered after the hook", file: file, line: line)
+ XCTAssertEqual(before.buffers, 1, "\(name) is metered before the hook", file: file, line: line)
+ if name == "tone" {
+ XCTAssertGreaterThan(after.rms, 0.1, "the tone reads as a tone", file: file, line: line)
+ XCTAssertEqual(after.rms, before.rms, "nothing was done to it", file: file, line: line)
+ } else {
+ XCTAssertEqual(after.rms, 0, "silence reads as silence", file: file, line: line)
+ XCTAssertEqual(after.peak, 0, file: file, line: line)
+ }
+ }
+ XCTAssertEqual(fake.calls, 0, "the suppressor is never handed a passthrough", file: file, line: line)
+ let seen = tap.seen
+ XCTAssertEqual(seen.calls, 2, "the sink sees both buffers", file: file, line: line)
+ XCTAssertGreaterThan(seen.energy, 0, "and hears the tone", file: file, line: line)
+ XCTAssertEqual(Set(seen.rates), [rate], "at the rate WebRTC reported", file: file, line: line)
+ return denoiser
+ }
+
+ func testTwoEightAndEighteenChannelsPassThroughAndAreMetered() {
+ for channels in [2, 8, 18] {
+ _ = check(rate: 48_000, channels: channels, frames: 480)
+ }
+ }
+
+ func testFortyFourOneAndNinetySixKilohertzPassThroughAndSaySo() {
+ for rate in [44_100, 96_000] {
+ let denoiser = check(rate: rate, channels: 1, frames: rate / 100)
+ XCTAssertEqual(denoiser.state, .unsupportedRate(rate))
+ }
+ }
+
+ func testTheToggleOffPassesThroughAndIsMetered() {
+ let denoiser = check(rate: 48_000, channels: 1, frames: 480, enabled: false)
+ XCTAssertEqual(denoiser.state, .off)
+ }
+
+ func testBuffersThatAreNotWholeFramesPassThroughAndAreMetered() {
+ for frames in [441, 479, 500, 1_000] {
+ _ = check(rate: 48_000, channels: 1, frames: frames)
+ }
+ }
+
+ /// And where it does process, the meter after the hook reads what the
+ /// suppressor made of it (the fake halves it: 6 dB down).
+ func testTheMeterAfterTheHookReadsTheProcessedAudio() {
+ let denoiser = CaptureDenoiser(suppressor: FakeSuppressor(), clock: { 1 })
+ denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 1)
+ var samples = Self.tone(480, rate: 48_000)
+ samples.withUnsafeMutableBufferPointer { b in
+ denoiser.inputLevel.record(b.baseAddress!, frames: 480, at: 1)
+ denoiser.process(b.baseAddress!, frames: 480)
+ denoiser.observe(b.baseAddress!, frames: 480)
+ }
+ let before = denoiser.inputLevel.take().rms
+ let after = denoiser.level.take().rms
+ XCTAssertEqual(after / before, 0.5, accuracy: 0.001)
+ }
+
+ /// Real speech on a stereo capture: untouched, and at speech level on the
+ /// meter the audio test judges "working" by.
+ func testSpeechOnAStereoCaptureIsUntouchedAndReadsAsSpeech() throws {
+ let speech = try SuppressorFixtureTests.speech()
+ let denoiser = CaptureDenoiser(suppressor: FakeSuppressor(), clock: { 1 })
+ denoiser.audioProcessingInitialize(sampleRate: 48_000, channels: 2)
+ var loudest: Float = 0
+ for start in stride(from: 0, to: speech.count - 480, by: 480) {
+ var samples = Array(speech[start..<(start + 480)])
+ let source = samples
+ samples.withUnsafeMutableBufferPointer { b in
+ denoiser.process(b.baseAddress!, frames: 480)
+ denoiser.observe(b.baseAddress!, frames: 480)
+ }
+ XCTAssertEqual(samples, source)
+ loudest = max(loudest, denoiser.level.take().rms)
+ }
+ XCTAssertGreaterThan(loudest, MicJudge.speechRMS)
+ }
+}