feat(stt): idle unload for local whisper model

The local faster-whisper model singleton (_local_model) is loaded once
and never released — the 'base' model holds ~370 MB of RAM/VRAM for
the entire lifetime of the process, even when no voice messages arrive
for hours or days. On long-running gateway processes (especially with
local LLMs competing for the same GPU) this is wasteful.

Add a config-driven idle unload: after stt.local.unload_after_idle_seconds
(default 0 = never) of no transcription activity, a lightweight daemon
thread sets _local_model = None so the Python GC can reclaim the
ctranslate2 objects. The next voice message reloads the model
transparently (the existing lazy-load path handles it).

The watcher:
  - Checks every 30s whether idle time exceeds the configured threshold
  - Acquires _local_model_lock before unloading (prevents races with
    concurrent transcriptions that are mid-load)
  - Exits immediately if the model is already None (unloaded by another
    path, e.g. the CUDA fallback eviction)
  - Is restarted by each transcription with the current config value,
    so changing stt.local.unload_after_idle_seconds in config.yaml takes
    effect on the next voice message without a process restart

Default is 0 (never unload) — zero behavior change for existing users.
Recommended value for gateway processes: 300 (5 minutes).

15 tests: config resolution (garbage/negative/None fallbacks), unload
safety (already-None, lock acquisition), touch timestamp, watcher
lifecycle (unload after timeout, no unload within timeout, exits when
model already None, stopped on new start). Existing STT test suite
unchanged.
This commit is contained in:
kshitij 2026-08-07 18:17:44 +05:30 committed by kshitij
parent 5cff192b39
commit 7b006ea6e8
5 changed files with 278 additions and 2 deletions

View File

@ -1210,7 +1210,8 @@ stt:
# # Set false to restore raw behavior (e.g. transcribing music/ambient audio).
# vad_min_silence_ms: 500 # min silence (ms) that splits speech chunks when vad is on
# no_speech_prob_threshold: 0.6 # drop a segment only if no_speech_prob > this...
# logprob_threshold: -1.0 # ...AND avg_logprob < this (both must hit — quiet real speech survives)
# logprob_threshold: -1.0 # ...AND avg_logprob < this (both must hit — quiet real speech survives)
# unload_after_idle_seconds: 0 # 0=never unload (default); 300=frees RAM/VRAM after 5min with no voice messages
language: "en" # GLOBAL language hint for every STT provider (per-provider language wins). Set "" for auto-detect.
# groq:
# model: "whisper-large-v3-turbo"

View File

