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:
kutesir
2026-06-24 21:33:16 +03:00
parent ad304913e4
commit c9f0fd0777
2 changed files with 24 additions and 5 deletions

View File

@@ -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()
}
}

View File

@@ -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)