From ab959d0b95264ee06a85c09c9bc79275cdaed58b Mon Sep 17 00:00:00 2001 From: Elisei_Nicolae Date: Fri, 26 Jun 2026 14:13:58 +0300 Subject: [PATCH] [recording-tail-setting] - Add adjustable tail recording to capture the last word --- Sources/Fluid/Persistence/BackupService.swift | 2 ++ Sources/Fluid/Persistence/SettingsStore.swift | 20 +++++++++++ Sources/Fluid/Services/ASRService.swift | 33 +++++++++++++++++-- Sources/Fluid/UI/SettingsView.swift | 26 +++++++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/Sources/Fluid/Persistence/BackupService.swift b/Sources/Fluid/Persistence/BackupService.swift index 4d590e7c..2b2e7d28 100644 --- a/Sources/Fluid/Persistence/BackupService.swift +++ b/Sources/Fluid/Persistence/BackupService.swift @@ -59,6 +59,8 @@ struct SettingsBackupPayload: Codable, Equatable { let visualizerNoiseThreshold: Double let overlayPosition: SettingsStore.OverlayPosition let overlayBottomOffset: Double + // Optional so backups written before this field existed still decode. + let recordingTailDuration: Double? let overlaySize: SettingsStore.OverlaySize let transcriptionPreviewCharLimit: Int let userTypingWPM: Int diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 936f530e..b62dc953 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -1725,6 +1725,21 @@ final class SettingsStore: ObservableObject { } } + /// Extra seconds to keep recording after the user presses stop, so the tail of their + /// last word isn't clipped. Default 0.2; range 0–0.4s. Read by ASRService at stop time. + var recordingTailDuration: Double { + get { + // 0 is a valid value here (disables the tail), so don't treat a stored 0 as "unset". + guard self.defaults.object(forKey: Keys.recordingTailDuration) != nil else { return 0.2 } + return self.defaults.double(forKey: Keys.recordingTailDuration) + } + set { + objectWillChange.send() + let clamped = max(min(newValue, 0.4), 0.0) + self.defaults.set(clamped, forKey: Keys.recordingTailDuration) + } + } + /// The size of the recording overlay (default: medium) var overlaySize: OverlaySize { get { @@ -2744,6 +2759,7 @@ final class SettingsStore: ObservableObject { visualizerNoiseThreshold: self.visualizerNoiseThreshold, overlayPosition: self.overlayPosition, overlayBottomOffset: self.overlayBottomOffset, + recordingTailDuration: self.recordingTailDuration, overlaySize: self.overlaySize, transcriptionPreviewCharLimit: self.transcriptionPreviewCharLimit, userTypingWPM: self.userTypingWPM, @@ -2834,6 +2850,9 @@ final class SettingsStore: ObservableObject { self.visualizerNoiseThreshold = payload.visualizerNoiseThreshold self.overlayPosition = payload.overlayPosition self.overlayBottomOffset = payload.overlayBottomOffset + if let recordingTailDuration = payload.recordingTailDuration { + self.recordingTailDuration = recordingTailDuration + } self.overlaySize = payload.overlaySize self.transcriptionPreviewCharLimit = payload.transcriptionPreviewCharLimit self.userTypingWPM = payload.userTypingWPM @@ -4376,6 +4395,7 @@ private extension SettingsStore { static let overlayPosition = "OverlayPosition" static let notchPresentationMode = "NotchPresentationMode" static let overlayBottomOffset = "OverlayBottomOffset" + static let recordingTailDuration = "RecordingTailDuration" static let overlayBottomOffsetMigratedTo50 = "OverlayBottomOffsetMigratedTo50" static let overlaySize = "OverlaySize" static let transcriptionPreviewCharLimit = "TranscriptionPreviewCharLimit" diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 6b21b482..7d4fa0e3 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -112,6 +112,7 @@ final class ASRService: ObservableObject { @Published var downloadingModelId: String? = nil // Tracks which model is currently being downloaded private var isStarting: Bool = false // Guard against re-entrant start() calls + private var isStopping: Bool = false // Guard against re-entrant stop() during the tail-capture grace window private var downloadProgressTask: Task? private var hasCompletedFirstTranscription: Bool = false // Track if model has warmed up with first transcription private var lastBoostHitTerm: String? @@ -528,6 +529,13 @@ final class ASRService: ObservableObject { private let audioRouteRecoveryDelayNanoseconds: UInt64 = 1_000_000_000 private var isRecoveringAudioRoute = false private let fastPreviewStopGraceNanoseconds: UInt64 = 200_000_000 + /// Extra time the mic tap stays live after the user triggers stop, so the tail + /// of their final word isn't clipped by input-pipeline latency. User-configurable + /// via Settings → "Extra recording after stop" (seconds); 0 disables. + private var stopTailCaptureGraceNanoseconds: UInt64 { + let seconds = max(0, SettingsStore.shared.recordingTailDuration) + return UInt64(seconds * 1_000_000_000) + } private let fastPreviewSampleRate = 16_000 private let fastPreviewMinimumSamples = 32_000 private let fastPreviewTailAudioToleranceMs = 300 @@ -941,10 +949,11 @@ final class ASRService: ObservableObject { let stopStartedAt = Date().timeIntervalSince1970 self.benchmarkLog("stop_start ageMs=\(self.elapsedMilliseconds(since: self.benchmarkRecordingStartedAt)) bufferedSamples=\(self.audioBuffer.count)") - guard self.isRunning else { - DebugLogger.shared.warning("⚠️ STOP() - not running, returning empty string", source: "ASRService") + guard self.isRunning, !self.isStopping else { + DebugLogger.shared.warning("⚠️ STOP() - not running or already stopping, returning empty string", source: "ASRService") return "" } + self.isStopping = true defer { self.applyPendingParakeetVocabularyReloadIfNeeded() } self.audioRouteRecoveryTask?.cancel() @@ -957,6 +966,23 @@ final class ASRService: ObservableObject { DebugLogger.shared.debug("📍 Preparing final transcription", source: "ASRService") + // Tail-capture grace: keep the mic tap live for a short window after the stop + // trigger so the user's final word isn't clipped by input-pipeline latency. + // The tap keeps appending to audioBuffer until setRecordingEnabled(false) below. + let tailGraceNanoseconds = self.stopTailCaptureGraceNanoseconds + if tailGraceNanoseconds > 0 { + DebugLogger.shared.debug("⏳ Tail-capture grace: keeping mic live for \(tailGraceNanoseconds / 1_000_000)ms...", source: "ASRService") + try? await Task.sleep(nanoseconds: tailGraceNanoseconds) + } + + // A cancel (stopWithoutTranscription) can run during the grace sleep above and tear the + // session down (isRunning → false). If so, bail without transcribing so the canceled + // dictation isn't inserted — the cancel path already owns the teardown and buffer clear. + guard self.isRunning else { + self.isStopping = false + return "" + } + DebugLogger.shared.debug("🚫 Setting audioCapturePipeline recording = false...", source: "ASRService") self.audioCapturePipeline.setRecordingEnabled(false) DebugLogger.shared.debug("✅ Capture pipeline disabled", source: "ASRService") @@ -966,6 +992,9 @@ final class ASRService: ObservableObject { // CRITICAL: Set isRunning to false before teardown so in-flight chunks stop safely. DebugLogger.shared.debug("🚫 Setting isRunning = false...", source: "ASRService") self.isRunning = false + // Release the stop guard as soon as the session isn't "running": holding it across the + // slow final transcription would block stopping a recording started during finalization. + self.isStopping = false DebugLogger.shared.debug("✅ isRunning disabled", source: "ASRService") // Stop monitoring device to prevent callbacks after stop diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index a49fc0fa..e665a93a 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -788,6 +788,32 @@ struct SettingsView: View { } Divider().opacity(0.2) + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 2) { + Text("Extra recording after stop") + .font(self.theme.typography.bodyStrong) + .foregroundStyle(self.settingsTitleText) + Text("Keeps recording briefly after you press stop so your last word isn't cut off.") + .font(self.theme.typography.bodySmall) + .foregroundStyle(self.settingsSecondaryText) + } + + Spacer() + + HStack(spacing: 6) { + Slider(value: self.$settings.recordingTailDuration, in: 0...0.4, step: 0.05) + .frame(width: 110) + .controlSize(.small) + + Text(String(format: "%.2f s", self.settings.recordingTailDuration)) + .font(.caption.monospaced()) + .foregroundStyle(self.settingsSecondaryText) + .frame(width: 54, alignment: .trailing) + } + .frame(width: 170, alignment: .trailing) + } + Divider().opacity(0.2) + self.optionToggleRow( title: "Copy to Clipboard", description: "Automatically copy transcribed text to clipboard as a backup.",