From dc96ffbf48e39e66eb055abef76639d5615f0989 Mon Sep 17 00:00:00 2001 From: Francesco Giannicola Date: Thu, 11 Jun 2026 17:29:49 +0200 Subject: [PATCH] fix(ios): dictation race/lifecycle bugs found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh code review of the audio/dictation stack surfaced real bugs: 1. Double-start crash (high): `state` only flips to .recording after the async recorder.start(), so two quick mic taps both saw .idle and both started — installing a second audio tap throws an uncatchable NSException. Add an `isStarting` guard set synchronously before the await. 2. Empty-audio dead path (medium): recorder.stop() returns WAVEncoder output, which always includes the 44-byte header, so `audio.isEmpty` was never true — a tap-with-no-speech got POSTed and came back as an error. Guard on `audio.count > WAVEncoder.headerSize` instead. 3. Stale interim leak (medium): LiveTranscriber.start() cleared `interim` only after the availability guard, so an unavailable recognizer could leave the previous session's words on screen. Clear it first (and in stop()). 4. Minor: a failed engine.start() left the session active + interruption observers installed; tear them down in the catch. Build- + test-verified (31 iOS tests). Co-Authored-By: Claude Opus 4.8 --- ios/ScribaKeyboard/AudioRecorder.swift | 4 ++++ ios/ScribaKeyboard/DictationController.swift | 14 +++++++++++++- ios/ScribaKeyboard/LiveTranscriber.swift | 5 ++++- 3 files changed, 21 insertions(+), 2 deletions(-) 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) {