Voice: harden startEngine against "Could not start recording"
Addresses the most common causes of the engine failing to start: - Defensive teardown before starting (removeTap + stop + reset) — a leftover tap from a prior attempt makes installTap throw "already has a tap". - Validate the input node's hardware format (sampleRate/channels > 0) and fail with a clear "No microphone input" message instead of throwing. - Use inputNode.inputFormat(forBus:0) for the tap. - Category .playAndRecord + [.duckOthers, .defaultToSpeaker] (more permissive than .record with .duckOthers). - Surface the underlying NSError domain+code in the banner + log to pinpoint any remaining device-specific failure. DEBUG-only KISANI_VOICE_START hook to drive the real engine on a sim. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -61,9 +61,15 @@ final class SpeechRecognizer: ObservableObject {
|
||||
}
|
||||
|
||||
private func startEngine() {
|
||||
// Defensive teardown: a tap left over from a previous (failed) attempt makes
|
||||
// installTap throw "already has a tap" → "Could not start recording".
|
||||
audioEngine.inputNode.removeTap(onBus: 0)
|
||||
if audioEngine.isRunning { audioEngine.stop() }
|
||||
audioEngine.reset()
|
||||
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.record, mode: .measurement, options: .duckOthers)
|
||||
try session.setCategory(.playAndRecord, mode: .measurement, options: [.duckOthers, .defaultToSpeaker])
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
|
||||
guard let recognizer, recognizer.isAvailable else {
|
||||
@@ -72,6 +78,17 @@ final class SpeechRecognizer: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
// Use the input node's actual hardware format. A 0-sample-rate format
|
||||
// (no usable input — e.g. a simulator with no audio input) would make
|
||||
// installTap throw, so fail with a clear message instead.
|
||||
let node = audioEngine.inputNode
|
||||
let fmt = node.inputFormat(forBus: 0)
|
||||
guard fmt.sampleRate > 0, fmt.channelCount > 0 else {
|
||||
error = "No microphone input available on this device."
|
||||
print("[VOICE] Invalid input format: \(fmt)")
|
||||
return
|
||||
}
|
||||
|
||||
let req = SFSpeechAudioBufferRecognitionRequest()
|
||||
req.shouldReportPartialResults = true
|
||||
self.request = req
|
||||
@@ -97,8 +114,6 @@ final class SpeechRecognizer: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
let node = audioEngine.inputNode
|
||||
let fmt = node.outputFormat(forBus: 0)
|
||||
node.installTap(onBus: 0, bufferSize: 1024, format: fmt) { [weak self] buf, _ in
|
||||
self?.request?.append(buf)
|
||||
}
|
||||
@@ -109,8 +124,9 @@ final class SpeechRecognizer: ObservableObject {
|
||||
print("[VOICE] Recording started")
|
||||
|
||||
} catch {
|
||||
self.error = "Could not start recording."
|
||||
print("[VOICE] Engine start failed: \(error)")
|
||||
let ns = error as NSError
|
||||
self.error = "Could not start recording. (\(ns.domain) \(ns.code))"
|
||||
print("[VOICE] Engine start failed: \(ns.domain) \(ns.code) — \(error.localizedDescription)")
|
||||
stopEngine()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1879,6 +1879,9 @@ struct AddTaskSheet: View {
|
||||
if let t = ProcessInfo.processInfo.environment["KISANI_VOICE_SIM"], !t.isEmpty {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { speech.transcript = t }
|
||||
}
|
||||
if ProcessInfo.processInfo.environment["KISANI_VOICE_START"] == "1" {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { speech.start() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Reference in New Issue
Block a user