Files
KisaniCal/KisaniCal/Managers/SpeechRecognizer.swift
kutesir 4a05844ee3 Voice: fix audio-session leak causing insufficientPriority (561017449)
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 <noreply@anthropic.com>
2026-06-24 21:56:45 +03:00

172 lines
6.8 KiB
Swift

import Foundation
import Speech
import AVFoundation
@MainActor
final class SpeechRecognizer: ObservableObject {
@Published var transcript = ""
@Published var isRecording = false
@Published var error: String?
private var recognizer: SFSpeechRecognizer?
private var audioEngine = AVAudioEngine()
private var request: SFSpeechAudioBufferRecognitionRequest?
private var task: SFSpeechRecognitionTask?
init() {
recognizer = SFSpeechRecognizer(locale: .current)
}
func toggle() {
isRecording ? stop() : start()
}
func start() {
guard !isRecording else { return }
error = nil
print("[VOICE] Button tapped")
SFSpeechRecognizer.requestAuthorization { [weak self] status in
DispatchQueue.main.async {
guard let self else { return }
switch status {
case .authorized:
print("[VOICE] Permission granted — speech recognition")
self.requestMicThenRecord()
case .denied, .restricted:
self.error = "Speech recognition access denied. Enable it in Settings → Wenza."
print("[VOICE] Permission denied — speech recognition")
case .notDetermined:
self.error = "Speech recognition permission not yet determined."
print("[VOICE] Permission not determined")
@unknown default: break
}
}
}
}
private func requestMicThenRecord() {
AVAudioSession.sharedInstance().requestRecordPermission { [weak self] granted in
DispatchQueue.main.async {
guard let self else { return }
if granted {
print("[VOICE] Permission granted — microphone")
self.startEngine()
} else {
self.error = "Microphone access denied. Enable it in Settings → Wenza."
print("[VOICE] Permission denied — microphone")
}
}
}
}
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()
// 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
}
// 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)")
stopEngine() // release the session we just activated
return
}
let req = SFSpeechAudioBufferRecognitionRequest()
req.shouldReportPartialResults = true
self.request = req
task = recognizer.recognitionTask(with: req) { [weak self] result, err in
DispatchQueue.main.async {
guard let self else { return }
if let result {
let text = result.bestTranscription.formattedString
self.transcript = text
print("[VOICE] Transcript received: \"\(text)\"")
}
if result?.isFinal == true {
print("[VOICE] Recognition finished (final)")
self.stopEngine()
} else if let err {
let code = (err as NSError).code
// Code 301 = "no speech" not an error worth surfacing
if code != 301 {
print("[VOICE] Recognition error \(code): \(err.localizedDescription)")
}
}
}
}
node.installTap(onBus: 0, bufferSize: 1024, format: fmt) { [weak self] buf, _ in
self?.request?.append(buf)
}
audioEngine.prepare()
try audioEngine.start()
isRecording = true
print("[VOICE] Recording started")
} catch {
let ns = error as NSError
// 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()
}
}
func stop() {
print("[VOICE] Recording stopped")
stopEngine()
}
private func stopEngine() {
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)
}
func reset() {
stopEngine()
transcript = ""
error = nil
}
}