Skip to content

fix(asr): unload idle speech model to release wired memory (#548)#619

Open
devzahirul wants to merge 3 commits into
altic-dev:mainfrom
devzahirul:fix/548-idle-speech-model-unload
Open

fix(asr): unload idle speech model to release wired memory (#548)#619
devzahirul wants to merge 3 commits into
altic-dev:mainfrom
devzahirul:fix/548-idle-speech-model-unload

Conversation

@devzahirul

@devzahirul devzahirul commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes the voice-engine side of the wired-memory pinning reported in #548: once a speech model is loaded, FluidVoice never releases it. (The reporter's specific 3.43 GB fluid-1-q4_k_m.gguf is the Fluid Intelligence runtime — same missing-idle-unload defect, separate code path; see the Related Issue section and PR discussion.) ASRService caches every provider instance (FluidAudioProvider alone holds two to three AsrManager CoreML models, WhisperProvider a Metal-backed GGUF session) and only drops them on app quit or model switch — there is no time-based offload anywhere in the codebase. Combined with the startup auto-load in initialize(), a multi-GB model stays resident from launch until quit, which matches the reporter's measurements (wired stayed at ~5.6–5.8 GB for 10+ idle hours and only returned to baseline on quit) and freezes 8 GB Macs.

This PR adds an idle unload for the speech model, mirroring the existing audio-engine standby pattern:

  • After a configurable window with no dictation (default 30 minutes; Never / 5 / 15 / 30 / 60 min under Settings → Voice Engine), ASRService drops all cached in-process providers and frees the model memory. Disk caches are untouched.
  • Reload is transparent: recording start is unaffected (audio capture starts immediately, and startRecording() already pre-loads the model in the background), while stop() awaits ensureAsrReady() — exactly the same path as the first dictation after app launch.
  • A use-count token (beginSpeechModelUse() / endSpeechModelUse()) guarantees the unload can never fire while a final transcription, a Local API request, or a long file-transcription job is in flight; recording, preparation, and download states are re-checked at fire time via the pure decision function canUnloadIdleSpeechModel(...).
  • Apple Speech engines are excluded (holdsModelInProcessMemory == false) — they run in system services and hold no in-process weights, so there is nothing to unload.
  • Changing the setting re-arms or cancels the countdown immediately (NotificationCenter, same pattern as parakeetVocabularyDidChange), and the value participates in settings backup/restore.

Also fixes a latent leak in the same lifecycle: resetTranscriptionProvider() cleared every cached provider except nemotronProviders, so switching away from a Nemotron model kept its CoreML models in memory forever (shutdownForTermination() already removed them; the reset path now matches).

Verified end-to-end

Release build, whisper-tiny, unload window set to 1 minute:

[14:15:36.660] [INFO]  [ASRService] === ASR INITIALIZATION COMPLETE ===
[14:15:36.660] [DEBUG] [ASRService] Speech model idle unload armed for 1min (model_ready)
[14:16:36.811] [INFO]  [ASRService] Unloading idle speech model to reclaim memory; it reloads on next dictation

Process RSS dropped from 236.7 MB to 160.6 MB the moment the unload fired (whisper-tiny is only a 46 MB GGUF; with the ~3.4 GB model from the report this is multi-GB of wired memory). The app stays fully functional afterwards — the next dictation reloads through the existing ensureAsrReady() flow.

Type of Change

  • 🐞 Bug fix
  • ✨ New feature
  • 💥 Breaking change
  • 🧹 Chore
  • 📝 Documentation update

Related Issue or Discussion

Part of #548 — this PR fixes the voice-engine side of the leak (ASR providers are never released while the app runs, and resetTranscriptionProvider() additionally leaks nemotronProviders on model switch). The reporter's specific 3.43 GB file (fluid-1-q4_k_m.gguf) is the Fluid Intelligence runtime, which has the same missing idle-unload and needs the same policy applied — happy to extend this PR or send it as a follow-up, whichever is preferred (see PR discussion).

Testing

  • Tested on Intel Mac
  • Tested on Apple Silicon Mac
  • Tested on macOS version: 26.5.2
  • Ran linter locally: swiftlint --strict --config .swiftlint.yml Sources — 0 violations
  • Ran formatter locally: swiftformat --config .swiftformat Sources
  • Ran tests locally: full xcodebuild test suite (same invocation as CI) — all green, including the new SpeechModelIdleUnloadTests (7 tests covering the unload decision truth table, the in-process-memory model classification, and the setting's default/clamping)

Screenshots / Video

New "Unload Model When Inactive" row in Settings → Voice Engine (below Remove Filler Words):

Unload Model When Inactive setting row

Full Voice Engine card showing placement (below Remove Filler Words)

Full Voice Engine settings card

Notes

  • Default is 30 minutes because idle offload appears to be the intended behavior (see maintainer comment in [BUG] Dictation model (~3.4 GB) stays pinned in wired memory and never unloads, freezing 8 GB Macs #548). Users who prefer the old always-resident behavior can select "Never". Frequent dictation never hits the unload — every model use re-arms the countdown.
  • Worst case after an idle unload: a dictation shorter than the model load time waits at stop() for the reload, identical to the first dictation after app launch. For most models this is a few seconds; the background pre-load during recording absorbs it entirely for normal-length dictations.
  • swiftformat 0.62.1 reformats ~60 files that this PR does not touch (the checked-in tree is not clean under it), so formatting was verified for the changed files only to keep the diff atomic and reviewable.

…#548)

Loaded speech models were never released: ASRService caches every
provider and only drops them on quit or model switch, so a multi-GB
model stayed resident from the startup auto-load until app exit,
pinning wired memory and freezing 8 GB Macs.

Add an idle unload that mirrors the audio-engine standby pattern:
- release all cached in-process providers after a configurable window
  without dictation (default 30 min; Never/5/15/30/60 in Voice Engine
  settings); disk caches stay, and ensureAsrReady() reloads the model
  on next use exactly like the first dictation after launch
- guard the unload with a model-use token held across stop()'s final
  transcription, Local API requests, and file transcription jobs, and
  re-check recording/preparation/download state at fire time
- skip Apple Speech engines, which hold no in-process weights
- clear nemotronProviders in resetTranscriptionProvider(), which
  leaked loaded Nemotron models when switching away from them

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: edd0454893

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

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.

@altic-dev

Copy link
Copy Markdown
Owner

Thanks for PR. Why is it under voice engine? I thought this was for Fluid Intelligence and not Voice model?

@greptile-apps

greptile-apps Bot commented Jul 15, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a wired-memory leak in ASRService by adding a configurable idle unload for on-device speech models (default 30 min), mirroring the existing audio-engine standby pattern. It also fixes a pre-existing leak where resetTranscriptionProvider() omitted nemotronProviders from its cleanup.

  • ASRService gains a use-count guard (beginSpeechModelUse/endSpeechModelUse) to protect final transcription, Local API requests, and long file jobs from being interrupted by an idle unload; the decision predicate canUnloadIdleSpeechModel is a pure, nonisolated function that is independently unit-tested.
  • A new Settings row ("Unload Model When Inactive") is conditionally shown only for on-device models; Apple Speech engines are excluded since they hold no in-process weights.
  • MeetingTranscriptionService correctly wraps its file-transcription path with beginSpeechModelUse/endSpeechModelUse, and the new attachStreamingTranscriptionWhenModelLoads helper handles the deferred streaming-preview case when recording starts while a model is still loading.

Confidence Score: 5/5

Safe to merge. The idle-unload mechanism is well-guarded: the pure decision predicate is checked at fire time, beginSpeechModelUse/endSpeechModelUse prevent unloading during active operations, and all edge cases (Apple engines, model switch, deinit) correctly cancel the timer.

The core unload path re-validates all conditions at fire time so no in-flight transcription can be silently dropped. The nemotronProviders leak fix, the backup/restore integration, and the deferred-streaming helper for post-unload recordings all look correct. The one efficiency nit (timer armed while in active use) causes log noise and repeated wakeups but does not affect correctness or data integrity.

No files require special attention. The ASRService.swift changes are the most complex but the guard conditions and actor isolation follow the existing patterns in that file.

Reviews (3): Last reviewed commit: "fix(settings): clamp idle-unload minutes..." | Re-trigger Greptile

Comment thread Sources/Fluid/UI/AISettingsView+SpeechRecognition.swift
Comment thread Sources/Fluid/UI/AISettingsView+SpeechRecognition.swift
…d setting UI (altic-dev#548)

Address review feedback on the idle-unload PR:
- start(): when capture begins with no loaded model (idle unload, or app
  launch before auto-load finishes), reload during capture and attach
  the live streaming preview to the same session once ready, instead of
  losing streaming for the whole dictation
- hide the Unload Model When Inactive row while an Apple Speech engine
  is selected, since those hold no in-process model to unload
- label non-standard restored values grammatically (After 1 minute)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9063f8686e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

"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.

…erflow-safe (altic-dev#548)

A hand-edited or corrupt restored value could previously reach the
UInt64 nanosecond multiplication unbounded and trap when the unload
timer was armed. Clamp reads and writes to 0...1440 minutes.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fd0821f105

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

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 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 👍 / 👎.

@devzahirul

Copy link
Copy Markdown
Contributor Author

Good question — you're right, and I've updated the PR description to be precise about it. Two separate things are going on in #548:

  1. The reporter's specific 3.43 GB is Fluid Intelligence — the issue body names fluid-1-q4_k_m.gguf. It's prewarmed at record start and unloadCachedRuntime() is only ever called on termination, manual model removal, or verification resets — never on idle. So that runtime stays wired for the app's lifetime through a code path this PR doesn't touch.

  2. The voice engine has the exact same defect independently, which is what this PR fixes and why the setting sits under Voice Engine: ASRService caches every provider and nothing releases them until quit (Whisper Large / Cohere / Qwen users carry multi-GB resident weights with no idle offload), plus resetTranscriptionProvider() misses nemotronProviders entirely, so switching away from Nemotron leaks its loaded CoreML models.

To fully resolve the reporter's case, the same idle policy needs to apply to the Fluid Intelligence runtime — likely armed on the same "minutes since last use" clock but tracked in PrivateAIIntegrationService, since that runtime is also used by Command Mode and Rewrite outside dictation. I'm happy to either extend this PR to cover it or keep this one scoped to the voice engine and send the LLM-runtime unload as a focused follow-up — whichever you prefer. I've changed the PR description from "Closes" to "Part of #548" in the meantime so the tracking stays accurate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants