From c9f0fd077798a93b40b52fa92b453848e34d25f2 Mon Sep 17 00:00:00 2001 From: kutesir Date: Wed, 24 Jun 2026 21:33:16 +0300 Subject: [PATCH] Voice: harden startEngine against "Could not start recording" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- KisaniCal/Managers/SpeechRecognizer.swift | 26 ++++++++++++++++++----- KisaniCal/Views/TodayView.swift | 3 +++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/KisaniCal/Managers/SpeechRecognizer.swift b/KisaniCal/Managers/SpeechRecognizer.swift index e1f4d25..fcaf728 100644 --- a/KisaniCal/Managers/SpeechRecognizer.swift +++ b/KisaniCal/Managers/SpeechRecognizer.swift @@ -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() } } diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift index c28444c..83a5970 100644 --- a/KisaniCal/Views/TodayView.swift +++ b/KisaniCal/Views/TodayView.swift @@ -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)