From 4a05844ee31d27439cfa8930b18c48fad83caa6d Mon Sep 17 00:00:00 2001 From: kutesir Date: Wed, 24 Jun 2026 21:56:45 +0300 Subject: [PATCH] Voice: fix audio-session leak causing insufficientPriority (561017449) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decoded the device error: NSOSStatusErrorDomain 561017449 = '!pri' = AVAudioSession insufficientPriority — the mic was denied because a session was left active (which also blocks the keyboard's own dictation). Root cause: the early-return guards (recognizer unavailable / invalid format) ran AFTER setActive(true) but never deactivated, leaking an active session. Also the .measurement + .duckOthers config is more prone to priority denials. Fixes: - Deactivate any stale active session before starting. - Use .record / .default (least-exclusive recording config). - stopEngine() now always releases the session (no early-out), and the bailout guards call it so the session is never left active. - Map 561017449 to an actionable message (close other audio apps / restart). Device-specific; not reproducible on the sim (blocks at the permission dialog). Co-Authored-By: Claude Opus 4.8 --- KisaniCal/Managers/SpeechRecognizer.swift | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/KisaniCal/Managers/SpeechRecognizer.swift b/KisaniCal/Managers/SpeechRecognizer.swift index fcaf728..38ab07a 100644 --- a/KisaniCal/Managers/SpeechRecognizer.swift +++ b/KisaniCal/Managers/SpeechRecognizer.swift @@ -69,12 +69,19 @@ final class SpeechRecognizer: ObservableObject { do { let session = AVAudioSession.sharedInstance() - try session.setCategory(.playAndRecord, mode: .measurement, options: [.duckOthers, .defaultToSpeaker]) + // Release any session left active by a prior attempt — a stuck-active + // session causes error 561017449 ('!pri', insufficientPriority) and can + // also block the keyboard's own dictation from grabbing the mic. + try? session.setActive(false, options: .notifyOthersOnDeactivation) + // Plain .record/.default is the least-exclusive recording config; the + // earlier .measurement + .duckOthers combo is more prone to priority denials. + try session.setCategory(.record, mode: .default) try session.setActive(true, options: .notifyOthersOnDeactivation) guard let recognizer, recognizer.isAvailable else { error = "Speech recognition is not available right now." print("[VOICE] Recognizer unavailable") + stopEngine() // release the session we just activated return } @@ -86,6 +93,7 @@ final class SpeechRecognizer: ObservableObject { guard fmt.sampleRate > 0, fmt.channelCount > 0 else { error = "No microphone input available on this device." print("[VOICE] Invalid input format: \(fmt)") + stopEngine() // release the session we just activated return } @@ -125,7 +133,13 @@ final class SpeechRecognizer: ObservableObject { } catch { let ns = error as NSError - self.error = "Could not start recording. (\(ns.domain) \(ns.code))" + // 561017449 = '!pri' = AVAudioSession insufficientPriority: another app/ + // process holds the mic, or the audio system is in a stuck state. + if ns.code == 561017449 { + self.error = "Microphone is busy. Close other audio apps (calls, Voice Memos), then try again. If it persists, restart your phone." + } else { + self.error = "Could not start recording. (\(ns.code))" + } print("[VOICE] Engine start failed: \(ns.domain) \(ns.code) — \(error.localizedDescription)") stopEngine() } @@ -137,14 +151,15 @@ final class SpeechRecognizer: ObservableObject { } private func stopEngine() { - guard audioEngine.isRunning || task != nil else { return } - audioEngine.stop() + if audioEngine.isRunning { audioEngine.stop() } audioEngine.inputNode.removeTap(onBus: 0) request?.endAudio() task?.finish() request = nil task = nil isRecording = false + // Always release the audio session, even on a failed/partial start — a + // session left active denies the mic to us and to keyboard dictation. try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) }