fix: show live transcript in voice wake test

main
Peter Steinberger 2025-12-06 03:02:26 +01:00
parent acc88bc2b4
commit 45400a1758
1 changed files with 30 additions and 22 deletions

View File

@ -1315,6 +1315,7 @@ enum VoiceWakeTestState: Equatable {
case idle case idle
case requesting case requesting
case listening case listening
case hearing(String)
case detected(String) case detected(String)
case failed(String) case failed(String)
} }
@ -1377,7 +1378,8 @@ actor MicLevelMonitor {
} }
} }
actor VoiceWakeTester { @MainActor
final class VoiceWakeTester {
private let recognizer: SFSpeechRecognizer? private let recognizer: SFSpeechRecognizer?
private let audioEngine = AVAudioEngine() private let audioEngine = AVAudioEngine()
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
@ -1387,7 +1389,7 @@ actor VoiceWakeTester {
self.recognizer = SFSpeechRecognizer(locale: locale) self.recognizer = SFSpeechRecognizer(locale: locale)
} }
func start(triggers: [String], micID: String?, onUpdate: @MainActor @escaping @Sendable (VoiceWakeTestState) -> Void) async throws { func start(triggers: [String], micID: String?, onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) async throws {
guard recognitionTask == nil else { return } guard recognitionTask == nil else { return }
guard let recognizer, recognizer.isAvailable else { guard let recognizer, recognizer.isAvailable else {
throw NSError(domain: "VoiceWakeTester", code: 1, userInfo: [NSLocalizedDescriptionKey: "Speech recognition unavailable"]) throw NSError(domain: "VoiceWakeTester", code: 1, userInfo: [NSLocalizedDescriptionKey: "Speech recognition unavailable"])
@ -1412,21 +1414,21 @@ actor VoiceWakeTester {
inputNode.removeTap(onBus: 0) inputNode.removeTap(onBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak self] buffer, _ in inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak self] buffer, _ in
guard let self else { return } guard let self else { return }
Task { await self.appendBuffer(buffer) } self.recognitionRequest?.append(buffer)
} }
audioEngine.prepare() audioEngine.prepare()
try audioEngine.start() try audioEngine.start()
await MainActor.run { onUpdate(.listening) } onUpdate(.listening)
guard let request = recognitionRequest else { return } guard let request = recognitionRequest else { return }
recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
let text = result?.bestTranscription.formattedString
let matched = text.map { Self.matches(text: $0, triggers: triggers) } ?? false
let errorMessage = error?.localizedDescription
guard let self else { return } guard let self else { return }
Task { await self.handleResult(matched: matched, text: text, errorMessage: errorMessage, onUpdate: onUpdate) } let text = result?.bestTranscription.formattedString ?? ""
let matched = Self.matches(text: text, triggers: triggers)
let errorMessage = error?.localizedDescription
self.handleResult(matched: matched, text: text, isFinal: result?.isFinal ?? false, errorMessage: errorMessage, onUpdate: onUpdate)
} }
} }
@ -1439,24 +1441,28 @@ actor VoiceWakeTester {
audioEngine.inputNode.removeTap(onBus: 0) audioEngine.inputNode.removeTap(onBus: 0)
} }
private func appendBuffer(_ buffer: AVAudioPCMBuffer) {
recognitionRequest?.append(buffer)
}
private func handleResult( private func handleResult(
matched: Bool, matched: Bool,
text: String?, text: String,
isFinal: Bool,
errorMessage: String?, errorMessage: String?,
onUpdate: @MainActor @escaping @Sendable (VoiceWakeTestState) -> Void onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void
) async { ) {
if matched, let text { if matched, !text.isEmpty {
stop() stop()
await MainActor.run { onUpdate(.detected(text)) } onUpdate(.detected(text))
return return
} }
if let errorMessage { if let errorMessage {
stop() stop()
await MainActor.run { onUpdate(.failed(errorMessage)) } onUpdate(.failed(errorMessage))
return
}
if isFinal {
stop()
onUpdate(text.isEmpty ? .failed("No speech detected") : .failed("No trigger heard: “\(text)"))
} else {
onUpdate(text.isEmpty ? .listening : .hearing(text))
} }
} }
@ -1764,7 +1770,7 @@ struct VoiceWakeSettings: View {
AnyView(Image(systemName: "waveform").foregroundStyle(.secondary)) AnyView(Image(systemName: "waveform").foregroundStyle(.secondary))
case .requesting: case .requesting:
AnyView(ProgressView().controlSize(.small)) AnyView(ProgressView().controlSize(.small))
case .listening: case .listening, .hearing:
AnyView( AnyView(
Image(systemName: "ear.and.waveform") Image(systemName: "ear.and.waveform")
.symbolEffect(.pulse) .symbolEffect(.pulse)
@ -1785,6 +1791,8 @@ struct VoiceWakeSettings: View {
return "Requesting mic & speech permission…" return "Requesting mic & speech permission…"
case .listening: case .listening:
return "Listening… say your trigger word." return "Listening… say your trigger word."
case let .hearing(text):
return "Heard: \(text)"
case .detected: case .detected:
return "Voice wake detected!" return "Voice wake detected!"
case let .failed(reason): case let .failed(reason):
@ -1816,7 +1824,7 @@ struct VoiceWakeSettings: View {
private func toggleTest() { private func toggleTest() {
if isTesting { if isTesting {
Task { await tester.stop() } tester.stop()
isTesting = false isTesting = false
testState = .idle testState = .idle
return return
@ -1842,13 +1850,13 @@ struct VoiceWakeSettings: View {
// timeout after 10s // timeout after 10s
try await Task.sleep(nanoseconds: 10 * 1_000_000_000) try await Task.sleep(nanoseconds: 10 * 1_000_000_000)
if isTesting { if isTesting {
await tester.stop() tester.stop()
testState = .failed("Timeout: no trigger heard") testState = .failed("Timeout: no trigger heard")
isTesting = false isTesting = false
await restartMeter() await restartMeter()
} }
} catch { } catch {
await tester.stop() tester.stop()
testState = .failed(error.localizedDescription) testState = .failed(error.localizedDescription)
isTesting = false isTesting = false
await restartMeter() await restartMeter()