test_transcriber.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. """Tests for the Whisper segment confidence gate."""
  2. import numpy as np
  3. import pytest
  4. from stt_daemon import pywhispercpp_transcriber as pw_mod
  5. from stt_daemon import transcriber as transcriber_mod
  6. from stt_daemon.config import Config
  7. from stt_daemon.pywhispercpp_transcriber import PyWhisperCppTranscriber
  8. from stt_daemon.transcriber import Transcriber, accept_segment
  9. class _Seg:
  10. def __init__(self, text="hello", no_speech_prob=0.1, avg_logprob=-0.3):
  11. self.text = text
  12. self.no_speech_prob = no_speech_prob
  13. self.avg_logprob = avg_logprob
  14. def test_accepts_confident_speech():
  15. assert accept_segment(_Seg(), 0.6, -1.0)
  16. def test_rejects_high_no_speech_prob():
  17. assert not accept_segment(_Seg(no_speech_prob=0.9), 0.6, -1.0)
  18. def test_rejects_low_avg_logprob():
  19. assert not accept_segment(_Seg(avg_logprob=-1.5), 0.6, -1.0)
  20. def test_threshold_boundary_is_accepted():
  21. assert accept_segment(_Seg(no_speech_prob=0.6, avg_logprob=-1.0), 0.6, -1.0)
  22. def test_missing_attributes_default_to_accept():
  23. class Bare:
  24. text = "x"
  25. assert accept_segment(Bare(), 0.6, -1.0)
  26. class _Info:
  27. def __init__(self, language="en", language_probability=1.0):
  28. self.language = language
  29. self.language_probability = language_probability
  30. class _FakeWhisperModel:
  31. """Records constructor/transcribe kwargs without loading a real model."""
  32. last = None
  33. def __init__(self, model, device, compute_type, cpu_threads=0):
  34. self.init_kwargs = {
  35. "model": model,
  36. "device": device,
  37. "compute_type": compute_type,
  38. "cpu_threads": cpu_threads,
  39. }
  40. self.transcribe_kwargs = None
  41. self.transcribe_calls = []
  42. self.segments_to_return = []
  43. self.detected_language = "en"
  44. self.detected_language_probability = 1.0
  45. _FakeWhisperModel.last = self
  46. def transcribe(self, audio, **kwargs):
  47. self.transcribe_kwargs = kwargs
  48. self.transcribe_calls.append(kwargs)
  49. return self.segments_to_return, _Info(
  50. self.detected_language, self.detected_language_probability
  51. )
  52. @pytest.fixture
  53. def fake_model(monkeypatch):
  54. monkeypatch.setattr(transcriber_mod, "WhisperModel", _FakeWhisperModel)
  55. return _FakeWhisperModel
  56. def _audio():
  57. return np.zeros(16000, dtype=np.float32)
  58. def test_cpu_threads_forwarded_to_model(fake_model):
  59. t = Transcriber(
  60. "small", device="cpu", compute_type="int8", language=None, cpu_threads=16
  61. )
  62. assert t.cpu_threads == 16
  63. assert fake_model.last.init_kwargs["cpu_threads"] == 16
  64. def test_cpu_threads_default_is_auto(fake_model):
  65. Transcriber("small", device="cpu", compute_type="int8", language=None)
  66. assert fake_model.last.init_kwargs["cpu_threads"] == 0
  67. def test_transcribe_disables_timestamps(fake_model):
  68. t = Transcriber("small", device="cpu", compute_type="int8", language=None)
  69. t.transcribe(_audio())
  70. kwargs = fake_model.last.transcribe_kwargs
  71. assert kwargs["without_timestamps"] is True
  72. assert kwargs["condition_on_previous_text"] is False
  73. def test_text_joins_spaceless_segments_with_space(fake_model):
  74. t = Transcriber("small", device="cpu", compute_type="int8", language=None)
  75. fake_model.last.segments_to_return = [_Seg(text="most"), _Seg(text="matter")]
  76. assert t.transcribe(_audio()).text == "most matter"
  77. def test_auto_language_stays_none_for_bilingual(fake_model):
  78. """language=auto must reach the engine as None so es/en both keep working."""
  79. t = Transcriber("small", device="cpu", compute_type="int8", language=None)
  80. t.transcribe(_audio())
  81. assert fake_model.last.transcribe_kwargs["language"] is None
  82. def test_explicit_language_is_preserved(fake_model):
  83. t = Transcriber("small", device="cpu", compute_type="int8", language="es")
  84. t.transcribe(_audio())
  85. assert fake_model.last.transcribe_kwargs["language"] == "es"
  86. def test_sticky_pins_language_after_first_detection(fake_model):
  87. """With sticky strategy, language is detected once and pinned."""
  88. t = Transcriber(
  89. "small",
  90. device="cpu",
  91. compute_type="int8",
  92. language=None,
  93. language_strategy="sticky",
  94. )
  95. fake_model.last.detected_language = "es"
  96. fake_model.last.detected_language_probability = 0.95
  97. # First call: auto-detect (language=None sent)
  98. t.transcribe(_audio())
  99. assert fake_model.last.transcribe_calls[0]["language"] is None
  100. assert t._pinned_language == "es"
  101. assert t._pinned_language_probability == 0.95
  102. # Second call: uses pinned language (language="es" sent)
  103. t.transcribe(_audio())
  104. assert fake_model.last.transcribe_calls[1]["language"] == "es"
  105. def test_sticky_reports_stored_detection_probability_when_pinned(fake_model):
  106. """When pinned, report the stored detection probability, not Whisper's 1.0."""
  107. t = Transcriber(
  108. "small",
  109. device="cpu",
  110. compute_type="int8",
  111. language=None,
  112. language_strategy="sticky",
  113. )
  114. fake_model.last.detected_language = "es"
  115. fake_model.last.detected_language_probability = 0.92
  116. # First call: auto-detect and pin
  117. result1 = t.transcribe(_audio())
  118. assert result1.language_probability == 0.92
  119. # Second call: pinned; Whisper returns 1.0 (explicit language), but we report stored
  120. fake_model.last.detected_language = "es"
  121. fake_model.last.detected_language_probability = 1.0
  122. fake_model.last.segments_to_return = [_Seg()] # Provide a segment so pin isn't cleared
  123. result2 = t.transcribe(_audio())
  124. assert result2.language_probability == 0.92 # Stored, not 1.0
  125. def test_sticky_clears_pin_when_best_logprob_too_low(fake_model):
  126. """Sticky clears the pin when the best segment's avg_logprob is below threshold."""
  127. t = Transcriber(
  128. "small",
  129. device="cpu",
  130. compute_type="int8",
  131. language=None,
  132. language_strategy="sticky",
  133. sticky_redetect_logprob=-0.8,
  134. avg_logprob_min=-1.0,
  135. )
  136. fake_model.last.detected_language = "en"
  137. fake_model.last.detected_language_probability = 0.98
  138. # Pin the language on first call with good segment
  139. fake_model.last.segments_to_return = [_Seg(avg_logprob=-0.5)]
  140. t.transcribe(_audio())
  141. assert t._pinned_language == "en"
  142. # Second call: return a segment with avg_logprob below threshold (-0.85 < -0.8)
  143. # but still accepted (above avg_logprob_min of -1.0)
  144. seg_low = _Seg(avg_logprob=-0.85)
  145. fake_model.last.segments_to_return = [seg_low]
  146. t.transcribe(_audio())
  147. # Pin should be cleared because max logprob is below sticky_redetect_logprob
  148. assert t._pinned_language is None
  149. def test_sticky_clears_pin_when_no_segments_accepted(fake_model):
  150. """Sticky clears the pin when no segments pass the confidence gate."""
  151. t = Transcriber(
  152. "small",
  153. device="cpu",
  154. compute_type="int8",
  155. language=None,
  156. language_strategy="sticky",
  157. )
  158. fake_model.last.detected_language = "en"
  159. fake_model.last.detected_language_probability = 0.99
  160. # Pin the language
  161. t.transcribe(_audio())
  162. assert t._pinned_language == "en"
  163. # Second call: return a segment that fails the gate (high no_speech_prob)
  164. seg_bad = _Seg(no_speech_prob=0.9)
  165. fake_model.last.segments_to_return = [seg_bad]
  166. t.transcribe(_audio())
  167. # Pin should be cleared because no segments were accepted
  168. assert t._pinned_language is None
  169. def test_static_strategy_never_pins(fake_model):
  170. """Static (default) strategy never pins; always sends language=None for auto-detect."""
  171. t = Transcriber(
  172. "small",
  173. device="cpu",
  174. compute_type="int8",
  175. language=None,
  176. language_strategy="static",
  177. )
  178. fake_model.last.detected_language = "es"
  179. # First call
  180. t.transcribe(_audio())
  181. assert fake_model.last.transcribe_calls[0]["language"] is None
  182. assert t._pinned_language is None
  183. # Second call
  184. t.transcribe(_audio())
  185. assert fake_model.last.transcribe_calls[1]["language"] is None
  186. assert t._pinned_language is None
  187. def test_explicit_language_with_sticky_ignores_sticky(fake_model):
  188. """When language is explicit, sticky strategy has no effect; no pinning occurs."""
  189. t = Transcriber(
  190. "small",
  191. device="cpu",
  192. compute_type="int8",
  193. language="es",
  194. language_strategy="sticky",
  195. )
  196. fake_model.last.detected_language = "es"
  197. # First call: sends language="es" (explicit)
  198. t.transcribe(_audio())
  199. assert fake_model.last.transcribe_calls[0]["language"] == "es"
  200. assert t._pinned_language is None # No pinning for explicit language
  201. # Second call: still sends language="es"
  202. t.transcribe(_audio())
  203. assert fake_model.last.transcribe_calls[1]["language"] == "es"
  204. assert t._pinned_language is None
  205. def test_sticky_tolerates_missing_avg_logprob_attribute(fake_model):
  206. """Sticky pin clearing uses getattr(..., 0.0) to tolerate missing avg_logprob."""
  207. t = Transcriber(
  208. "small",
  209. device="cpu",
  210. compute_type="int8",
  211. language=None,
  212. language_strategy="sticky",
  213. sticky_redetect_logprob=-0.8,
  214. )
  215. fake_model.last.detected_language = "en"
  216. fake_model.last.detected_language_probability = 0.99
  217. # Pin the language with a normal segment
  218. fake_model.last.segments_to_return = [_Seg(avg_logprob=-0.5)]
  219. t.transcribe(_audio())
  220. assert t._pinned_language == "en"
  221. # Second call: return a segment with no avg_logprob attribute (only text/no_speech_prob)
  222. class SegNoLogprob:
  223. text = "hello"
  224. no_speech_prob = 0.1
  225. fake_model.last.segments_to_return = [SegNoLogprob()]
  226. # Should not raise; defaulted 0.0 is above threshold -0.8, so pin is RETAINED
  227. t.transcribe(_audio())
  228. assert t._pinned_language == "en" # Pin retained because 0.0 >= -0.8
  229. class _StubPyWhisperCppTranscriber(PyWhisperCppTranscriber):
  230. """Replaces the pywhispercpp backend without touching the real package."""
  231. init_kwargs: dict
  232. def __init__(self, **kwargs):
  233. self.init_kwargs = kwargs
  234. def test_build_transcriber_faster_whisper_backend(fake_model):
  235. cfg = Config().whisper
  236. cfg.backend = "faster-whisper"
  237. t = transcriber_mod.build_transcriber(cfg)
  238. assert isinstance(t, Transcriber)
  239. assert fake_model.last.init_kwargs["model"] == cfg.model
  240. assert fake_model.last.init_kwargs["device"] == cfg.device
  241. assert fake_model.last.init_kwargs["compute_type"] == cfg.compute_type
  242. def test_build_transcriber_pywhispercpp_backend(monkeypatch):
  243. monkeypatch.setattr(pw_mod, "PyWhisperCppTranscriber", _StubPyWhisperCppTranscriber)
  244. cfg = Config().whisper
  245. cfg.backend = "pywhispercpp"
  246. t = transcriber_mod.build_transcriber(cfg)
  247. assert isinstance(t, PyWhisperCppTranscriber)
  248. assert t.init_kwargs["model"] == cfg.model
  249. assert t.init_kwargs["language"] == cfg.language
  250. assert t.init_kwargs["beam_size"] == cfg.beam_size
  251. assert t.init_kwargs["cpu_threads"] == cfg.cpu_threads
  252. assert t.init_kwargs["no_speech_prob_max"] == cfg.no_speech_prob_max
  253. assert t.init_kwargs["avg_logprob_min"] == cfg.avg_logprob_min
  254. assert t.init_kwargs["language_strategy"] == cfg.language_strategy
  255. assert t.init_kwargs["sticky_redetect_logprob"] == cfg.sticky_redetect_logprob
  256. assert t.init_kwargs["vad_filter"] == cfg.vad_filter