test_daemon.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. """Tests for the daemon's worker processing and transcription backlog warning."""
  2. import json
  3. import logging
  4. import numpy as np
  5. import pytest
  6. from stt_daemon import events
  7. from stt_daemon import transcriber as transcriber_mod
  8. from stt_daemon.config import Config
  9. from stt_daemon.daemon import Daemon
  10. from stt_daemon.output import TranscriptSink
  11. from stt_daemon.segmenter import Utterance
  12. from stt_daemon.transcriber import TranscriptResult
  13. def _daemon(threshold=2, queue_size=10):
  14. cfg = Config()
  15. cfg.daemon.backlog_warn_at = threshold
  16. cfg.daemon.queue_size = queue_size
  17. return Daemon(cfg)
  18. def test_queue_size_comes_from_config():
  19. daemon = _daemon(queue_size=7)
  20. assert daemon.segment_queue.maxsize == 7
  21. def test_backlog_warning_is_edge_triggered(caplog):
  22. daemon = _daemon(threshold=2)
  23. with caplog.at_level(logging.WARNING, logger="stt_daemon"):
  24. daemon.segment_queue.put("a")
  25. daemon._check_backlog()
  26. assert "backlog" not in caplog.text.lower()
  27. daemon.segment_queue.put("b")
  28. daemon._check_backlog()
  29. assert caplog.text.lower().count("backlog") == 1
  30. daemon._check_backlog()
  31. assert caplog.text.lower().count("backlog") == 1
  32. daemon.segment_queue.get()
  33. daemon.segment_queue.get()
  34. daemon._check_backlog()
  35. assert daemon._backlog_warned is False
  36. daemon.segment_queue.put("c")
  37. daemon.segment_queue.put("d")
  38. daemon._check_backlog()
  39. assert caplog.text.lower().count("backlog") == 2
  40. class _FakeWhisperModel:
  41. """Constructor-only fake; keeps faster-whisper from loading a real model."""
  42. def __init__(self, *args, **kwargs):
  43. self.init_kwargs = kwargs
  44. class _FakeCppModel:
  45. """Constructor-only fake; keeps pywhispercpp from loading a real model."""
  46. def __init__(self, *args, **kwargs):
  47. self.init_kwargs = kwargs
  48. def test_build_transcriber_defaults_to_faster_whisper(monkeypatch):
  49. monkeypatch.setattr(transcriber_mod, "WhisperModel", _FakeWhisperModel)
  50. assert isinstance(_daemon()._build_transcriber(), transcriber_mod.Transcriber)
  51. def test_build_transcriber_honors_pywhispercpp_backend(monkeypatch):
  52. cpp_model = pytest.importorskip("pywhispercpp.model")
  53. monkeypatch.setattr(cpp_model, "Model", _FakeCppModel)
  54. from stt_daemon.pywhispercpp_transcriber import PyWhisperCppTranscriber
  55. cfg = Config()
  56. cfg.whisper.backend = "pywhispercpp"
  57. assert isinstance(Daemon(cfg)._build_transcriber(), PyWhisperCppTranscriber)
  58. # --- worker processing ------------------------------------------------------
  59. class _FakeTranscriber:
  60. """Returns a canned result without loading a real model."""
  61. def __init__(self, result):
  62. self.result = result
  63. def transcribe(self, audio):
  64. return self.result
  65. def close(self):
  66. pass
  67. class _RaisingSink:
  68. """Sink that fails on emit, simulating a non-JSON-serializable record."""
  69. def emit(self, *args, **kwargs):
  70. raise TypeError("Object of type float32 is not JSON serializable")
  71. def close(self):
  72. pass
  73. def _utterance(seg_id=1):
  74. return Utterance(
  75. audio=np.zeros(1600, dtype=np.float32),
  76. duration_s=1.0,
  77. started_at=0.0,
  78. rms=0.05,
  79. reason="pause",
  80. seg_id=seg_id,
  81. )
  82. def _transcript_result():
  83. return TranscriptResult(language="en", language_probability=0.9, text="hello world")
  84. def _daemon_with_raising_sink():
  85. daemon = _daemon()
  86. daemon.transcriber = _FakeTranscriber(_transcript_result())
  87. daemon.sink = _RaisingSink()
  88. published = []
  89. daemon._publish = lambda type_, **fields: published.append((type_, fields))
  90. return daemon, published
  91. def test_sink_failure_still_publishes_transcript(caplog):
  92. daemon, published = _daemon_with_raising_sink()
  93. with caplog.at_level(logging.ERROR, logger="stt_daemon"):
  94. daemon._process(_utterance())
  95. transcripts = [fields for type_, fields in published if type_ == events.TRANSCRIPT]
  96. assert len(transcripts) == 1
  97. assert transcripts[0]["text"] == "hello world"
  98. assert transcripts[0]["lang_prob"] == 0.9
  99. assert transcripts[0]["audio"] is None
  100. assert "Output error" in caplog.text
  101. sid = daemon._session_tracker.current_id
  102. assert transcripts[0]["session_id"] == sid
  103. assert sid
  104. assert transcripts[0]["session_dir"].endswith(sid)
  105. def test_sink_failure_reports_segment_done_as_emitted():
  106. daemon, published = _daemon_with_raising_sink()
  107. daemon._process(_utterance())
  108. done = [fields for type_, fields in published if type_ == events.SEGMENT_DONE]
  109. assert len(done) == 1
  110. assert done[0]["emitted"] is True
  111. # --- session identity -------------------------------------------------------
  112. class _RecordingSink:
  113. """Minimal sink capturing the per-emit session_dir kwarg."""
  114. def __init__(self):
  115. self.session_dirs = []
  116. def emit(self, *args, **kwargs):
  117. self.session_dirs.append(kwargs.get("session_dir"))
  118. return None
  119. def close(self):
  120. pass
  121. def _session_daemon(monkeypatch, clock):
  122. daemon = _daemon()
  123. daemon.transcriber = _FakeTranscriber(_transcript_result())
  124. sink = _RecordingSink()
  125. daemon.sink = sink
  126. published = []
  127. daemon._publish = lambda type_, **fields: published.append((type_, fields))
  128. monkeypatch.setattr("stt_daemon.daemon.time.time", lambda: clock["now"])
  129. return daemon, sink, published
  130. def test_process_stamps_session_id_matching_jsonl(tmp_path):
  131. daemon = _daemon()
  132. daemon.transcriber = _FakeTranscriber(_transcript_result())
  133. daemon.cfg.output.stdout = False
  134. daemon.cfg.output.jsonl = True
  135. daemon.cfg.output.save_audio = False
  136. daemon.cfg.output.session_dir = str(tmp_path)
  137. daemon.sink = TranscriptSink(stdout=False, jsonl=True, save_audio=False, sample_rate=16000)
  138. published = []
  139. daemon._publish = lambda type_, **fields: published.append((type_, fields))
  140. daemon._process(_utterance(1))
  141. sid = daemon._session_tracker.current_id
  142. assert isinstance(sid, str) and sid
  143. transcripts = [f for t, f in published if t == events.TRANSCRIPT]
  144. assert len(transcripts) == 1
  145. assert transcripts[0]["session_id"] == sid
  146. assert transcripts[0]["session_dir"] == str(tmp_path / sid)
  147. sessions = [f for t, f in published if t == events.SESSION]
  148. assert len(sessions) == 1
  149. assert sessions[0]["session_id"] == sid
  150. record = json.loads((tmp_path / sid / "transcripts.jsonl").read_text())
  151. assert record["session_id"] == sid
  152. def test_within_gap_reuses_one_session(monkeypatch):
  153. clock = {"now": 1000.0}
  154. daemon, sink, published = _session_daemon(monkeypatch, clock)
  155. daemon._process(_utterance(1))
  156. clock["now"] = 1100.0
  157. daemon._process(_utterance(2))
  158. sessions = [f for t, f in published if t == events.SESSION]
  159. assert len(sessions) == 1
  160. transcripts = [f for t, f in published if t == events.TRANSCRIPT]
  161. assert len(transcripts) == 2
  162. assert transcripts[0]["session_id"] == transcripts[1]["session_id"]
  163. assert transcripts[0]["session_id"] == sessions[0]["session_id"]
  164. assert len(sink.session_dirs) == 2
  165. assert all(
  166. d is not None and d.name == sessions[0]["session_id"] for d in sink.session_dirs
  167. )
  168. def test_beyond_gap_mints_new_session(monkeypatch):
  169. clock = {"now": 1000.0}
  170. daemon, _sink, published = _session_daemon(monkeypatch, clock)
  171. daemon._process(_utterance(1))
  172. clock["now"] = 1100.0 # inside the 300s default gap
  173. daemon._process(_utterance(2))
  174. clock["now"] = 1500.0 # 400s > 300s: rollover
  175. daemon._process(_utterance(3))
  176. sessions = [f for t, f in published if t == events.SESSION]
  177. assert len(sessions) == 2
  178. assert sessions[0]["session_id"] != sessions[1]["session_id"]
  179. transcripts = [f for t, f in published if t == events.TRANSCRIPT]
  180. assert transcripts[0]["session_id"] == sessions[0]["session_id"]
  181. assert transcripts[1]["session_id"] == sessions[0]["session_id"]
  182. assert transcripts[2]["session_id"] == sessions[1]["session_id"]
  183. assert daemon._session_tracker.current_id == sessions[1]["session_id"]
  184. def test_session_advances_with_file_writes_off(monkeypatch):
  185. clock = {"now": 1000.0}
  186. daemon, _sink, published = _session_daemon(monkeypatch, clock)
  187. daemon.cfg.output.jsonl = False
  188. daemon.cfg.output.save_audio = False
  189. daemon._process(_utterance(1))
  190. clock["now"] = 1500.0
  191. daemon._process(_utterance(2))
  192. sessions = [f for t, f in published if t == events.SESSION]
  193. assert len(sessions) == 2
  194. assert sessions[1]["session_id"] != sessions[0]["session_id"]