@ -1537,6 +1537,7 @@ DEFAULT_CONFIG = {
"vad_min_silence_ms": 500, # min silence (ms) that splits speech chunks when vad is on
"no_speech_prob_threshold": 0.6, # drop a segment only if no_speech_prob is ABOVE this...
"logprob_threshold": -1.0, # ...AND its avg_logprob is BELOW this (both must hit)
"unload_after_idle_seconds": 0, # 0=never (default); 300=frees RAM/VRAM after 5min idle
},
"groq": {
"model": "whisper-large-v3-turbo", # whisper-large-v3, whisper-large-v3-turbo, distil-whisper-large-v3-en

View File

@ -0,0 +1,189 @@
"""Tests for the local whisper model idle-unload mechanism.
The local faster-whisper model singleton (``_local_model``) is loaded once
and never released — hundreds of MB of RAM/VRAM sit idle between voice
messages on long-running gateway processes. These tests verify the
config-driven idle unload: after ``unload_after_idle_seconds`` of no
transcription activity, a daemon thread sets ``_local_model = None`` so the
memory can be reclaimed. The next transcription reloads the model
transparently.
Contract under test:
1. Default is 0 (never unload) — no thread is started.
2. After a successful transcription with a non-zero timeout, the watcher
thread is started and eventually unloads the model.
3. ``_touch_transcription_time`` resets the idle timer.
4. ``_unload_local_model`` is safe to call when the model is already None.
5. Config resolution handles garbage values gracefully.
"""
import time
from unittest.mock import MagicMock, patch
import pytest
from tools.transcription_tools import (
_get_idle_unload_seconds,
_touch_transcription_time,
_unload_local_model,
_start_idle_unload_watcher,
_IDLE_UNLOAD_CHECK_INTERVAL,
)
import tools.transcription_tools as tt
# ============================================================================
# Config resolution
# ============================================================================
class TestGetIdleUnloadSeconds:
def test_default_is_zero(self):
assert _get_idle_unload_seconds({}) == 0
def test_explicit_value(self):
assert _get_idle_unload_seconds({"unload_after_idle_seconds": 300}) == 300
def test_zero_means_never(self):
assert _get_idle_unload_seconds({"unload_after_idle_seconds": 0}) == 0
def test_negative_clamped_to_zero(self):
assert _get_idle_unload_seconds({"unload_after_idle_seconds": -5}) == 0
def test_garbage_falls_back_to_zero(self):
assert _get_idle_unload_seconds({"unload_after_idle_seconds": "never"}) == 0
def test_none_falls_back_to_zero(self):
assert _get_idle_unload_seconds({"unload_after_idle_seconds": None}) == 0
# ============================================================================
# _unload_local_model
# ============================================================================
class TestUnloadLocalModel:
def test_unloads_when_model_present(self):
mock_model = MagicMock(name="whisper_model")
with patch.object(tt, "_local_model", mock_model), \
patch.object(tt, "_local_model_name", "base"):
_unload_local_model()
assert tt._local_model is None
assert tt._local_model_name is None
def test_safe_when_already_none(self):
with patch.object(tt, "_local_model", None), \
patch.object(tt, "_local_model_name", None):
# Must not raise
_unload_local_model()
assert tt._local_model is None
def test_acquires_model_lock(self):
"""The unload must hold _local_model_lock to prevent races with
concurrent transcriptions that are mid-load."""
mock_model = MagicMock(name="whisper_model")
with patch.object(tt, "_local_model", mock_model), \
patch.object(tt, "_local_model_name", "base"), \
patch.object(tt, "_local_model_lock") as mock_lock:
_unload_local_model()
mock_lock.__enter__.assert_called()
mock_lock.__exit__.assert_called()
# ============================================================================
# _touch_transcription_time
# ============================================================================
class TestTouchTranscriptionTime:
def test_sets_timestamp(self):
original = tt._last_transcription_time
try:
tt._last_transcription_time = 0.0
_touch_transcription_time()
assert tt._last_transcription_time > 0
finally:
tt._last_transcription_time = original
# ============================================================================
# Watcher thread (with mocked time)
# ============================================================================
class TestIdleUnloadWatcher:
def test_default_zero_does_not_start_thread(self):
"""When unload_after_idle_seconds is 0, no watcher should be started."""
with patch.object(tt, "_local_model", MagicMock()), \
patch.object(tt, "_start_idle_unload_watcher") as mock_start:
# Simulate _transcribe_local's behavior: only start if > 0
idle_timeout = _get_idle_unload_seconds({"unload_after_idle_seconds": 0})
if idle_timeout > 0:
_start_idle_unload_watcher(idle_timeout)
mock_start.assert_not_called()
def test_watcher_unloads_after_timeout(self):
"""The watcher unloads the model after the configured idle period."""
mock_model = MagicMock(name="whisper_model")
# Use a very short timeout and patch the check interval to 0.01s
# so the test runs in < 1 second.
with patch.object(tt, "_local_model", mock_model), \
patch.object(tt, "_local_model_name", "base"), \
patch.object(tt, "_IDLE_UNLOAD_CHECK_INTERVAL", 0.01), \
patch.object(tt, "_last_transcription_time", time.monotonic() - 100):
_start_idle_unload_watcher(timeout_seconds=1)
# Wait for the watcher to fire
for _ in range(100):
if tt._local_model is None:
break
time.sleep(0.02)
assert tt._local_model is None
def test_watcher_does_not_unload_within_timeout(self):
"""The watcher does NOT unload when the model was recently used."""
mock_model = MagicMock(name="whisper_model")
original_model = tt._local_model
original_name = tt._local_model_name
original_interval = tt._IDLE_UNLOAD_CHECK_INTERVAL
original_ts = tt._last_transcription_time
try:
tt._local_model = mock_model
tt._local_model_name = "base"
tt._IDLE_UNLOAD_CHECK_INTERVAL = 0.01
tt._last_transcription_time = time.monotonic()
_start_idle_unload_watcher(timeout_seconds=100)
# Give it a few check cycles — model must survive
time.sleep(0.1)
assert tt._local_model is not None
finally:
tt._local_model = original_model
tt._local_model_name = original_name
tt._IDLE_UNLOAD_CHECK_INTERVAL = original_interval
tt._last_transcription_time = original_ts
def test_watcher_exits_when_model_already_none(self):
"""If the model was unloaded by another path, the watcher exits."""
with patch.object(tt, "_local_model", None), \
patch.object(tt, "_IDLE_UNLOAD_CHECK_INTERVAL", 0.01), \
patch.object(tt, "_last_transcription_time", 0.0):
_start_idle_unload_watcher(timeout_seconds=1)
time.sleep(0.05)
# No crash, no hang — watcher detected _local_model is None and exited
def test_watcher_stopped_on_new_start(self):
"""Starting a new watcher stops the previous one."""
mock_model = MagicMock(name="whisper_model")
with patch.object(tt, "_local_model", mock_model), \
patch.object(tt, "_local_model_name", "base"), \
patch.object(tt, "_IDLE_UNLOAD_CHECK_INTERVAL", 0.01), \
patch.object(tt, "_last_transcription_time", time.monotonic()):
_start_idle_unload_watcher(timeout_seconds=100)
first_thread = tt._idle_unload_thread
assert first_thread is not None and first_thread.is_alive()
_start_idle_unload_watcher(timeout_seconds=200)
second_thread = tt._idle_unload_thread
assert second_thread is not first_thread
# First thread should have been stopped
time.sleep(0.05)
assert not first_thread.is_alive()

View File

@ -139,6 +139,19 @@ _local_model_name: Optional[str] = None
# None` and download/load the whisper model twice (#24767).
_local_model_lock = threading.Lock()
# --- Idle unload ---------------------------------------------------------------
# The model singleton above is loaded once and never released — hundreds of MB
# of RAM/VRAM sit idle between voice messages. On long-running gateway
# processes (especially with local LLMs competing for the same GPU) this is
# wasteful. A lightweight daemon thread checks _last_transcription_time and
# unloads the model after a configurable idle period. The next voice message
# reloads it transparently.
_last_transcription_time: float = 0.0
_idle_unload_thread: Optional[threading.Thread] = None
_idle_unload_stop = threading.Event()
_IDLE_UNLOAD_CHECK_INTERVAL = 30 # seconds between idle checks
# ---------------------------------------------------------------------------
# Config helpers
# ---------------------------------------------------------------------------
@ -1470,6 +1483,72 @@ def _should_force_faster_whisper_cpu() -> bool:
return _sysctl_value("hw.optional.arm64") == "1"
def _get_idle_unload_seconds(local_cfg: Dict[str, Any]) -> int:
"""Resolve the idle unload timeout from config.
0 = never unload (default). Negative values are treated as 0.
"""
try:
val = int(local_cfg.get("unload_after_idle_seconds", 0))
except (TypeError, ValueError):
return 0
return max(val, 0)
def _unload_local_model() -> None:
"""Release the cached local whisper model and free its memory.
Safe to call from any thread. The model lock prevents races with a
concurrent transcription that is mid-load.
"""
global _local_model, _local_model_name
with _local_model_lock:
if _local_model is not None:
logger.info(
"Unloading local whisper model '%s' after idle timeout",
_local_model_name or "unknown",
)
_local_model = None
_local_model_name = None
def _start_idle_unload_watcher(timeout_seconds: int) -> None:
"""Start (or replace) the background idle-unload thread.
The thread checks every ``_IDLE_UNLOAD_CHECK_INTERVAL`` seconds whether
``_last_transcription_time`` is older than ``timeout_seconds``. If so, it
unloads the model and exits. The next transcription restarts it.
"""
global _idle_unload_thread
# Stop any existing watcher — a new timeout may have been configured
_idle_unload_stop.set()
if _idle_unload_thread is not None and _idle_unload_thread.is_alive():
_idle_unload_thread.join(timeout=5)
_idle_unload_stop.clear()
def _watch():
while not _idle_unload_stop.is_set():
if _idle_unload_stop.wait(_IDLE_UNLOAD_CHECK_INTERVAL):
break
if _local_model is None:
break
idle_for = time.monotonic() - _last_transcription_time
if idle_for >= timeout_seconds:
_unload_local_model()
break
_idle_unload_thread = threading.Thread(
target=_watch, name="hermes-stt-idle-unload", daemon=True
)
_idle_unload_thread.start()
def _touch_transcription_time() -> None:
"""Record that a transcription just completed (resets the idle timer)."""
global _last_transcription_time
_last_transcription_time = time.monotonic()
def _load_local_whisper_model(model_name: str, device: str = "auto", compute_type: str = "auto"):
"""Load faster-whisper with graceful CUDA → CPU fallback.
@ -1705,6 +1784,11 @@ def _transcribe_local(file_path: str, model_name: str) -> Dict[str, Any]:
Path(file_path).name, model_name, info.language, info.duration,
)
_touch_transcription_time()
idle_timeout = _get_idle_unload_seconds(local_cfg)
if idle_timeout > 0:
_start_idle_unload_watcher(idle_timeout)
return {"success": True, "transcript": transcript, "provider": "local"}
except Exception as e:

View File

@ -1872,6 +1872,7 @@ stt:
vad_min_silence_ms: 500 # min silence (ms) that splits speech chunks when vad is on
no_speech_prob_threshold: 0.6 # drop a segment only when no_speech_prob > this...
logprob_threshold: -1.0 # ...AND avg_logprob < this (both must hit — quiet real speech survives)
unload_after_idle_seconds: 0 # 0=never unload (default); 300=frees ~370MB RAM after 5min idle
groq:
language: "" # per-provider override of stt.language
openai:
@ -1886,7 +1887,7 @@ Set `stt.echo_transcripts: false` when the gateway should transcribe voice notes
Provider behavior:
- `local` uses `faster-whisper` running on your machine. Install it separately with `pip install faster-whisper`. Silence-hallucination hardening is on by default: a Silero VAD filter keeps silence/noise from ever reaching Whisper, cross-window conditioning is disabled, and segments the model itself flags as probably-not-speech *and* low-confidence are dropped. Set `stt.local.vad: false` to transcribe non-speech audio (music, ambient) with the raw behavior.
- `local` uses `faster-whisper` running on your machine. Install it separately with `pip install faster-whisper`. Silence-hallucination hardening is on by default: a Silero VAD filter keeps silence/noise from ever reaching Whisper, cross-window conditioning is disabled, and segments the model itself flags as probably-not-speech *and* low-confidence are dropped. Set `stt.local.vad: false` to transcribe non-speech audio (music, ambient) with the raw behavior. The model stays loaded in memory between voice messages for low-latency transcription; set `stt.local.unload_after_idle_seconds` (e.g. `300` for 5 minutes) to automatically release the model when idle — useful on gateway processes or machines with limited RAM/VRAM.
- `groq` uses Groq's Whisper-compatible endpoint and reads `GROQ_API_KEY`. Pass `stt.groq.language` (or the global `HERMES_LOCAL_STT_LANGUAGE` env var) to skip auto-detection and reduce latency.
- `openai` uses the OpenAI speech API and reads `VOICE_TOOLS_OPENAI_KEY`.