فهرست منبع

Added language strategy

Efren Yevale Varela 1 ماه پیش
والد
کامیت
0c99fd60ea
8فایلهای تغییر یافته به همراه369 افزوده شده و 29 حذف شده
  1. 16 11
      README.md
  2. 11 9
      config.example.toml
  3. 2 0
      src/stt_daemon/cli.py
  4. 12 0
      src/stt_daemon/config.py
  5. 2 0
      src/stt_daemon/daemon.py
  6. 65 6
      src/stt_daemon/transcriber.py
  7. 76 0
      tests/test_config.py
  8. 185 3
      tests/test_transcriber.py

+ 16 - 11
README.md

@@ -111,17 +111,22 @@ rms_floor        = 0.008              # Drop segments quieter than this RMS (noi
 trailing_trim_ms = 200                # Keep only this much trailing silence (less hallucination-prone padding)
 
 [whisper]
-model        = "small"                # "tiny"/"base"/"small"/"medium"/"large" (multilingual; do NOT use `.en` variants)
-device       = "cpu"                  # "cpu" or "cuda"
-compute_type = "int8"                 # "int8" (CPU) or "float16" (CUDA, slower but more accurate)
-language     = "auto"                 # "auto" (detect), "en", "es", or None/""/"none"
-beam_size    = 1                      # 1 = fast greedy, >1 = slower beam search
-vad_filter   = false                  # Use Whisper's built-in VAD (we already have webrtcvad)
-cpu_threads  = 0                      # CPU inference threads (0 = CTranslate2's automatic choice).
-                                      # On many-core CPUs, set this to the physical core count
-                                      # (e.g. 16 on a Ryzen 9 8945HX) - measurably faster than auto.
-no_speech_prob_max = 0.6              # Drop segments Whisper scores as non-speech above this
-avg_logprob_min    = -1.0             # Drop segments with confidence below this
+model                   = "small"               # "tiny"/"base"/"small"/"medium"/"large" (multilingual; do NOT use `.en` variants)
+device                  = "cpu"                 # "cpu" or "cuda"
+compute_type            = "int8"                # "int8" (CPU) or "float16" (CUDA, slower but more accurate)
+language                = "auto"                # "auto" (detect), "en", "es", or None/""/"none"
+beam_size               = 1                     # 1 = fast greedy, >1 = slower beam search
+vad_filter              = false                 # Use Whisper's built-in VAD (we already have webrtcvad)
+cpu_threads             = 0                     # CPU inference threads (0 = CTranslate2's automatic choice).
+                                                # On many-core CPUs, set this to the physical core count
+                                                # (e.g. 16 on a Ryzen 9 8945HX) - measurably faster than auto.
+no_speech_prob_max      = 0.6                   # Drop segments Whisper scores as non-speech above this
+avg_logprob_min         = -1.0                  # Drop segments with confidence below this
+language_strategy       = "static"              # "static" (detect per utterance) | "sticky" (detect once, pin)
+sticky_redetect_logprob = -0.8                  # Unpin and re-detect if segment avg_logprob falls below this.
+                                                # Segments below avg_logprob_min (-1.0) are dropped; between
+                                                # -1.0 and -0.8 they are accepted but trigger re-detection.
+                                                # No accepted segments also clears the pin for re-detection.
 
 [sink]                                # Virtual audio sink configuration
 sink_name             = "VirtualMicSink"       # null-sink name

+ 11 - 9
config.example.toml

@@ -19,15 +19,17 @@ rms_floor        = 0.008
 trailing_trim_ms = 200
 
 [whisper]
-model              = "small"
-device             = "cpu"
-compute_type       = "int8"
-language           = "auto"
-beam_size          = 1
-vad_filter         = false
-cpu_threads        = 0
-no_speech_prob_max = 0.6
-avg_logprob_min    = -1.0
+model                   = "small"
+device                  = "cpu"
+compute_type            = "int8"
+language                = "auto"
+beam_size               = 1
+vad_filter              = false
+cpu_threads             = 0
+no_speech_prob_max      = 0.6
+avg_logprob_min         = -1.0
+language_strategy       = "static"
+sticky_redetect_logprob = -0.8
 
 [sink]
 sink_name             = "VirtualMicSink"

+ 2 - 0
src/stt_daemon/cli.py

@@ -431,6 +431,8 @@ def _check(args) -> int:
             cpu_threads=cfg.whisper.cpu_threads,
             no_speech_prob_max=cfg.whisper.no_speech_prob_max,
             avg_logprob_min=cfg.whisper.avg_logprob_min,
+            language_strategy=cfg.whisper.language_strategy,
+            sticky_redetect_logprob=cfg.whisper.sticky_redetect_logprob,
         )
         transcriber.close()
         print("✓ Model ready", flush=True)

+ 12 - 0
src/stt_daemon/config.py

@@ -59,6 +59,8 @@ class WhisperConfig:
     cpu_threads: int = 0
     no_speech_prob_max: float = 0.6
     avg_logprob_min: float = -1.0
+    language_strategy: str = "static"
+    sticky_redetect_logprob: float = -0.8
 
 
 @dataclass
@@ -217,6 +219,12 @@ def load_config(path: str | None = None) -> Config:
             # Normalize language: "", "auto", "none" -> None
             if language in ("", "auto", "none"):
                 language = None
+            strategy = whisper_data.get("language_strategy", cfg.whisper.language_strategy)
+            if strategy not in ("static", "sticky"):
+                logger.warning(
+                    "Unknown language_strategy %r; falling back to 'static'", strategy
+                )
+                strategy = "static"
             cfg.whisper = WhisperConfig(
                 model=whisper_data.get("model", cfg.whisper.model),
                 device=whisper_data.get("device", cfg.whisper.device),
@@ -231,6 +239,10 @@ def load_config(path: str | None = None) -> Config:
                 avg_logprob_min=whisper_data.get(
                     "avg_logprob_min", cfg.whisper.avg_logprob_min
                 ),
+                language_strategy=strategy,
+                sticky_redetect_logprob=whisper_data.get(
+                    "sticky_redetect_logprob", cfg.whisper.sticky_redetect_logprob
+                ),
             )
 
         # Output section

+ 2 - 0
src/stt_daemon/daemon.py

@@ -108,6 +108,8 @@ class Daemon:
             cpu_threads=self.cfg.whisper.cpu_threads,
             no_speech_prob_max=self.cfg.whisper.no_speech_prob_max,
             avg_logprob_min=self.cfg.whisper.avg_logprob_min,
+            language_strategy=self.cfg.whisper.language_strategy,
+            sticky_redetect_logprob=self.cfg.whisper.sticky_redetect_logprob,
         )
 
     def _build_sink(self) -> TranscriptSink:

+ 65 - 6
src/stt_daemon/transcriber.py

@@ -1,10 +1,13 @@
 """Transcription using Faster-Whisper."""
 
+import logging
 from dataclasses import dataclass
 
 import numpy as np
 from faster_whisper import WhisperModel
 
+logger = logging.getLogger(__name__)
+
 
 @dataclass
 class TranscriptResult:
@@ -40,6 +43,8 @@ class Transcriber:
         cpu_threads: int = 0,
         no_speech_prob_max: float = 0.6,
         avg_logprob_min: float = -1.0,
+        language_strategy: str = "static",
+        sticky_redetect_logprob: float = -0.8,
     ):
         """Initialize the transcriber.
 
@@ -55,6 +60,8 @@ class Transcriber:
                 faster than the automatic choice on many-core CPUs.
             no_speech_prob_max: Drop segments Whisper scores as non-speech above this.
             avg_logprob_min: Drop segments with average log-probability below this.
+            language_strategy: "static" (always auto-detect) or "sticky" (detect once, pin).
+            sticky_redetect_logprob: Threshold below which to clear the pin (re-detect).
         """
         self.language = language
         self.device = device
@@ -64,6 +71,10 @@ class Transcriber:
         self.cpu_threads = cpu_threads
         self.no_speech_prob_max = no_speech_prob_max
         self.avg_logprob_min = avg_logprob_min
+        self.language_strategy = language_strategy
+        self.sticky_redetect_logprob = sticky_redetect_logprob
+        self._pinned_language: str | None = None
+        self._pinned_language_probability: float = 1.0
 
         # Attempt CUDA if requested, fall back to CPU
         if device == "cuda":
@@ -91,31 +102,79 @@ class Transcriber:
     def transcribe(self, audio: np.ndarray) -> TranscriptResult:
         """Transcribe audio to text.
 
+        With sticky language strategy, the language is detected once and then
+        pinned for subsequent utterances to save computation. The pin is cleared
+        if a result looks untrustworthy (low confidence or no accepted segments).
+
         Args:
             audio: Audio array (float32, mono, 16 kHz).
 
         Returns:
             TranscriptResult with language, probability, and text.
         """
+        # Decide which language to request
+        language_to_send: str | None = self.language
+        if self.language is None and self.language_strategy == "sticky":
+            language_to_send = self._pinned_language
+
         segments, info = self.model.transcribe(
             audio,
-            language=self.language,
+            language=language_to_send,
             beam_size=self.beam_size,
             vad_filter=self.vad_filter,
             condition_on_previous_text=False,
             without_timestamps=True,
         )
 
-        # Join the segments Whisper is confident about
-        text = "".join(
-            seg.text
+        # Collect accepted segments and track their confidence
+        accepted_segments = [
+            seg
             for seg in segments
             if accept_segment(seg, self.no_speech_prob_max, self.avg_logprob_min)
-        ).strip()
+        ]
+        text = "".join(seg.text for seg in accepted_segments).strip()
+
+        # Sticky pinning logic (only when auto-detecting with sticky strategy)
+        language_probability_to_report = info.language_probability
+        if self.language is None and self.language_strategy == "sticky":
+            # We just auto-detected (sent language=None to the engine)
+            if language_to_send is None:
+                # This is the first detection (no pin yet)
+                self._pinned_language = info.language
+                self._pinned_language_probability = info.language_probability
+                logger.info(
+                    "Pinned language to %r (probability %.3f)",
+                    self._pinned_language,
+                    self._pinned_language_probability,
+                )
+            else:
+                # We already had a pin; check if we should clear it
+                should_clear = False
+                reason = ""
+                if not accepted_segments:
+                    should_clear = True
+                    reason = "no accepted segments"
+                else:
+                    max_logprob = max(
+                        getattr(seg, "avg_logprob", 0.0) for seg in accepted_segments
+                    )
+                    if max_logprob < self.sticky_redetect_logprob:
+                        should_clear = True
+                        reason = (
+                            f"best segment avg_logprob {max_logprob:.3f} < "
+                            f"sticky_redetect_logprob {self.sticky_redetect_logprob}"
+                        )
+                if should_clear:
+                    logger.info("Clearing language pin: %s", reason)
+                    self._pinned_language = None
+                    self._pinned_language_probability = 1.0
+            # Report the stored pin probability when pinned
+            if self._pinned_language is not None:
+                language_probability_to_report = self._pinned_language_probability
 
         return TranscriptResult(
             language=info.language,
-            language_probability=info.language_probability,
+            language_probability=language_probability_to_report,
             text=text,
         )
 

+ 76 - 0
tests/test_config.py

@@ -291,3 +291,79 @@ cpu_threads = 16
         cfg = load_config(str(config_path))
 
     assert cfg.whisper.cpu_threads == 16
+
+
+def test_language_strategy_defaults_to_static():
+    """language_strategy defaults to 'static'."""
+    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.whisper.language_strategy == "static"
+
+
+def test_language_strategy_sticky_is_parsed():
+    """language_strategy 'sticky' is parsed correctly."""
+    config_toml = """
+[whisper]
+language_strategy = "sticky"
+"""
+    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.whisper.language_strategy == "sticky"
+
+
+def test_unknown_language_strategy_falls_back_to_static(caplog):
+    """An unknown language_strategy value falls back to 'static' with a warning."""
+    config_toml = """
+[whisper]
+language_strategy = "bogus"
+"""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        config_path = Path(tmpdir) / "config.toml"
+        config_path.write_text(config_toml)
+
+        with caplog.at_level("WARNING", logger="stt_daemon.config"):
+            cfg = load_config(str(config_path))
+
+    assert cfg.whisper.language_strategy == "static"
+    assert "Unknown language_strategy" in caplog.text
+    assert "bogus" in caplog.text
+
+
+def test_sticky_redetect_logprob_defaults_to_minus_0_8():
+    """sticky_redetect_logprob defaults to -0.8."""
+    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.whisper.sticky_redetect_logprob == -0.8
+
+
+def test_sticky_redetect_logprob_is_parsed():
+    """sticky_redetect_logprob is parsed from TOML."""
+    config_toml = """
+[whisper]
+sticky_redetect_logprob = -0.5
+"""
+    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.whisper.sticky_redetect_logprob == -0.5

