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 Divider() CopyAudioDiagnosticsButton { test.diagnosticsText() } } .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 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") .opacity(test.startingSentence == nil ? 1 : 0.4) if let starting = test.startingSentence { // The window is up at once; the microphone takes a moment, and a // Bluetooth headset a second or two more. Said, not frozen. HStack(spacing: 8) { ProgressView().controlSize(.small) Text(starting).font(.callout).fixedSize(horizontal: false, vertical: true) } .accessibilityElement(children: .combine) .accessibilityIdentifier("testMicrophoneResult") } else { 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, .noAudio: 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 || test.startingSentence != 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.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 /// "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 { /// "Copy audio diagnostics" in Test audio; in a call, where the screen's /// account goes in too, "Copy call diagnostics". var title = "Copy audio diagnostics" let text: () -> String @State private var copied = false var body: some View { HStack(spacing: 8) { Button(title, systemImage: "doc.on.clipboard") { AudioDiagnosticsReport.copy(text()) copied = true } .buttonStyle(.borderless) .font(.footnote) .accessibilityIdentifier("copyAudioDiagnostics") .accessibilityHint("Copies device names, formats, levels and screen sharing's account, never audio or pictures, 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 /// 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.. Color { i >= MeterSteps.count - 1 ? .orange : CallsAppearance.accent } }