test_pywhispercpp_transcriber.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. """Tests for the pywhispercpp transcriber backend."""
  2. import json
  3. import logging
  4. import math
  5. import numpy as np
  6. import pytest
  7. from stt_daemon.pywhispercpp_transcriber import PyWhisperCppTranscriber, accept_segment
  8. class _Seg:
  9. def __init__(self, text="hello", probability=0.9):
  10. self.text = text
  11. self.probability = probability
  12. class _FakeModel:
  13. """Records constructor/transcribe kwargs without loading a real model."""
  14. last = None
  15. def __init__(self, model, **kwargs):
  16. self.init_kwargs = {"model": model, **kwargs}
  17. self.transcribe_calls = []
  18. self.auto_detect_calls = 0
  19. self.segments_to_return = []
  20. self.detected_language = "en"
  21. self.detected_language_probability: float | np.float32 = 1.0
  22. _FakeModel.last = self
  23. def auto_detect_language(self, audio):
  24. self.auto_detect_calls += 1
  25. return (self.detected_language, self.detected_language_probability), {}
  26. def transcribe(self, audio, **kwargs):
  27. self.transcribe_calls.append(kwargs)
  28. return self.segments_to_return
  29. def _audio():
  30. return np.zeros(16000, dtype=np.float32)
  31. def _transcriber(**kwargs):
  32. kwargs.setdefault("model", "small")
  33. kwargs.setdefault("language", None)
  34. kwargs.setdefault("model_class", _FakeModel)
  35. return PyWhisperCppTranscriber(**kwargs)
  36. # --- accept_segment gate ---------------------------------------------------
  37. def test_accepts_probability_above_floor():
  38. assert accept_segment(_Seg(probability=0.9), -1.0)
  39. def test_rejects_probability_below_floor():
  40. assert not accept_segment(_Seg(probability=math.exp(-1.0) - 0.01), -1.0)
  41. def test_probability_at_floor_boundary_is_accepted():
  42. assert accept_segment(_Seg(probability=math.exp(-1.0)), -1.0)
  43. def test_nan_probability_is_accepted():
  44. assert accept_segment(_Seg(probability=math.nan), -1.0)
  45. def test_missing_probability_defaults_to_accept():
  46. class Bare:
  47. text = "x"
  48. assert accept_segment(Bare(), -1.0)
  49. # --- constructor wiring ----------------------------------------------------
  50. def test_cpu_threads_forwarded_as_n_threads():
  51. _transcriber(cpu_threads=16)
  52. assert _FakeModel.last.init_kwargs["n_threads"] == 16
  53. def test_cpu_threads_default_is_auto():
  54. _transcriber()
  55. # Omitted so pywhispercpp picks its own thread count (it rejects None).
  56. assert "n_threads" not in _FakeModel.last.init_kwargs
  57. def test_language_omitted_from_model_constructor():
  58. """pywhispercpp rejects 'auto' ("unknown language"); language is set per call."""
  59. _transcriber()
  60. assert "language" not in _FakeModel.last.init_kwargs
  61. def test_beam_size_five_enables_beam_search():
  62. _transcriber(beam_size=5)
  63. kwargs = _FakeModel.last.init_kwargs
  64. assert kwargs["params_sampling_strategy"] == 1
  65. assert kwargs["beam_search"] == {"beam_size": 5, "patience": -1.0}
  66. def test_beam_size_one_uses_greedy():
  67. _transcriber(beam_size=1)
  68. kwargs = _FakeModel.last.init_kwargs
  69. assert kwargs["params_sampling_strategy"] == 0
  70. # Omitted so pywhispercpp keeps its default (it rejects an empty dict).
  71. assert "beam_search" not in kwargs
  72. def test_vad_filter_warns_and_is_ignored(caplog):
  73. with caplog.at_level(logging.WARNING):
  74. _transcriber(vad_filter=True)
  75. assert "vad_filter" in caplog.text
  76. assert _FakeModel.last.init_kwargs["model"] == "small"
  77. def test_close_is_noop():
  78. assert _transcriber().close() is None
  79. # --- transcribe: language handling -----------------------------------------
  80. def test_forced_language_skips_detection():
  81. t = _transcriber(language="es")
  82. result = t.transcribe(_audio())
  83. assert _FakeModel.last.auto_detect_calls == 0
  84. assert _FakeModel.last.transcribe_calls[0]["language"] == "es"
  85. assert result.language == "es"
  86. assert result.language_probability == 1.0
  87. def test_auto_detect_propagates_language_and_probability():
  88. t = _transcriber(language=None)
  89. _FakeModel.last.detected_language = "es"
  90. _FakeModel.last.detected_language_probability = 0.93
  91. result = t.transcribe(_audio())
  92. assert _FakeModel.last.auto_detect_calls == 1
  93. assert _FakeModel.last.transcribe_calls[0]["language"] == "es"
  94. assert result.language == "es"
  95. assert result.language_probability == 0.93
  96. def test_auto_detect_probability_is_builtin_float():
  97. """np.float32 from pywhispercpp is not JSON-serializable by the event bus."""
  98. t = _transcriber(language=None)
  99. _FakeModel.last.detected_language = "es"
  100. _FakeModel.last.detected_language_probability = np.float32(0.93)
  101. result = t.transcribe(_audio())
  102. assert type(result.language) is str
  103. assert type(result.language_probability) is float
  104. json.dumps({"lang": result.language, "lang_prob": result.language_probability})
  105. def test_forced_language_probability_is_builtin_float():
  106. result = _transcriber(language="es").transcribe(_audio())
  107. assert type(result.language_probability) is float
  108. json.dumps({"lang_prob": result.language_probability})
  109. def test_explicit_language_with_sticky_ignores_sticky():
  110. t = _transcriber(language="es", language_strategy="sticky")
  111. t.transcribe(_audio())
  112. t.transcribe(_audio())
  113. assert _FakeModel.last.auto_detect_calls == 0
  114. assert t._pinned_language is None
  115. # --- transcribe: segment gate and text -------------------------------------
  116. def test_no_speech_thold_forwards_no_speech_prob_max():
  117. t = _transcriber(no_speech_prob_max=0.75)
  118. t.transcribe(_audio())
  119. kwargs = _FakeModel.last.transcribe_calls[0]
  120. assert kwargs["no_speech_thold"] == 0.75
  121. assert kwargs["extract_probability"] is True
  122. def test_probability_gate_drops_low_and_keeps_high():
  123. t = _transcriber(avg_logprob_min=-1.0)
  124. _FakeModel.last.segments_to_return = [
  125. _Seg(text="bad ", probability=math.exp(-1.0) - 0.05),
  126. _Seg(text="good", probability=0.9),
  127. ]
  128. assert t.transcribe(_audio()).text == "good"
  129. def test_text_is_joined_and_stripped():
  130. t = _transcriber()
  131. _FakeModel.last.segments_to_return = [
  132. _Seg(text=" hola ", probability=0.9),
  133. _Seg(text="mundo ", probability=0.9),
  134. ]
  135. assert t.transcribe(_audio()).text == "hola mundo"
  136. def test_text_joins_spaceless_segments_with_space():
  137. t = _transcriber()
  138. _FakeModel.last.segments_to_return = [
  139. _Seg(text="most", probability=0.9),
  140. _Seg(text="matter", probability=0.9),
  141. ]
  142. assert t.transcribe(_audio()).text == "most matter"
  143. # --- sticky strategy --------------------------------------------------------
  144. def test_sticky_pins_on_first_call_and_reuses_pin():
  145. t = _transcriber(language=None, language_strategy="sticky")
  146. _FakeModel.last.detected_language = "es"
  147. _FakeModel.last.detected_language_probability = 0.95
  148. _FakeModel.last.segments_to_return = [_Seg(probability=0.9)]
  149. first = t.transcribe(_audio())
  150. assert first.language_probability == 0.95
  151. assert t._pinned_language == "es"
  152. assert t._pinned_language_probability == 0.95
  153. second = t.transcribe(_audio())
  154. assert _FakeModel.last.auto_detect_calls == 1 # pin reused, no second detection
  155. assert _FakeModel.last.transcribe_calls[1]["language"] == "es"
  156. assert second.language == "es"
  157. assert second.language_probability == 0.95
  158. def test_sticky_pinned_probability_is_builtin_float():
  159. """The pin must store a builtin float, not the np.float32 detection result."""
  160. t = _transcriber(language=None, language_strategy="sticky")
  161. _FakeModel.last.detected_language = "es"
  162. _FakeModel.last.detected_language_probability = np.float32(0.95)
  163. _FakeModel.last.segments_to_return = [_Seg(probability=0.9)]
  164. first = t.transcribe(_audio())
  165. second = t.transcribe(_audio())
  166. assert type(first.language_probability) is float
  167. assert type(t._pinned_language_probability) is float
  168. assert type(second.language_probability) is float
  169. json.dumps({"lang_prob": second.language_probability})
  170. def test_sticky_clears_pin_on_low_probability():
  171. t = _transcriber(
  172. language=None,
  173. language_strategy="sticky",
  174. sticky_redetect_logprob=-0.8,
  175. avg_logprob_min=-1.0,
  176. )
  177. _FakeModel.last.detected_language = "en"
  178. _FakeModel.last.detected_language_probability = 0.98
  179. _FakeModel.last.segments_to_return = [_Seg(probability=0.9)]
  180. t.transcribe(_audio())
  181. assert t._pinned_language == "en"
  182. # Accepted (0.40 >= exp(-1.0)) but below exp(-0.8) -> pin cleared
  183. _FakeModel.last.segments_to_return = [_Seg(probability=0.40)]
  184. t.transcribe(_audio())
  185. assert t._pinned_language is None
  186. def test_sticky_clears_pin_when_no_segments_accepted():
  187. t = _transcriber(language=None, language_strategy="sticky")
  188. _FakeModel.last.detected_language = "en"
  189. _FakeModel.last.detected_language_probability = 0.99
  190. _FakeModel.last.segments_to_return = [_Seg(probability=0.9)]
  191. t.transcribe(_audio())
  192. assert t._pinned_language == "en"
  193. _FakeModel.last.segments_to_return = [_Seg(probability=0.1)]
  194. t.transcribe(_audio())
  195. assert t._pinned_language is None
  196. def test_sticky_retains_pin_when_probability_missing():
  197. """A NaN probability is accepted and does not trigger re-detection."""
  198. t = _transcriber(language=None, language_strategy="sticky")
  199. _FakeModel.last.detected_language = "en"
  200. _FakeModel.last.detected_language_probability = 0.99
  201. _FakeModel.last.segments_to_return = [_Seg(probability=0.9)]
  202. t.transcribe(_audio())
  203. assert t._pinned_language == "en"
  204. _FakeModel.last.segments_to_return = [_Seg(probability=math.nan)]
  205. t.transcribe(_audio())
  206. assert t._pinned_language == "en"
  207. def test_static_strategy_never_pins():
  208. t = _transcriber(language=None, language_strategy="static")
  209. _FakeModel.last.detected_language = "es"
  210. t.transcribe(_audio())
  211. t.transcribe(_audio())
  212. assert _FakeModel.last.auto_detect_calls == 2
  213. assert t._pinned_language is None
  214. @pytest.mark.parametrize("strategy", ["static", "sticky"])
  215. def test_transcribe_receives_detected_language(strategy):
  216. t = _transcriber(language=None, language_strategy=strategy)
  217. _FakeModel.last.detected_language = "en"
  218. t.transcribe(_audio())
  219. assert _FakeModel.last.transcribe_calls[0]["language"] == "en"