test_transcriber.py 9.3 KB

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