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
4 changes: 4 additions & 0 deletions Fluid.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */; };
86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */; };
272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */; };
80465DE1A77810B54487CD0E /* SpeechModelIdleUnloadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE1E8B230CB0C06897E51E9 /* SpeechModelIdleUnloadTests.swift */; };
7CDB0A2F2F3C4D5600FB7CAD /* dictation_fixture.wav in Resources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */; };
7CDB0A302F3C4D5600FB7CAD /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */; };
7CE006BD2E80EBE600DDCCD6 /* AppUpdater in Frameworks */ = {isa = PBXBuildFile; productRef = 7CE006BC2E80EBE600DDCCD6 /* AppUpdater */; };
Expand All @@ -38,6 +39,7 @@
7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = "<group>"; };
343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientRequestBodyTests.swift; sourceTree = "<group>"; };
980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSupportTests.swift; sourceTree = "<group>"; };
4CE1E8B230CB0C06897E51E9 /* SpeechModelIdleUnloadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechModelIdleUnloadTests.swift; sourceTree = "<group>"; };
7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = "<group>"; };
7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = "<group>"; };
7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
Expand Down Expand Up @@ -109,6 +111,7 @@
7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */,
7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */,
343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */,
4CE1E8B230CB0C06897E51E9 /* SpeechModelIdleUnloadTests.swift */,
980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */,
);
path = FluidDictationIntegrationTests;
Expand Down Expand Up @@ -265,6 +268,7 @@
7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */,
7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */,
86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */,
80465DE1A77810B54487CD0E /* SpeechModelIdleUnloadTests.swift in Sources */,
272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
1 change: 1 addition & 0 deletions Sources/Fluid/Persistence/BackupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ struct SettingsBackupPayload: Codable, Equatable {
let weekendsDontBreakStreak: Bool
let fillerWords: [String]
let removeFillerWordsEnabled: Bool
let speechModelIdleUnloadMinutes: Int?
let autoConvertPunctuationEnabled: Bool?
let literalDictationFormattingEnabled: Bool?
let punctuationDictionaryPrefix: String?
Expand Down
44 changes: 44 additions & 0 deletions Sources/Fluid/Persistence/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2995,6 +2995,7 @@ final class SettingsStore: ObservableObject {
weekendsDontBreakStreak: self.weekendsDontBreakStreak,
fillerWords: self.fillerWords,
removeFillerWordsEnabled: self.removeFillerWordsEnabled,
speechModelIdleUnloadMinutes: self.speechModelIdleUnloadMinutes,
autoConvertPunctuationEnabled: self.autoConvertPunctuationEnabled,
literalDictationFormattingEnabled: self.literalDictationFormattingEnabled,
punctuationDictionaryPrefix: self.punctuationDictionaryPrefix,
Expand Down Expand Up @@ -3114,6 +3115,9 @@ final class SettingsStore: ObservableObject {
self.weekendsDontBreakStreak = payload.weekendsDontBreakStreak
self.fillerWords = payload.fillerWords
self.removeFillerWordsEnabled = payload.removeFillerWordsEnabled
if let speechModelIdleUnloadMinutes = payload.speechModelIdleUnloadMinutes {
self.speechModelIdleUnloadMinutes = speechModelIdleUnloadMinutes
}
if let autoConvertPunctuationEnabled = payload.autoConvertPunctuationEnabled {
self.autoConvertPunctuationEnabled = autoConvertPunctuationEnabled
}
Expand Down Expand Up @@ -4377,6 +4381,20 @@ final class SettingsStore: ObservableObject {
}
}

/// Whether transcription runs on weights loaded into FluidVoice's own
/// process memory. Apple engines run in system services, so there is
/// no in-process model to unload when idle.
var holdsModelInProcessMemory: Bool {
switch self {
case .appleSpeech, .appleSpeechAnalyzer:
return false
case .parakeetTDT, .parakeetTDTv2, .parakeetRealtime, .qwen3Asr, .cohereTranscribeSixBit,
.nemotronOffline, .nemotronStreaming, .nemotronStreaming320,
.whisperTiny, .whisperBase, .whisperSmall, .whisperMedium, .whisperLargeTurbo, .whisperLarge:
return true
}
}

/// Warning text for models with high memory requirements, nil if no warning needed
var memoryWarning: String? {
switch self {
Expand Down Expand Up @@ -4927,6 +4945,7 @@ private extension SettingsStore {

/// Unified Speech Model (replaces above two)
static let selectedSpeechModel = "SelectedSpeechModel"
static let speechModelIdleUnloadMinutes = "SpeechModelIdleUnloadMinutes"
static let selectedCohereLanguage = "SelectedCohereLanguage"
static let selectedNemotronLanguage = "SelectedNemotronLanguage"
static let selectedAppleSpeechLocaleIdentifier = "SelectedAppleSpeechLocaleIdentifier"
Expand Down Expand Up @@ -5185,6 +5204,31 @@ extension SettingsStore {
}
}

/// Minutes of dictation inactivity after which the loaded speech model is
/// unloaded to reclaim memory. 0 keeps the model loaded until quit.
/// Clamped to at most 24 hours so stored or restored values can never
/// overflow the nanosecond arithmetic that arms the unload timer.
var speechModelIdleUnloadMinutes: Int {
get {
guard let value = self.defaults.object(forKey: Keys.speechModelIdleUnloadMinutes) as? Int else {
return Self.defaultSpeechModelIdleUnloadMinutes
}
return Self.clampedSpeechModelIdleUnloadMinutes(value)
}
set {
objectWillChange.send()
self.defaults.set(Self.clampedSpeechModelIdleUnloadMinutes(newValue), forKey: Keys.speechModelIdleUnloadMinutes)
NotificationCenter.default.post(name: .speechModelIdleUnloadPolicyDidChange, object: nil)
}
}

static let defaultSpeechModelIdleUnloadMinutes = 30
static let maxSpeechModelIdleUnloadMinutes = 24 * 60

static func clampedSpeechModelIdleUnloadMinutes(_ minutes: Int) -> Int {
min(max(0, minutes), Self.maxSpeechModelIdleUnloadMinutes)
}

var selectedCohereLanguage: CohereLanguage {
get {
if let rawValue = self.defaults.string(forKey: Keys.selectedCohereLanguage),
Expand Down
170 changes: 167 additions & 3 deletions Sources/Fluid/Services/ASRService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,22 @@ final class ASRService: ObservableObject {
failureCount >= 3
}

nonisolated static func canUnloadIdleSpeechModel(
isAsrReady: Bool,
isRunning: Bool,
isStarting: Bool,
hasActiveModelPreparation: Bool,
hasActiveModelDownload: Bool,
activeSpeechModelUseCount: Int
) -> Bool {
isAsrReady
&& !isRunning
&& !isStarting
&& !hasActiveModelPreparation
&& !hasActiveModelDownload
&& activeSpeechModelUseCount == 0
}

@Published var isRunning: Bool = false
@Published var finalText: String = ""
@Published var partialTranscription: String = ""
Expand Down Expand Up @@ -205,6 +221,8 @@ final class ASRService: ObservableObject {
await self.providerResetDrain?.task.value
await self.transcriptionExecutor.cancelAndAwaitPending()

self.modelIdleUnloadTask?.cancel()
self.modelIdleUnloadTask = nil
self.fluidAudioProvider = nil
self.parakeetRealtimeProvider = nil
self.externalCoreMLProvider = nil
Expand Down Expand Up @@ -557,9 +575,12 @@ final class ASRService: ObservableObject {
self.wordBoostStatusText = "Word boost: off"

// Reset cached providers to force re-initialization with new settings
self.modelIdleUnloadTask?.cancel()
self.modelIdleUnloadTask = nil
self.fluidAudioProvider = nil
self.parakeetRealtimeProvider = nil
self.externalCoreMLProvider = nil
self.nemotronProviders.removeAll()
self.whisperProvider = nil
self.appleSpeechProvider = nil
self._appleSpeechAnalyzerProvider = nil
Expand All @@ -579,6 +600,85 @@ final class ASRService: ObservableObject {
}
}

// MARK: - Idle Speech Model Unload

/// Marks the loaded speech model as in use so an idle unload cannot fire
/// mid-operation. Every call must be balanced with `endSpeechModelUse()`.
func beginSpeechModelUse() {
self.activeSpeechModelUseCount += 1
self.modelIdleUnloadTask?.cancel()
self.modelIdleUnloadTask = nil
}

/// Balances `beginSpeechModelUse()` and re-arms the idle unload countdown
/// once the model is no longer in use.
func endSpeechModelUse() {
self.activeSpeechModelUseCount = max(0, self.activeSpeechModelUseCount - 1)
if self.activeSpeechModelUseCount == 0 {
self.scheduleModelIdleUnloadIfEnabled(reason: "model_use_ended")
}
}

private func scheduleModelIdleUnloadIfEnabled(reason: String) {
self.modelIdleUnloadTask?.cancel()
self.modelIdleUnloadTask = nil

let minutes = SettingsStore.shared.speechModelIdleUnloadMinutes
guard minutes > 0,
self.isAsrReady,
SettingsStore.shared.selectedSpeechModel.holdsModelInProcessMemory
else { return }

DebugLogger.shared.debug(
"Speech model idle unload armed for \(minutes)min (\(reason))",
source: "ASRService"
)
let delay = UInt64(minutes) * 60 * 1_000_000_000

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clamp idle-unload minutes before computing nanoseconds

If a restored backup or UserDefaults entry contains a very large positive speechModelIdleUnloadMinutes value, it reaches this calculation because the setting getter only clamps the lower bound, and the chained UInt64 multiplications can overflow and trap when the idle timer is scheduled after model load or a policy change. Clamp to a sane maximum or use overflow-safe duration construction before arming the task.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fd0821f — reads and writes are now clamped to 0…1440 minutes (clampedSpeechModelIdleUnloadMinutes), so the nanosecond multiplication is bounded well inside UInt64 range, and the unit test covers Int.max both via the setter and written directly to defaults.

self.modelIdleUnloadTask = Task { [weak self] in
do {
try await Task.sleep(nanoseconds: delay)
} catch {
return
}
self?.unloadIdleSpeechModel()
}
}

/// Releases every cached in-process speech model once the idle window has
/// elapsed with no dictation. Disk caches are untouched; the existing
/// `ensureAsrReady()` paths reload the model on next use, exactly like the
/// first dictation after app launch.
private func unloadIdleSpeechModel() {
self.modelIdleUnloadTask = nil
guard Self.canUnloadIdleSpeechModel(
isAsrReady: self.isAsrReady,
isRunning: self.isRunning,
isStarting: self.isStarting,
hasActiveModelPreparation: self.ensureReadyTask != nil,
hasActiveModelDownload: self.modelDownloadTask != nil,
activeSpeechModelUseCount: self.activeSpeechModelUseCount
) else {
self.scheduleModelIdleUnloadIfEnabled(reason: "unload_deferred")
return
}

DebugLogger.shared.info(
"Unloading idle speech model to reclaim memory; it reloads on next dictation",
source: "ASRService"
)
self.fluidAudioProvider = nil
self.parakeetRealtimeProvider = nil
self.externalCoreMLProvider = nil
self.nemotronProviders.removeAll()
self.whisperProvider = nil
self.isAsrReady = false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reload the model before starting post-idle streaming

When the idle timer fires, this leaves isAsrReady false; the next start() call does not call ensureAsrReady() before startStreamingTranscription(), and that helper immediately returns while isAsrReady is false. For streaming-capable models, the entire first dictation after an idle timeout loses live partial transcription and only reloads in stop(), so the model should be reloaded before starting the streaming loop if the intent is to reload on the next dictation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 9063f86. start() now branches: if the model is loaded it starts streaming immediately as before; otherwise it defers via attachStreamingTranscriptionWhenModelLoads(), which awaits ensureAsrReady() (single-flighted with the existing ContentView pre-load task) and attaches the streaming loop to the same session once ready, guarded by the session ID, isRunning, and a re-check of supportsStreaming. This also improves the pre-existing cold-launch case where recording started before the startup auto-load finished.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow cached models to start after idle unload

When the idle timer unloads a model, this flips isAsrReady to false while leaving modelsExistOnDisk true, but the in-app RecordingView still disables its Start Recording button whenever !asr.isAsrReady && !asr.isRunning (Sources/Fluid/UI/RecordingView.swift:77). After the default idle window, users who start dictation from that UI can no longer trigger the intended transparent reload; update the UI gating/readiness state to allow cached idle-unloaded models to start.

Useful? React with 👍 / 👎.

self.hasCompletedFirstTranscription = false
self.isLoadingModel = false
self.modelPreparationPhase = nil
self.lastBoostHitTerm = nil
self.refreshWordBoostStatus()
}

// CRITICAL FIX (launch-time crash mitigation):
// Combine's default ObservableObject.objectWillChange implementation uses Swift reflection to walk *stored*
// properties. If we store an AVFoundation ObjC class type (like AVAudioEngine) directly, the reflection
Expand Down Expand Up @@ -996,6 +1096,14 @@ final class ASRService: ObservableObject {
private let audioRouteRecoveryDelayNanoseconds: UInt64 = 1_000_000_000
private var audioEngineStandbyTask: Task<Void, Never>?
private let audioEngineStandbyNanoseconds: UInt64 = 8_000_000_000

/// Countdown that releases the loaded speech model after the configured
/// idle window, so multi-GB model weights do not stay resident forever.
private var modelIdleUnloadTask: Task<Void, Never>?
/// Number of in-flight operations (final transcription, Local API calls,
/// file transcription) that must block an idle unload while they run.
private var activeSpeechModelUseCount = 0
private var idleUnloadPolicyObserver: NSObjectProtocol?
private var isEngineTapInstalled = false
private var isRecoveringAudioRoute = false

Expand Down Expand Up @@ -1079,6 +1187,15 @@ final class ASRService: ObservableObject {
self?.handleParakeetVocabularyDidChange()
}
}
self.idleUnloadPolicyObserver = NotificationCenter.default.addObserver(
forName: .speechModelIdleUnloadPolicyDidChange,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.scheduleModelIdleUnloadIfEnabled(reason: "policy_changed")
}
}
}

deinit {
Expand All @@ -1089,6 +1206,9 @@ final class ASRService: ObservableObject {
if let observer = self.engineConfigurationChangeObserver {
NotificationCenter.default.removeObserver(observer)
}
if let observer = self.idleUnloadPolicyObserver {
NotificationCenter.default.removeObserver(observer)
}
}

@MainActor
Expand Down Expand Up @@ -1370,9 +1490,13 @@ final class ASRService: ObservableObject {
// Only start streaming for models that support it (large Whisper models are too slow)
let model = SettingsStore.shared.selectedSpeechModel
if model.supportsStreaming, !forDictionaryTraining {
DebugLogger.shared.debug("📡 Starting streaming transcription...", source: "ASRService")
self.benchmarkLog("streaming_timer_start intervalMs=\(Int((self.streamingChunkDurationSeconds * 1000).rounded())) minSamples=\(self.minimumStreamingPreviewSamples)")
self.startStreamingTranscription()
if self.isAsrReady {
DebugLogger.shared.debug("📡 Starting streaming transcription...", source: "ASRService")
self.benchmarkLog("streaming_timer_start intervalMs=\(Int((self.streamingChunkDurationSeconds * 1000).rounded())) minSamples=\(self.minimumStreamingPreviewSamples)")
self.startStreamingTranscription()
} else {
self.attachStreamingTranscriptionWhenModelLoads()
}
} else if forDictionaryTraining {
DebugLogger.shared.debug("⏸️ Skipping streaming for dictionary training sample", source: "ASRService")
} else {
Expand Down Expand Up @@ -1470,9 +1594,11 @@ final class ASRService: ObservableObject {
return ""
}
let useDictionaryTrainingPath = forDictionaryTraining || self.isDictionaryTrainingCaptureActive
self.beginSpeechModelUse()
defer {
self.applyPendingParakeetVocabularyReloadIfNeeded()
self.isDictionaryTrainingCaptureActive = false
self.endSpeechModelUse()
}

self.audioRouteRecoveryTask?.cancel()
Expand Down Expand Up @@ -1727,6 +1853,8 @@ final class ASRService: ObservableObject {
}

func transcribeSamplesForAPI(_ inputSamples: [Float]) async throws -> ASRTranscriptionResult {
self.beginSpeechModelUse()
defer { self.endSpeechModelUse() }
var samples = inputSamples
guard !samples.isEmpty else {
return ASRTranscriptionResult(text: "", confidence: 0)
Expand Down Expand Up @@ -1764,6 +1892,8 @@ final class ASRService: ObservableObject {
}

func transcribeFileForAPI(_ fileURL: URL) async throws -> (result: ASRTranscriptionResult, sampleCount: Int) {
self.beginSpeechModelUse()
defer { self.endSpeechModelUse() }
guard FileManager.default.isReadableFile(atPath: fileURL.path) else {
throw NSError(
domain: "ASRService",
Expand Down Expand Up @@ -1809,9 +1939,11 @@ final class ASRService: ObservableObject {

func stopWithoutTranscription() async {
guard self.isRunning else { return }
self.beginSpeechModelUse()
defer {
self.applyPendingParakeetVocabularyReloadIfNeeded()
self.isDictionaryTrainingCaptureActive = false
self.endSpeechModelUse()
}

self.audioRouteRecoveryTask?.cancel()
Expand Down Expand Up @@ -3003,6 +3135,7 @@ final class ASRService: ObservableObject {
self.isAsrReady = true
self.isCancellingModelPreparation = false
self.refreshWordBoostStatus()
self.scheduleModelIdleUnloadIfEnabled(reason: "model_ready")
} catch is CancellationError {
DebugLogger.shared.info("ASR initialization cancelled", source: "ASRService")
if provider.shouldClearCacheAfterCancellation,
Expand Down Expand Up @@ -3229,6 +3362,33 @@ final class ASRService: ObservableObject {

// MARK: - Timer-based Streaming Transcription (No VAD)

/// The model can be absent when capture starts (idle unload, or app launch
/// before the startup auto-load finishes). Reload it during capture and
/// attach the live streaming preview to the same session once it is ready,
/// instead of losing streaming for the whole dictation.
private func attachStreamingTranscriptionWhenModelLoads() {
let sessionID = self.benchmarkSessionID
DebugLogger.shared.debug("📡 Streaming deferred - loading model during capture...", source: "ASRService")
Task { [weak self] in
guard let self else { return }
do {
try await self.ensureAsrReady()
} catch {
DebugLogger.shared.debug(
"Streaming attach abandoned - model load failed: \(error.localizedDescription)",
source: "ASRService"
)
return
}
guard self.isRunning,
self.benchmarkSessionID == sessionID,
SettingsStore.shared.selectedSpeechModel.supportsStreaming
else { return }
self.benchmarkLog("streaming_timer_start intervalMs=\(Int((self.streamingChunkDurationSeconds * 1000).rounded())) minSamples=\(self.minimumStreamingPreviewSamples) deferred=true")
self.startStreamingTranscription()
}
}

private func startStreamingTranscription() {
self.streamingTask?.cancel()
guard self.isAsrReady else { return }
Expand Down Expand Up @@ -4195,3 +4355,7 @@ private final nonisolated class AudioCapturePipeline: @unchecked Sendable {
return mono
}
}

extension Notification.Name {
static let speechModelIdleUnloadPolicyDidChange = Notification.Name("SpeechModelIdleUnloadPolicyDidChange")
}
Loading
Loading