fix: show live transcript in voice wake test
parent
acc88bc2b4
commit
45400a1758
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue