Просмотр исходного кода

Added warning to notice if transcription starts falling behind

Efren Yevale Varela 1 месяц назад
Родитель
Сommit
4c0c587966
6 измененных файлов с 129 добавлено и 1 удалено
  1. 17 0
      README.md
  2. 4 0
      config.example.toml
  3. 18 0
      src/stt_daemon/config.py
  4. 16 1
      src/stt_daemon/daemon.py
  5. 30 0
      tests/test_config.py
  6. 44 0
      tests/test_daemon.py

+ 17 - 0
README.md

@@ -127,6 +127,10 @@ sink_name             = "VirtualMicSink"    # Name of the null sink to create
 remap_source_name     = "VirtualMic"        # Name of the exposed microphone
 remap_source_name     = "VirtualMic"        # Name of the exposed microphone
 loopback_latency_msec = 1                   # Loopback latency in ms (lower = faster, higher = smoother)
 loopback_latency_msec = 1                   # Loopback latency in ms (lower = faster, higher = smoother)
 monitor_output        = false               # Loop sink output back to default speaker (CAUTION: feedback risk!)
 monitor_output        = false               # Loop sink output back to default speaker (CAUTION: feedback risk!)
+
+[daemon]
+queue_size      = 50                        # Max utterances queued for transcription (oldest dropped when full)
+backlog_warn_at = 5                         # Warn when the queue backs up this far (worker falling behind)
 ```
 ```
 
 
 ### Audio Source Setup
 ### Audio Source Setup
@@ -453,6 +457,19 @@ Example:
 uv run stt-daemon --model base --device cuda
 uv run stt-daemon --model base --device cuda
 ```
 ```
 
 
+### "Transcription backlog reached N of M"
+
+This warning means the single transcription worker is falling behind: utterances
+are arriving faster than they are being transcribed, and once the queue is full
+the oldest ones get dropped. The worker is serial by design (faster-whisper's
+model isn't thread-safe), so fix it by making transcription faster rather than by
+queueing more:
+
+1. **Smaller/faster model**: `--model base` or `tiny`
+2. **GPU**: `--device cuda` with `compute_type float16`
+3. **Raise `backlog_warn_at`** or `queue_size` in `[daemon]` only to make the
+   warning/dropping less likely — it hides the symptom, not the cause.
+
 ### Spanish / English / Other languages
 ### Spanish / English / Other languages
 
 
 The Faster-Whisper model is **multilingual by default**. It auto-detects language or can be forced:
 The Faster-Whisper model is **multilingual by default**. It auto-detects language or can be forced:

+ 4 - 0
config.example.toml

@@ -39,3 +39,7 @@ sink_name             = "VirtualMicSink"    # Name of the null sink to create
 remap_source_name     = "VirtualMic"        # Name of the exposed microphone
 remap_source_name     = "VirtualMic"        # Name of the exposed microphone
 loopback_latency_msec = 1                   # Loopback latency in ms
 loopback_latency_msec = 1                   # Loopback latency in ms
 monitor_output        = false               # Loop sink.monitor to default sink (AVOID: causes acoustic feedback)
 monitor_output        = false               # Loop sink.monitor to default sink (AVOID: causes acoustic feedback)
+
+[daemon]
+queue_size      = 50                  # max utterances waiting for transcription (oldest dropped when full)
+backlog_warn_at = 5                   # warn when the queue backs up this far (worker falling behind)

+ 18 - 0
src/stt_daemon/config.py

@@ -53,6 +53,13 @@ class OutputConfig:
     audio_dir: str = "audio"
     audio_dir: str = "audio"
 
 
 
 
+@dataclass
+class DaemonConfig:
+    """Daemon runtime configuration."""
+    queue_size: int = 50
+    backlog_warn_at: int = 5
+
+
 @dataclass
 @dataclass
 class SinkConfig:
 class SinkConfig:
     """Virtual audio sink configuration."""
     """Virtual audio sink configuration."""
@@ -70,6 +77,7 @@ class Config:
     whisper: WhisperConfig = field(default_factory=WhisperConfig)
     whisper: WhisperConfig = field(default_factory=WhisperConfig)
     output: OutputConfig = field(default_factory=OutputConfig)
     output: OutputConfig = field(default_factory=OutputConfig)
     sink: SinkConfig = field(default_factory=SinkConfig)
     sink: SinkConfig = field(default_factory=SinkConfig)
+    daemon: DaemonConfig = field(default_factory=DaemonConfig)
 
 
 
 
 def load_config(path: str | None = None) -> Config:
 def load_config(path: str | None = None) -> Config:
@@ -171,4 +179,14 @@ def load_config(path: str | None = None) -> Config:
                 monitor_output=sink_data.get("monitor_output", cfg.sink.monitor_output),
                 monitor_output=sink_data.get("monitor_output", cfg.sink.monitor_output),
             )
             )
 
 
