Skip to content
Open
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
2 changes: 2 additions & 0 deletions Sources/Fluid/Persistence/BackupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions Sources/Fluid/Persistence/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
33 changes: 31 additions & 2 deletions Sources/Fluid/Services/ASRService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
private var hasCompletedFirstTranscription: Bool = false // Track if model has warmed up with first transcription
private var lastBoostHitTerm: String?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ""
Comment thread
EliseiNicolae marked this conversation as resolved.
}
self.isStopping = true
defer { self.applyPendingParakeetVocabularyReloadIfNeeded() }

self.audioRouteRecoveryTask?.cancel()
Expand All @@ -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)
Comment thread
EliseiNicolae marked this conversation as resolved.
}

// 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 {
Comment thread
EliseiNicolae marked this conversation as resolved.
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")
Expand All @@ -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
Expand Down
26 changes: 26 additions & 0 deletions Sources/Fluid/UI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Loading