test_daemon_events.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. """Tests for the daemon's publish-only event stream (Phase 1)."""
  2. import logging
  3. import os
  4. import shutil
  5. import socket
  6. import tempfile
  7. import threading
  8. import time
  9. import numpy as np
  10. import pytest
  11. from stt_daemon import events
  12. from stt_daemon.bus import EventSubscriber
  13. from stt_daemon.config import Config
  14. from stt_daemon.daemon import Daemon
  15. from stt_daemon.transcriber import TranscriptResult
  16. FRAME_SAMPLES = 480
  17. FRAME_BYTES = FRAME_SAMPLES * 2
  18. @pytest.fixture
  19. def sock_path():
  20. directory = tempfile.mkdtemp(prefix="sttb-")
  21. try:
  22. yield os.path.join(directory, "s.sock")
  23. finally:
  24. shutil.rmtree(directory, ignore_errors=True)
  25. def _frame(value=0):
  26. return np.full(FRAME_SAMPLES, value, dtype=np.int16).tobytes()
  27. class FakeVad:
  28. def __init__(self, decisions):
  29. self._decisions = list(decisions)
  30. self._index = 0
  31. def is_speech(self, frame, sample_rate):
  32. if self._index < len(self._decisions):
  33. decision = self._decisions[self._index]
  34. self._index += 1
  35. return decision
  36. return False
  37. class FakeCapture:
  38. def __init__(self, frames, delay=0.0, stall_after=None, healthy=True):
  39. self._frames = list(frames)
  40. self._delay = delay
  41. self._stall_after = stall_after
  42. self._healthy = healthy
  43. self._closed = threading.Event()
  44. self.frame_bytes = FRAME_BYTES
  45. def frames(self):
  46. for index, frame in enumerate(self._frames):
  47. if self._closed.is_set():
  48. return
  49. if self._delay:
  50. time.sleep(self._delay)
  51. yield frame
  52. if self._stall_after is not None and index + 1 >= self._stall_after:
  53. self._closed.wait()
  54. return
  55. def healthy(self):
  56. return self._healthy
  57. def close(self):
  58. self._closed.set()
  59. class FakeTranscriber:
  60. def __init__(self, text="hello", language="en", probability=0.99):
  61. self.text = text
  62. self.language = language
  63. self.probability = probability
  64. def transcribe(self, audio):
  65. return TranscriptResult(
  66. language=self.language,
  67. language_probability=self.probability,
  68. text=self.text,
  69. )
  70. def close(self):
  71. pass
  72. class FakeSink:
  73. def __init__(self):
  74. self.emitted = []
  75. self.transcribe_times = []
  76. def emit(
  77. self,
  78. result,
  79. duration_s,
  80. rms=0.0,
  81. reason="unknown",
  82. audio=None,
  83. transcribe_s=None,
  84. ):
  85. if not result.text:
  86. return None
  87. self.emitted.append(result.text)
  88. self.transcribe_times.append(transcribe_s)
  89. return None
  90. def close(self):
  91. pass
  92. class _Collector:
  93. def __init__(self, subscriber):
  94. self.events = []
  95. self._thread = threading.Thread(target=self._run, args=(subscriber,), daemon=True)
  96. self._thread.start()
  97. def _run(self, subscriber):
  98. try:
  99. for event in subscriber.events():
  100. self.events.append(event)
  101. except Exception: # noqa: BLE001 - collector must not crash the test thread
  102. pass
  103. def types(self):
  104. return [e["type"] for e in self.events]
  105. def of(self, type_):
  106. return [e for e in self.events if e["type"] == type_]
  107. def wait_for(self, predicate, timeout=5.0):
  108. deadline = time.time() + timeout
  109. while time.time() < deadline:
  110. if predicate(self.events):
  111. return True
  112. time.sleep(0.01)
  113. return predicate(self.events)
  114. def _base_config(sock_path):
  115. cfg = Config()
  116. cfg.events.socket = sock_path
  117. cfg.events.heartbeat_ms = 50
  118. cfg.audio.frame_ms = 30
  119. cfg.vad.pre_roll_ms = 60
  120. cfg.vad.speech_start_ms = 30
  121. cfg.vad.silence_ms = 60
  122. cfg.vad.min_utterance_ms = 30
  123. cfg.vad.chunk_pause_ms = 300
  124. cfg.vad.soft_max_s = 10.0
  125. cfg.vad.max_utterance_s = 30.0
  126. cfg.vad.rms_floor = 0.0
  127. cfg.daemon.queue_size = 50
  128. cfg.output.stdout = False
  129. cfg.output.jsonl_dir = None
  130. cfg.output.save_audio = False
  131. return cfg
  132. def _start_daemon(cfg, capture, vad, transcriber):
  133. sink = FakeSink()
  134. daemon = Daemon(
  135. cfg,
  136. log_level=logging.WARNING,
  137. capture_factory=lambda: capture,
  138. vad_factory=lambda _aggr: vad,
  139. transcriber_factory=lambda: transcriber,
  140. sink_factory=lambda: sink,
  141. )
  142. daemon._initialize()
  143. subscriber = EventSubscriber(cfg.events.socket, reconnect_delay=0.05)
  144. collector = _Collector(subscriber)
  145. daemon._start_workers()
  146. return daemon, collector, subscriber, sink
  147. def test_transcript_and_segment_done_share_seg_id(sock_path):
  148. cfg = _base_config(sock_path)
  149. frames = [_frame() for _ in range(5)]
  150. vad = FakeVad([False, True, True, False, False])
  151. transcriber = FakeTranscriber(text="hello")
  152. daemon, collector, subscriber, _ = _start_daemon(cfg, FakeCapture(frames), vad, transcriber)
  153. collector.wait_for(lambda e: any(x["type"] == events.SEGMENT_DONE for x in e))
  154. starts = collector.of(events.SPEECH_START)
  155. stops = collector.of(events.SPEECH_STOP)
  156. transcripts = collector.of(events.TRANSCRIPT)
  157. dones = collector.of(events.SEGMENT_DONE)
  158. assert len(starts) == 1
  159. assert "seg_id" not in starts[0] # speech_start carries no seg_id
  160. assert len(stops) == 1
  161. assert len(transcripts) == 1
  162. assert len(dones) == 1
  163. assert stops[0]["seg_id"] == transcripts[0]["seg_id"] == dones[0]["seg_id"] == 1
  164. assert transcripts[0]["text"] == "hello"
  165. assert dones[0]["emitted"] is True
  166. assert dones[0]["end_reason"] == "end_of_turn"
  167. subscriber.close()
  168. daemon.stop()
  169. def test_transcribe_seconds_is_forwarded_to_sink(sock_path):
  170. """The worker measures wall time around transcribe() and passes it to emit()."""
  171. cfg = _base_config(sock_path)
  172. frames = [_frame() for _ in range(5)]
  173. vad = FakeVad([False, True, True, False, False])
  174. transcriber = FakeTranscriber(text="hello")
  175. daemon, collector, subscriber, sink = _start_daemon(
  176. cfg, FakeCapture(frames), vad, transcriber
  177. )
  178. collector.wait_for(lambda e: any(x["type"] == events.SEGMENT_DONE for x in e))
  179. assert len(sink.transcribe_times) == 1
  180. assert sink.transcribe_times[0] is not None
  181. assert sink.transcribe_times[0] >= 0.0
  182. subscriber.close()
  183. daemon.stop()
  184. def test_gated_segment_reports_emitted_false(sock_path):
  185. cfg = _base_config(sock_path)
  186. frames = [_frame() for _ in range(5)]
  187. vad = FakeVad([False, True, True, False, False])
  188. transcriber = FakeTranscriber(text="") # gated / no text
  189. daemon, collector, subscriber, _ = _start_daemon(cfg, FakeCapture(frames), vad, transcriber)
  190. collector.wait_for(lambda e: any(x["type"] == events.SEGMENT_DONE for x in e))
  191. assert collector.of(events.TRANSCRIPT) == []
  192. dones = collector.of(events.SEGMENT_DONE)
  193. assert len(dones) == 1
  194. assert dones[0]["emitted"] is False
  195. assert len(collector.of(events.SPEECH_STOP)) == 1
  196. subscriber.close()
  197. daemon.stop()
  198. def test_chunked_utterance_yields_one_pair_per_chunk(sock_path):
  199. cfg = _base_config(sock_path)
  200. cfg.vad.soft_max_s = 0.3 # 10 frames
  201. cfg.vad.chunk_pause_ms = 60 # 2 frames
  202. cfg.vad.silence_ms = 600 # 20 frames (never reached here)
  203. frames = [_frame() for _ in range(47)]
  204. decisions = [False] + [True] * 12 + [False] * 2 + [True] * 12 + [False] * 20
  205. transient = FakeTranscriber(text="chunk")
  206. daemon, collector, subscriber, _ = _start_daemon(
  207. cfg, FakeCapture(frames), FakeVad(decisions), transient
  208. )
  209. collector.wait_for(
  210. lambda e: len([x for x in e if x["type"] == events.SEGMENT_DONE]) >= 2
  211. )
  212. stops = collector.of(events.SPEECH_STOP)
  213. dones = collector.of(events.SEGMENT_DONE)
  214. assert len(stops) == len(dones) >= 2
  215. stop_ids = [s["seg_id"] for s in stops]
  216. done_ids = [d["seg_id"] for d in dones]
  217. assert stop_ids == sorted(stop_ids)
  218. assert len(set(stop_ids)) == len(stop_ids) # unique per chunk
  219. assert set(stop_ids) == set(done_ids)
  220. assert all(d["end_reason"] == "pause" for d in dones)
  221. subscriber.close()
  222. daemon.stop()
  223. def test_heartbeats_advance_and_carry_health(sock_path):
  224. cfg = _base_config(sock_path)
  225. cfg.events.heartbeat_ms = 40
  226. frames = [_frame() for _ in range(40)]
  227. daemon, collector, subscriber, _ = _start_daemon(
  228. cfg, FakeCapture(frames, delay=0.02), FakeVad([]), FakeTranscriber()
  229. )
  230. collector.wait_for(lambda e: len([x for x in e if x["type"] == events.HEARTBEAT]) >= 3)
  231. heartbeats = collector.of(events.HEARTBEAT)
  232. assert len(heartbeats) >= 3
  233. seqs = [h["seq"] for h in heartbeats]
  234. assert seqs == sorted(seqs)
  235. assert len(set(seqs)) == len(seqs)
  236. last = heartbeats[-1]
  237. assert last["model_loaded"] is True
  238. assert last["device_ok"] is True
  239. assert last["capture"] == cfg.audio.device
  240. for field in ("in_flight", "segments_done", "worker_stall_s", "queue_depth",
  241. "frames_since_last", "rms", "uptime_s", "last_transcript_age_s"):
  242. assert field in last
  243. subscriber.close()
  244. daemon.stop()
  245. def test_stalled_capture_stops_heartbeats(sock_path):
  246. cfg = _base_config(sock_path)
  247. cfg.events.heartbeat_ms = 60
  248. frames = [_frame() for _ in range(30)]
  249. capture = FakeCapture(frames, delay=0.01, stall_after=10)
  250. daemon, collector, subscriber, _ = _start_daemon(
  251. cfg, capture, FakeVad([]), FakeTranscriber()
  252. )
  253. assert collector.wait_for(
  254. lambda e: any(x["type"] == events.HEARTBEAT for x in e), timeout=3.0
  255. )
  256. time.sleep(0.2) # let it stall
  257. before = len(collector.of(events.HEARTBEAT))
  258. time.sleep(0.45) # ~7 heartbeat intervals at 60ms
  259. after = len(collector.of(events.HEARTBEAT))
  260. assert after == before, f"heartbeats continued during a stall ({before} -> {after})"
  261. subscriber.close()
  262. daemon.stop()
  263. def test_discarded_segment_still_emits_speech_stop(sock_path):
  264. """A turn that is discarded (blip / too quiet) must still emit speech_stop.
  265. Without it, consumers that track speech_open from speech_start/speech_stop
  266. latch true forever, which blocks readiness and chat dispatch.
  267. """
  268. cfg = _base_config(sock_path)
  269. cfg.vad.min_utterance_ms = 300 # a 1-frame blip is discarded
  270. frames = [_frame() for _ in range(4)]
  271. vad = FakeVad([True, False, False, False]) # confirm, then silence -> discard
  272. daemon, collector, subscriber, _ = _start_daemon(
  273. cfg, FakeCapture(frames), vad, FakeTranscriber()
  274. )
  275. collector.wait_for(lambda e: any(x["type"] == events.SPEECH_STOP for x in e))
  276. time.sleep(0.2)
  277. starts = collector.of(events.SPEECH_START)
  278. stops = collector.of(events.SPEECH_STOP)
  279. assert len(starts) == 1
  280. assert len(stops) == 1
  281. assert stops[0]["seg_id"] is None # discarded: no segment assigned
  282. assert stops[0]["end_reason"] == "discarded"
  283. assert collector.of(events.SEGMENT_DONE) == []
  284. assert collector.of(events.TRANSCRIPT) == []
  285. subscriber.close()
  286. daemon.stop()
  287. def test_inbound_data_on_subscriber_socket_is_ignored(sock_path):
  288. cfg = _base_config(sock_path)
  289. cfg.events.heartbeat_ms = 40
  290. frames = [_frame() for _ in range(60)]
  291. daemon, collector, subscriber, _ = _start_daemon(
  292. cfg, FakeCapture(frames, delay=0.02), FakeVad([]), FakeTranscriber()
  293. )
  294. # The bus is publish-only: a client writing data must have no effect.
  295. raw = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
  296. raw.connect(sock_path)
  297. raw.sendall(b'{"v":1,"type":"evil","seq":1,"ts":0}\n')
  298. time.sleep(0.1)
  299. assert collector.wait_for(
  300. lambda e: len([x for x in e if x["type"] == events.HEARTBEAT]) >= 2
  301. )
  302. assert all(x["type"] != "evil" for x in collector.events)
  303. raw.close()
  304. subscriber.close()
  305. daemon.stop()
  306. def test_request_stop_shuts_down_run_loop(tmp_path):
  307. """request_stop() (the signal-handler path) must unwind run() cleanly."""
  308. sock_path = str(tmp_path / "events.sock")
  309. cfg = _base_config(sock_path)
  310. capture = FakeCapture([_frame(0)] * 5, stall_after=1)
  311. sink = FakeSink()
  312. daemon = Daemon(
  313. cfg,
  314. log_level=logging.WARNING,
  315. capture_factory=lambda: capture,
  316. vad_factory=lambda _aggr: FakeVad([]),
  317. transcriber_factory=lambda: FakeTranscriber(),
  318. sink_factory=lambda: sink,
  319. )
  320. thread = threading.Thread(target=daemon.run, daemon=True)
  321. thread.start()
  322. time.sleep(0.2)
  323. daemon.request_stop()
  324. thread.join(timeout=5)
  325. assert not thread.is_alive()
  326. assert capture._closed.is_set()