+ 185 - 3
tests/test_transcriber.py

@@ -38,8 +38,9 @@ def test_missing_attributes_default_to_accept():
 
 
 class _Info:
-    language = "en"
-    language_probability = 1.0
+    def __init__(self, language="en", language_probability=1.0):
+        self.language = language
+        self.language_probability = language_probability
 
 
 class _FakeWhisperModel:
@@ -55,11 +56,18 @@ class _FakeWhisperModel:
             "cpu_threads": cpu_threads,
         }
         self.transcribe_kwargs = None
+        self.transcribe_calls = []
+        self.segments_to_return = []
+        self.detected_language = "en"
+        self.detected_language_probability = 1.0
         _FakeWhisperModel.last = self
 
     def transcribe(self, audio, **kwargs):
         self.transcribe_kwargs = kwargs
-        return [], _Info()
+        self.transcribe_calls.append(kwargs)
+        return self.segments_to_return, _Info(
+            self.detected_language, self.detected_language_probability
+        )
 
 
 @pytest.fixture
@@ -104,3 +112,177 @@ def test_explicit_language_is_preserved(fake_model):
     t = Transcriber("small", device="cpu", compute_type="int8", language="es")
     t.transcribe(_audio())
     assert fake_model.last.transcribe_kwargs["language"] == "es"
