From 5944f263cd1dbc920f3d93d9c18f42541fc8dd95 Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 13 Apr 2026 18:45:05 -0500 Subject: [PATCH] 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) --- .../Services/PronunciationService.swift | 71 ++++++++++++------- .../Views/Practice/ListeningView.swift | 2 +- 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/Conjuga/Conjuga/Services/PronunciationService.swift b/Conjuga/Conjuga/Services/PronunciationService.swift index 3b81039..0177bb9 100644 --- a/Conjuga/Conjuga/Services/PronunciationService.swift +++ b/Conjuga/Conjuga/Services/PronunciationService.swift @@ -50,45 +50,64 @@ final class PronunciationService { recognizer = SFSpeechRecognizer(locale: Locale(identifier: "es-ES")) } - func startRecording() throws { - guard isAuthorized else { return } + func startRecording() { + guard isAuthorized else { + print("[PronunciationService] not authorized") + return + } resolveRecognizerIfNeeded() - guard let recognizer, recognizer.isAvailable else { return } + guard let recognizer, recognizer.isAvailable else { + print("[PronunciationService] recognizer unavailable") + return + } stopRecording() - let audioSession = AVAudioSession.sharedInstance() - try audioSession.setCategory(.playAndRecord, mode: .measurement, options: [.duckOthers, .defaultToSpeaker]) - try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + do { + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.playAndRecord, mode: .measurement, options: [.duckOthers, .defaultToSpeaker]) + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) - audioEngine = AVAudioEngine() - request = SFSpeechAudioBufferRecognitionRequest() + audioEngine = AVAudioEngine() + request = SFSpeechAudioBufferRecognitionRequest() - guard let audioEngine, let request else { return } - request.shouldReportPartialResults = true + guard let audioEngine, let request else { return } + request.shouldReportPartialResults = true - let inputNode = audioEngine.inputNode - let recordingFormat = inputNode.outputFormat(forBus: 0) + let inputNode = audioEngine.inputNode + let recordingFormat = inputNode.outputFormat(forBus: 0) - inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in - request.append(buffer) - } + // Validate format — 0 channels crashes installTap + guard recordingFormat.channelCount > 0 else { + print("[PronunciationService] invalid recording format (0 channels)") + self.audioEngine = nil + self.request = nil + return + } - audioEngine.prepare() - try audioEngine.start() + inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in + request.append(buffer) + } - transcript = "" - isRecording = true + audioEngine.prepare() + try audioEngine.start() - task = recognizer.recognitionTask(with: request) { [weak self] result, error in - Task { @MainActor in - if let result { - self?.transcript = result.bestTranscription.formattedString - } - if error != nil || (result?.isFinal == true) { - self?.stopRecording() + transcript = "" + isRecording = true + + task = recognizer.recognitionTask(with: request) { [weak self] result, error in + DispatchQueue.main.async { + if let result { + self?.transcript = result.bestTranscription.formattedString + } + if error != nil || (result?.isFinal == true) { + self?.stopRecording() + } } } + } catch { + print("[PronunciationService] startRecording failed: \(error)") + stopRecording() } } diff --git a/Conjuga/Conjuga/Views/Practice/ListeningView.swift b/Conjuga/Conjuga/Views/Practice/ListeningView.swift index b3939f6..0ec44db 100644 --- a/Conjuga/Conjuga/Views/Practice/ListeningView.swift +++ b/Conjuga/Conjuga/Views/Practice/ListeningView.swift @@ -166,7 +166,7 @@ struct ListeningView: View { if result.score >= 0.7 { correctCount += 1 } withAnimation { isRevealed = true } } else { - try? pronunciation.startRecording() + pronunciation.startRecording() } } label: { Label(pronunciation.isRecording ? "Stop" : "Start Speaking", systemImage: pronunciation.isRecording ? "stop.circle.fill" : "mic.circle.fill")