| 1 | import SwiftUI |
| 2 | |
| 3 | /// "Test audio": one button, on the home screen and in the device panel, |
| 4 | /// opening the test in a sheet. The test runs while the sheet is open and |
| 5 | /// stops, recording and all, when it closes. |
| 6 | @MainActor public struct AudioSelfTestButton: View { |
| 7 | private let call: Call? |
| 8 | @State private var testing = false |
| 9 | |
| 10 | /// `call` in a call, where the test reads that call's microphone and |
| 11 | /// plays nothing; nil on the home screen. |
| 12 | public init(call: Call? = nil) { |
| 13 | self.call = call |
| 14 | } |
| 15 | |
| 16 | public var body: some View { |
| 17 | Button("Test audio", systemImage: "waveform") { testing = true } |
| 18 | .accessibilityIdentifier("testAudio") |
| 19 | .accessibilityHint("Checks your microphone and speaker.") |
| 20 | .sheet(isPresented: $testing) { |
| 21 | AudioSelfTestSheet(call: call) { testing = false } |
| 22 | } |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | @MainActor private struct AudioSelfTestSheet: View { |
| 27 | @StateObject private var test: AudioSelfTest |
| 28 | let done: () -> Void |
| 29 | |
| 30 | init(call: Call?, done: @escaping () -> Void) { |
| 31 | _test = StateObject(wrappedValue: call.map { AudioSelfTest.during($0) } ?? AudioSelfTest.standalone()) |
| 32 | self.done = done |
| 33 | } |
| 34 | |
| 35 | var body: some View { |
| 36 | #if os(iOS) |
| 37 | NavigationStack { |
| 38 | ScrollView { AudioSelfTestView(test: test).padding(20) } |
| 39 | .navigationTitle("Test audio") |
| 40 | .navigationBarTitleDisplayMode(.inline) |
| 41 | .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done", action: done) } } |
| 42 | } |
| 43 | .task { await test.open() } |
| 44 | .onDisappear { test.close() } |
| 45 | #else |
| 46 | VStack(alignment: .leading, spacing: 0) { |
| 47 | Text("Test audio").font(.title2.bold()).padding([.horizontal, .top], 20) |
| 48 | ScrollView { AudioSelfTestView(test: test).padding(20) } |
| 49 | HStack { |
| 50 | Spacer() |
| 51 | Button("Done", action: done).keyboardShortcut(.defaultAction) |
| 52 | }.padding(16) |
| 53 | } |
| 54 | .frame(minWidth: 420, idealWidth: 460, minHeight: 480, idealHeight: 560) |
| 55 | .task { await test.open() } |
| 56 | .onDisappear { test.close() } |
| 57 | #endif |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | /// The test itself: microphone, speaker, and hear yourself, each with a |
| 62 | /// sentence saying what was found. Every meter has its words beside it. |
| 63 | @MainActor public struct AudioSelfTestView: View { |
| 64 | @ObservedObject private var test: AudioSelfTest |
| 65 | |
| 66 | public init(test: AudioSelfTest) { |
| 67 | self.test = test |
| 68 | } |
| 69 | |
| 70 | public var body: some View { |
| 71 | VStack(alignment: .leading, spacing: 24) { |
| 72 | microphone |
| 73 | Divider() |
| 74 | speaker |
| 75 | Divider() |
| 76 | hearYourself |
| 77 | Divider() |
| 78 | CopyAudioDiagnosticsButton { test.diagnosticsText() } |
| 79 | } |
| 80 | .frame(maxWidth: .infinity, alignment: .leading) |
| 81 | } |
| 82 | |
| 83 | // MARK: Microphone |
| 84 | |
| 85 | private var microphone: some View { |
| 86 | VStack(alignment: .leading, spacing: 10) { |
| 87 | Text("Microphone").font(.headline) |
| 88 | #if os(macOS) |
| 89 | if let routes = test.routes { |
| 90 | DevicePicker( |
| 91 | label: "Microphone", routes: routes, input: true, identifier: "testMicrophonePicker", |
| 92 | changed: test.microphoneChanged) |
| 93 | } else if !test.microphoneName.isEmpty { |
| 94 | Text(test.microphoneName).foregroundStyle(.secondary) |
| 95 | } |
| 96 | #else |
| 97 | if !test.microphoneName.isEmpty { Text(test.microphoneName).foregroundStyle(.secondary) } |
| 98 | #endif |
| 99 | if !test.microphoneDetail.isEmpty { |
| 100 | Text("Using \(test.microphoneDetail)").font(.footnote).foregroundStyle(.secondary) |
| 101 | .fixedSize(horizontal: false, vertical: true) |
| 102 | .accessibilityIdentifier("testMicrophoneDevice") |
| 103 | } |
| 104 | LevelMeter(step: test.micStep, label: "Microphone level") |
| 105 | .opacity(test.startingSentence == nil ? 1 : 0.4) |
| 106 | if let starting = test.startingSentence { |
| 107 | // The window is up at once; the microphone takes a moment, and a |
| 108 | // Bluetooth headset a second or two more. Said, not frozen. |
| 109 | HStack(spacing: 8) { |
| 110 | ProgressView().controlSize(.small) |
| 111 | Text(starting).font(.callout).fixedSize(horizontal: false, vertical: true) |
| 112 | } |
| 113 | .accessibilityElement(children: .combine) |
| 114 | .accessibilityIdentifier("testMicrophoneResult") |
| 115 | } else { |
| 116 | Label(test.micSentence, systemImage: micSymbol) |
| 117 | .font(.callout) |
| 118 | .fixedSize(horizontal: false, vertical: true) |
| 119 | .accessibilityIdentifier("testMicrophoneResult") |
| 120 | } |
| 121 | if let advice = test.micAdvice { |
| 122 | Text(advice).font(.footnote).foregroundStyle(.secondary) |
| 123 | .fixedSize(horizontal: false, vertical: true) |
| 124 | } |
| 125 | Text( |
| 126 | test.reduceNoise |
| 127 | ? "Measured after background noise reduction, as others hear you." |
| 128 | : "Background noise reduction is off, so this is your microphone as it is." |
| 129 | ) |
| 130 | .font(.footnote).foregroundStyle(.secondary) |
| 131 | .fixedSize(horizontal: false, vertical: true) |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | private var micSymbol: String { |
| 136 | if test.micProblem != nil { return "exclamationmark.triangle" } |
| 137 | switch test.micVerdict { |
| 138 | case .working: return "checkmark.circle" |
| 139 | case .silent, .noAudio: return "exclamationmark.triangle" |
| 140 | case .listening, .hearing, .notStarted: return "ear" |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | // MARK: Speaker |
| 145 | |
| 146 | private var speaker: some View { |
| 147 | VStack(alignment: .leading, spacing: 10) { |
| 148 | Text("Speaker").font(.headline) |
| 149 | if test.inCall { |
| 150 | Text(AudioSelfTest.inCallPlayback).font(.callout).foregroundStyle(.secondary) |
| 151 | .fixedSize(horizontal: false, vertical: true) |
| 152 | } else { |
| 153 | #if os(macOS) |
| 154 | if let routes = test.routes { |
| 155 | DevicePicker( |
| 156 | label: "Speaker", routes: routes, input: false, identifier: "testSpeakerPicker", changed: {}) |
| 157 | } |
| 158 | #else |
| 159 | if !test.speakerName.isEmpty { Text(test.speakerName).foregroundStyle(.secondary) } |
| 160 | #endif |
| 161 | Button("Play a test sound", systemImage: "speaker.wave.2") { |
| 162 | Task { await test.playTestSound() } |
| 163 | } |
| 164 | .buttonStyle(.bordered) |
| 165 | .disabled(busy) |
| 166 | .accessibilityIdentifier("testSpeaker") |
| 167 | if test.speaker == .playing { |
| 168 | LevelMeter(step: test.outputStep, label: "Speaker level") |
| 169 | } |
| 170 | if let line = test.speakerSentence { |
| 171 | Text(line).font(.callout).fixedSize(horizontal: false, vertical: true) |
| 172 | .accessibilityIdentifier("testSpeakerResult") |
| 173 | } |
| 174 | } |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | // MARK: Hear yourself |
| 179 | |
| 180 | private var hearYourself: some View { |
| 181 | VStack(alignment: .leading, spacing: 10) { |
| 182 | Text("Hear yourself as others will").font(.headline) |
| 183 | if test.inCall { |
| 184 | Text(AudioSelfTest.inCallPlayback).font(.callout).foregroundStyle(.secondary) |
| 185 | .fixedSize(horizontal: false, vertical: true) |
| 186 | } else { |
| 187 | Text( |
| 188 | "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." |
| 189 | ) |
| 190 | .font(.footnote).foregroundStyle(.secondary) |
| 191 | .fixedSize(horizontal: false, vertical: true) |
| 192 | Button("Record and play back", systemImage: "record.circle") { |
| 193 | Task { await test.recordAndPlayBack() } |
| 194 | } |
| 195 | .buttonStyle(.bordered) |
| 196 | .disabled(busy || test.micProblem != nil || test.startingSentence != nil) |
| 197 | .accessibilityIdentifier("testRecord") |
| 198 | if test.recording == .recording { |
| 199 | ProgressView(value: test.recordingProgress) |
| 200 | .accessibilityLabel("Recording") |
| 201 | .accessibilityValue("\(Int(test.recordingProgress * 100)) percent") |
| 202 | } |
| 203 | if test.recording == .playing { |
| 204 | LevelMeter(step: test.outputStep, label: "Playback level") |
| 205 | } |
| 206 | if let line = test.recordingSentence { |
| 207 | Text(line).font(.callout).fixedSize(horizontal: false, vertical: true) |
| 208 | .accessibilityIdentifier("testRecordResult") |
| 209 | } |
| 210 | } |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | private var busy: Bool { |
| 215 | test.speaker == .playing || test.recording == .recording || test.recording == .playing |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | #if os(macOS) |
| 220 | /// The test's own device choice before a call. The same remembered |
| 221 | /// choice the device panel makes, so what is chosen here is what the |
| 222 | /// next call uses. |
| 223 | @MainActor private struct DevicePicker: View { |
| 224 | let label: String |
| 225 | @ObservedObject var routes: AudioRoutes |
| 226 | let input: Bool |
| 227 | let identifier: String |
| 228 | let changed: () -> Void |
| 229 | @State private var problem = "" |
| 230 | |
| 231 | var body: some View { |
| 232 | let list = input ? routes.inputs : routes.outputs |
| 233 | let current = input ? routes.currentInput : routes.currentOutput |
| 234 | VStack(alignment: .leading, spacing: 4) { |
| 235 | if list.isEmpty { |
| 236 | Text("No \(label.lowercased()) found").foregroundStyle(.secondary) |
| 237 | } else { |
| 238 | Picker( |
| 239 | label, |
| 240 | selection: Binding( |
| 241 | get: { current }, |
| 242 | set: { id in |
| 243 | guard let id, id != current else { return } |
| 244 | do { |
| 245 | try input ? routes.use(input: id) : routes.use(output: id) |
| 246 | problem = "" |
| 247 | changed() |
| 248 | } catch { problem = error.localizedDescription } |
| 249 | }) |
| 250 | ) { |
| 251 | if current == nil { Text("Choose a device").tag(Optional<String>.none) } |
| 252 | ForEach(list) { Text($0.name).tag(Optional($0.id)) } |
| 253 | } |
| 254 | .pickerStyle(.menu) |
| 255 | .labelsHidden() |
| 256 | .accessibilityLabel(label) |
| 257 | .accessibilityIdentifier(identifier) |
| 258 | } |
| 259 | if !problem.isEmpty { |
| 260 | Text(problem).font(.callout).foregroundStyle(.red) |
| 261 | } |
| 262 | } |
| 263 | } |
| 264 | } |
| 265 | #endif |
| 266 | |
| 267 | /// "Copy audio diagnostics": what the app knows about its audio — the |
| 268 | /// devices chosen and resolved, formats, the engine's state, the last 50 |
| 269 | /// audio log lines and the recent levels — as text on the clipboard, to |
| 270 | /// paste into a message. Never any audio. |
| 271 | @MainActor struct CopyAudioDiagnosticsButton: View { |
| 272 | /// "Copy audio diagnostics" in Test audio; in a call, where the screen's |
| 273 | /// account goes in too, "Copy call diagnostics". |
| 274 | var title = "Copy audio diagnostics" |
| 275 | let text: () -> String |
| 276 | @State private var copied = false |
| 277 | |
| 278 | var body: some View { |
| 279 | HStack(spacing: 8) { |
| 280 | Button(title, systemImage: "doc.on.clipboard") { |
| 281 | AudioDiagnosticsReport.copy(text()) |
| 282 | copied = true |
| 283 | } |
| 284 | .buttonStyle(.borderless) |
| 285 | .font(.footnote) |
| 286 | .accessibilityIdentifier("copyAudioDiagnostics") |
| 287 | .accessibilityHint("Copies device names, formats, levels and screen sharing's account, never audio or pictures, to paste into a message.") |
| 288 | if copied { |
| 289 | Text("Copied").font(.footnote).foregroundStyle(.secondary) |
| 290 | .accessibilityIdentifier("copyAudioDiagnosticsDone") |
| 291 | } |
| 292 | } |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | /// A level as a row of bars: the lit count is the level, in steps |
| 297 | /// (MeterSteps), so it only redraws when a bar changes. With Reduce |
| 298 | /// Motion the bars switch without animating. VoiceOver hears the level in |
| 299 | /// words. |
| 300 | @MainActor struct LevelMeter: View { |
| 301 | let step: Int |
| 302 | let label: String |
| 303 | @Environment(\.accessibilityReduceMotion) private var reduceMotion |
| 304 | |
| 305 | var body: some View { |
| 306 | HStack(spacing: 3) { |
| 307 | ForEach(0..<MeterSteps.count, id: \.self) { i in |
| 308 | RoundedRectangle(cornerRadius: 2) |
| 309 | .fill(i < step ? color(i) : Color.secondary.opacity(0.2)) |
| 310 | .frame(height: 10) |
| 311 | } |
| 312 | } |
| 313 | .frame(maxWidth: 320) |
| 314 | .animation(reduceMotion ? nil : .easeOut(duration: 0.08), value: step) |
| 315 | .accessibilityElement(children: .ignore) |
| 316 | .accessibilityLabel(label) |
| 317 | .accessibilityValue(MeterSteps.words(step)) |
| 318 | } |
| 319 | |
| 320 | private func color(_ i: Int) -> Color { |
| 321 | i >= MeterSteps.count - 1 ? .orange : CallsAppearance.accent |
| 322 | } |
| 323 | } |
| 324 | |