+
+
+def test_sticky_pins_language_after_first_detection(fake_model):
+    """With sticky strategy, language is detected once and pinned."""
+    t = Transcriber(
+        "small",
+        device="cpu",
+        compute_type="int8",
+        language=None,
+        language_strategy="sticky",
+    )
+    fake_model.last.detected_language = "es"
+    fake_model.last.detected_language_probability = 0.95
+
+    # First call: auto-detect (language=None sent)
+    t.transcribe(_audio())
+    assert fake_model.last.transcribe_calls[0]["language"] is None
+    assert t._pinned_language == "es"
+    assert t._pinned_language_probability == 0.95
+
+    # Second call: uses pinned language (language="es" sent)
+    t.transcribe(_audio())
+    assert fake_model.last.transcribe_calls[1]["language"] == "es"
+
+
+def test_sticky_reports_stored_detection_probability_when_pinned(fake_model):
+    """When pinned, report the stored detection probability, not Whisper's 1.0."""
+    t = Transcriber(
+        "small",
+        device="cpu",
+        compute_type="int8",
+        language=None,
+        language_strategy="sticky",
+    )
+    fake_model.last.detected_language = "es"
+    fake_model.last.detected_language_probability = 0.92
+
+    # First call: auto-detect and pin
+    result1 = t.transcribe(_audio())
+    assert result1.language_probability == 0.92
+
+    # Second call: pinned; Whisper returns 1.0 (explicit language), but we report stored
+    fake_model.last.detected_language = "es"
+    fake_model.last.detected_language_probability = 1.0
+    fake_model.last.segments_to_return = [_Seg()]  # Provide a segment so pin isn't cleared
+    result2 = t.transcribe(_audio())
+    assert result2.language_probability == 0.92  # Stored, not 1.0
+
+
+def test_sticky_clears_pin_when_best_logprob_too_low(fake_model):
+    """Sticky clears the pin when the best segment's avg_logprob is below threshold."""
+    t = Transcriber(
+        "small",
+        device="cpu",
+        compute_type="int8",
+        language=None,
+        language_strategy="sticky",
+        sticky_redetect_logprob=-0.8,
+        avg_logprob_min=-1.0,
+    )
+    fake_model.last.detected_language = "en"
+    fake_model.last.detected_language_probability = 0.98
+
+    # Pin the language on first call with good segment
+    fake_model.last.segments_to_return = [_Seg(avg_logprob=-0.5)]
+    t.transcribe(_audio())
+    assert t._pinned_language == "en"
+
+    # Second call: return a segment with avg_logprob below threshold (-0.85 < -0.8)
+    # but still accepted (above avg_logprob_min of -1.0)
+    seg_low = _Seg(avg_logprob=-0.85)
+    fake_model.last.segments_to_return = [seg_low]
+
+    t.transcribe(_audio())
+    # Pin should be cleared because max logprob is below sticky_redetect_logprob
+    assert t._pinned_language is None
+
+
+def test_sticky_clears_pin_when_no_segments_accepted(fake_model):
+    """Sticky clears the pin when no segments pass the confidence gate."""
+    t = Transcriber(
+        "small",
+        device="cpu",
+        compute_type="int8",
+        language=None,
+        language_strategy="sticky",
+    )
+    fake_model.last.detected_language = "en"
+    fake_model.last.detected_language_probability = 0.99
+
+    # Pin the language
+    t.transcribe(_audio())
+    assert t._pinned_language == "en"
+
+    # Second call: return a segment that fails the gate (high no_speech_prob)
+    seg_bad = _Seg(no_speech_prob=0.9)
+    fake_model.last.segments_to_return = [seg_bad]
+
+    t.transcribe(_audio())
+    # Pin should be cleared because no segments were accepted
+    assert t._pinned_language is None
+
+
+def test_static_strategy_never_pins(fake_model):
+    """Static (default) strategy never pins; always sends language=None for auto-detect."""
+    t = Transcriber(
+        "small",
+        device="cpu",
+        compute_type="int8",
+        language=None,
+        language_strategy="static",
+    )
+    fake_model.last.detected_language = "es"
+
+    # First call
+    t.transcribe(_audio())
+    assert fake_model.last.transcribe_calls[0]["language"] is None
+    assert t._pinned_language is None
+
+    # Second call
+    t.transcribe(_audio())
+    assert fake_model.last.transcribe_calls[1]["language"] is None
+    assert t._pinned_language is None
+
+
+def test_explicit_language_with_sticky_ignores_sticky(fake_model):
+    """When language is explicit, sticky strategy has no effect; no pinning occurs."""
+    t = Transcriber(
+        "small",
+        device="cpu",
+        compute_type="int8",
+        language="es",
+        language_strategy="sticky",
+    )
+    fake_model.last.detected_language = "es"
+
+    # First call: sends language="es" (explicit)
+    t.transcribe(_audio())
+    assert fake_model.last.transcribe_calls[0]["language"] == "es"
+    assert t._pinned_language is None  # No pinning for explicit language
+
+    # Second call: still sends language="es"
+    t.transcribe(_audio())
+    assert fake_model.last.transcribe_calls[1]["language"] == "es"
+    assert t._pinned_language is None
+
+
+def test_sticky_tolerates_missing_avg_logprob_attribute(fake_model):
+    """Sticky pin clearing uses getattr(..., 0.0) to tolerate missing avg_logprob."""
+    t = Transcriber(
+        "small",
+        device="cpu",
+        compute_type="int8",
+        language=None,
+        language_strategy="sticky",
+        sticky_redetect_logprob=-0.8,
+    )
+    fake_model.last.detected_language = "en"
+    fake_model.last.detected_language_probability = 0.99
+
+    # Pin the language with a normal segment
+    fake_model.last.segments_to_return = [_Seg(avg_logprob=-0.5)]
+    t.transcribe(_audio())
+    assert t._pinned_language == "en"
+
+    # Second call: return a segment with no avg_logprob attribute (only text/no_speech_prob)
+    class SegNoLogprob:
+        text = "hello"
+        no_speech_prob = 0.1
+    fake_model.last.segments_to_return = [SegNoLogprob()]
+
+    # Should not raise; defaulted 0.0 is above threshold -0.8, so pin is RETAINED
+    t.transcribe(_audio())
+    assert t._pinned_language == "en"  # Pin retained because 0.0 >= -0.8