+        # Daemon section
+        if "daemon" in data:
+            daemon_data = data["daemon"]
+            cfg.daemon = DaemonConfig(
+                queue_size=daemon_data.get("queue_size", cfg.daemon.queue_size),
+                backlog_warn_at=daemon_data.get(
+                    "backlog_warn_at", cfg.daemon.backlog_warn_at
+                ),
+            )
+
     return cfg
     return cfg

+ 16 - 1
src/stt_daemon/daemon.py

@@ -39,9 +39,10 @@ class Daemon:
         self.sink: Optional[TranscriptSink] = None
         self.sink: Optional[TranscriptSink] = None
 
 
         # Threading
         # Threading
-        self.segment_queue: queue.Queue = queue.Queue(maxsize=50)
+        self.segment_queue: queue.Queue = queue.Queue(maxsize=cfg.daemon.queue_size)
         self._stop_event = threading.Event()
         self._stop_event = threading.Event()
         self._stopped = False
         self._stopped = False
+        self._backlog_warned = False
         self._reader_thread: Optional[threading.Thread] = None
         self._reader_thread: Optional[threading.Thread] = None
         self._worker_thread: Optional[threading.Thread] = None
         self._worker_thread: Optional[threading.Thread] = None
 
 
@@ -129,6 +130,7 @@ class Daemon:
                             self.segment_queue.put(utt, block=False)
                             self.segment_queue.put(utt, block=False)
                         except queue.Empty:
                         except queue.Empty:
                             pass
                             pass
+                    self._check_backlog()
 
 
             # Shutdown: flush any in-progress utterance
             # Shutdown: flush any in-progress utterance
             if self.segmenter:
             if self.segmenter:
@@ -148,6 +150,19 @@ class Daemon:
             if self.capture:
             if self.capture:
                 self.capture.close()
                 self.capture.close()
 
 
+    def _check_backlog(self) -> None:
+        """Warn once per overload episode; re-arm only after the queue fully drains."""
+        size = self.segment_queue.qsize()
+        if size >= self.cfg.daemon.backlog_warn_at and not self._backlog_warned:
+            self.logger.warning(
+                "Transcription backlog reached %d of %d - worker is falling behind",
+                size,
+                self.segment_queue.maxsize,
+            )
+            self._backlog_warned = True
+        elif size == 0 and self._backlog_warned:
+            self._backlog_warned = False
+
     def _worker_loop(self) -> None:
     def _worker_loop(self) -> None:
         """Worker thread: transcribe segments and emit results."""
         """Worker thread: transcribe segments and emit results."""
         try:
         try:

+ 30 - 0
tests/test_config.py

@@ -216,3 +216,33 @@ audio_dir = "clips"
 
 
     assert cfg.output.save_audio is True
     assert cfg.output.save_audio is True
     assert cfg.output.audio_dir == "clips"
     assert cfg.output.audio_dir == "clips"
+
+
+def test_daemon_defaults():
+    with tempfile.TemporaryDirectory() as tmpdir:
+        import os
+        old_cwd = os.getcwd()
+        try:
+            os.chdir(tmpdir)
+            cfg = load_config()
+        finally:
+            os.chdir(old_cwd)
+
+    assert cfg.daemon.queue_size == 50
+    assert cfg.daemon.backlog_warn_at == 5
+
+
+def test_daemon_config_fields():
+    config_toml = """
+[daemon]
+queue_size = 20
+backlog_warn_at = 3
+"""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        config_path = Path(tmpdir) / "config.toml"
+        config_path.write_text(config_toml)
+
+        cfg = load_config(str(config_path))
+
+    assert cfg.daemon.queue_size == 20
+    assert cfg.daemon.backlog_warn_at == 3

+ 44 - 0
tests/test_daemon.py

@@ -0,0 +1,44 @@
+"""Tests for the daemon's transcription backlog warning."""
+
+import logging
+
+from stt_daemon.config import Config
+from stt_daemon.daemon import Daemon
+
+
+def _daemon(threshold=2, queue_size=10):
+    cfg = Config()
+    cfg.daemon.backlog_warn_at = threshold
+    cfg.daemon.queue_size = queue_size
+    return Daemon(cfg)
+
+
+def test_queue_size_comes_from_config():
+    daemon = _daemon(queue_size=7)
+    assert daemon.segment_queue.maxsize == 7
+
+
+def test_backlog_warning_is_edge_triggered(caplog):
+    daemon = _daemon(threshold=2)
+
+    with caplog.at_level(logging.WARNING, logger="stt_daemon"):
+        daemon.segment_queue.put("a")
+        daemon._check_backlog()
+        assert "backlog" not in caplog.text.lower()
+
+        daemon.segment_queue.put("b")
+        daemon._check_backlog()
+        assert caplog.text.lower().count("backlog") == 1
+
+        daemon._check_backlog()
+        assert caplog.text.lower().count("backlog") == 1
+
+        daemon.segment_queue.get()
+        daemon.segment_queue.get()
+        daemon._check_backlog()
+        assert daemon._backlog_warned is False
+
+        daemon.segment_queue.put("c")
+        daemon.segment_queue.put("d")
+        daemon._check_backlog()
+        assert caplog.text.lower().count("backlog") == 2