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>
172 lines
6.8 KiB
Swift
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
|
|
}
|
|
}
|