Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ios/ScribaKeyboard/AudioRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 13 additions & 1 deletion ios/ScribaKeyboard/DictationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down
5 changes: 4 additions & 1 deletion ios/ScribaKeyboard/LiveTranscriber.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -63,6 +65,7 @@ final class LiveTranscriber: ObservableObject {
}
task?.cancel()
task = nil
publish("")
}

private func publish(_ text: String) {
Expand Down
Loading