Fix listening practice crash when tapping Start Speaking

Wrap startRecording in do/catch so audio setup failures don't crash.
Validate recording format has channels before installTap. Use
DispatchQueue.main.async instead of Task{@MainActor} in recognition
callback to avoid dispatch queue assertions.

Closes #13

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-13 18:45:05 -05:00
parent a3318adf5e
commit 5944f263cd
2 changed files with 46 additions and 27 deletions

View File

@@ -50,45 +50,64 @@ final class PronunciationService {
recognizer = SFSpeechRecognizer(locale: Locale(identifier: "es-ES")) recognizer = SFSpeechRecognizer(locale: Locale(identifier: "es-ES"))
} }
func startRecording() throws { func startRecording() {
guard isAuthorized else { return } guard isAuthorized else {
print("[PronunciationService] not authorized")
return
}
resolveRecognizerIfNeeded() resolveRecognizerIfNeeded()
guard let recognizer, recognizer.isAvailable else { return } guard let recognizer, recognizer.isAvailable else {
print("[PronunciationService] recognizer unavailable")
return
}
stopRecording() stopRecording()
let audioSession = AVAudioSession.sharedInstance() do {
try audioSession.setCategory(.playAndRecord, mode: .measurement, options: [.duckOthers, .defaultToSpeaker]) let audioSession = AVAudioSession.sharedInstance()
try audioSession.setActive(true, options: .notifyOthersOnDeactivation) try audioSession.setCategory(.playAndRecord, mode: .measurement, options: [.duckOthers, .defaultToSpeaker])
try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
audioEngine = AVAudioEngine() audioEngine = AVAudioEngine()
request = SFSpeechAudioBufferRecognitionRequest() request = SFSpeechAudioBufferRecognitionRequest()
guard let audioEngine, let request else { return } guard let audioEngine, let request else { return }
request.shouldReportPartialResults = true request.shouldReportPartialResults = true
let inputNode = audioEngine.inputNode let inputNode = audioEngine.inputNode
let recordingFormat = inputNode.outputFormat(forBus: 0) let recordingFormat = inputNode.outputFormat(forBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in // Validate format 0 channels crashes installTap
request.append(buffer) guard recordingFormat.channelCount > 0 else {
} print("[PronunciationService] invalid recording format (0 channels)")
self.audioEngine = nil
self.request = nil
return
}
audioEngine.prepare() inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in
try audioEngine.start() request.append(buffer)
}
transcript = "" audioEngine.prepare()
isRecording = true try audioEngine.start()
task = recognizer.recognitionTask(with: request) { [weak self] result, error in transcript = ""
Task { @MainActor in isRecording = true
if let result {
self?.transcript = result.bestTranscription.formattedString task = recognizer.recognitionTask(with: request) { [weak self] result, error in
} DispatchQueue.main.async {
if error != nil || (result?.isFinal == true) { if let result {
self?.stopRecording() self?.transcript = result.bestTranscription.formattedString
}
if error != nil || (result?.isFinal == true) {
self?.stopRecording()
}
} }
} }
} catch {
print("[PronunciationService] startRecording failed: \(error)")
stopRecording()
} }
} }

View File

@@ -166,7 +166,7 @@ struct ListeningView: View {
if result.score >= 0.7 { correctCount += 1 } if result.score >= 0.7 { correctCount += 1 }
withAnimation { isRevealed = true } withAnimation { isRevealed = true }
} else { } else {
try? pronunciation.startRecording() pronunciation.startRecording()
} }
} label: { } label: {
Label(pronunciation.isRecording ? "Stop" : "Start Speaking", systemImage: pronunciation.isRecording ? "stop.circle.fill" : "mic.circle.fill") Label(pronunciation.isRecording ? "Stop" : "Start Speaking", systemImage: pronunciation.isRecording ? "stop.circle.fill" : "mic.circle.fill")