diff --git a/ios/ScribaKeyboard/AudioRecorder.swift b/ios/ScribaKeyboard/AudioRecorder.swift index 8c17110..708eaf3 100644 --- a/ios/ScribaKeyboard/AudioRecorder.swift +++ b/ios/ScribaKeyboard/AudioRecorder.swift @@ -72,7 +72,11 @@ final class AudioRecorder: ObservableObject { do { try engine.start() } catch { + // Tear down everything we set up so a retry starts clean (otherwise + // the observers leak and the session stays active). input.removeTap(onBus: 0) + removeSessionObservers() + try? session.setActive(false, options: [.notifyOthersOnDeactivation]) throw RecorderError.engineFailed(error) } publish(isRecording: true, level: 0) diff --git a/ios/ScribaKeyboard/DictationController.swift b/ios/ScribaKeyboard/DictationController.swift index 43119b6..4a64019 100644 --- a/ios/ScribaKeyboard/DictationController.swift +++ b/ios/ScribaKeyboard/DictationController.swift @@ -29,6 +29,11 @@ final class DictationController: ObservableObject { private let impact = UIImpactFeedbackGenerator(style: .medium) private let notify = UINotificationFeedbackGenerator() + // Guards against a double-start: `state` only flips to `.recording` after the + // async `recorder.start()`, so a second quick tap would otherwise also start + // and install a second audio tap (an uncatchable crash). + private var isStarting = false + init() { // If the system cuts the recording short (call, another app, AirPods // removed), finalize what we captured rather than losing it. @@ -73,6 +78,11 @@ final class DictationController: ObservableObject { } private func startRecording() async { + // Ignore a second start that races the first (state is still .idle until + // the await below completes). + if isStarting || state == .recording { return } + isStarting = true + defer { isStarting = false } do { try await recorder.start() live.start() // live preview; no-op if speech permission isn't granted @@ -87,7 +97,9 @@ final class DictationController: ObservableObject { private func finishRecording() async { live.stop() let audio = recorder.stop() - guard !audio.isEmpty else { + // A header-only WAV (no captured samples) isn't worth a round-trip — it'd + // just come back as "no speech". Treat it as a silent no-op. + guard audio.count > WAVEncoder.headerSize else { state = .idle return } diff --git a/ios/ScribaKeyboard/LiveTranscriber.swift b/ios/ScribaKeyboard/LiveTranscriber.swift index da0159e..dcbbf88 100644 --- a/ios/ScribaKeyboard/LiveTranscriber.swift +++ b/ios/ScribaKeyboard/LiveTranscriber.swift @@ -29,12 +29,14 @@ final class LiveTranscriber: ObservableObject { /// Begins a live recognition session. No-op (server path still works) if /// permission isn't granted or a recognizer isn't available. func start() { + // Clear any leftover preview first, so an unavailable recognizer (early + // return below) can't leave a previous session's words on screen. + publish("") guard SFSpeechRecognizer.authorizationStatus() == .authorized, let recognizer = SFSpeechRecognizer(locale: Self.locale()), recognizer.isAvailable else { return } - publish("") let request = SFSpeechAudioBufferRecognitionRequest() request.shouldReportPartialResults = true // Keep the live preview on-device (private, no network) when supported. @@ -63,6 +65,7 @@ final class LiveTranscriber: ObservableObject { } task?.cancel() task = nil + publish("") } private func publish(_ text: String) {