test_daemon_events.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  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. **kwargs,
  85. ):
  86. if not result.text:
  87. return None
  88. self.emitted.append(result.text)
  89. self.transcribe_times.append(transcribe_s)
  90. self.last_kwargs = dict(kwargs)
  91. return None
  92. def close(self):
  93. pass
  94. class _Collector:
  95. def __init__(self, subscriber):
  96. self.events = []
  97. self._thread = threading.Thread(target=self._run, args=(subscriber,), daemon=True)
  98. self._thread.start()
  99. def _run(self, subscriber):
  100. try:
  101. for event in subscriber.events():
  102. self.events.append(event)
  103. except Exception: # noqa: BLE001 - collector must not crash the test thread
  104. pass
  105. def types(self):
  106. return [e["type"] for e in self.events]
  107. def of(self, type_):
  108. return [e for e in self.events if e["type"] == type_]
  109. def wait_for(self, predicate, timeout=5.0):
  110. deadline = time.time() + timeout
  111. while time.time() < deadline:
  112. if predicate(self.events):
  113. return True
  114. time.sleep(0.01)
  115. return predicate(self.events)
  116. def _base_config(sock_path):
  117. cfg = Config()
  118. cfg.events.socket = sock_path
  119. cfg.events.heartbeat_ms = 50
  120. cfg.audio.frame_ms = 30
  121. cfg.vad.pre_roll_ms = 60
  122. cfg.vad.speech_start_ms = 30
  123. cfg.vad.silence_ms = 60
  124. cfg.vad.min_utterance_ms = 30
  125. cfg.vad.chunk_pause_ms = 300
  126. cfg.vad.soft_max_s = 10.0
  127. cfg.vad.max_utterance_s = 30.0
  128. cfg.vad.rms_floor = 0.0
  129. cfg.daemon.queue_size = 50
  130. cfg.output.stdout = False
  131. cfg.output.jsonl_dir = None
  132. cfg.output.save_audio = False
  133. return cfg
  134. def _start_daemon(cfg, capture, vad, transcriber):
  135. sink = FakeSink()
  136. daemon = Daemon(
  137. cfg,
  138. log_level=logging.WARNING,
  139. capture_factory=lambda: capture,
  140. vad_factory=lambda _aggr: vad,
  141. transcriber_factory=lambda: transcriber,
  142. sink_factory=lambda: sink,
  143. )
  144. daemon._initialize()
  145. subscriber = EventSubscriber(cfg.events.socket, reconnect_delay=0.05)
  146. collector = _Collector(subscriber)
  147. daemon._start_workers()
  148. return daemon, collector, subscriber, sink
  149. def test_transcript_and_segment_done_share_seg_id(sock_path):
  150. cfg = _base_config(sock_path)
  151. frames = [_frame() for _ in range(5)]
  152. vad = FakeVad([False, True, True, False, False])
  153. transcriber = FakeTranscriber(text="hello")
  154. daemon, collector, subscriber, _ = _start_daemon(cfg, FakeCapture(frames), vad, transcriber)
  155. collector.wait_for(lambda e: any(x["type"] == events.SEGMENT_DONE for x in e))
  156. starts = collector.of(events.SPEECH_START)
  157. stops = collector.of(events.SPEECH_STOP)
  158. transcripts = collector.of(events.TRANSCRIPT)
  159. dones = collector.of(events.SEGMENT_DONE)
  160. assert len(starts) == 1
  161. assert "seg_id" not in starts[0] # speech_start carries no seg_id
  162. assert len(stops) == 1
  163. assert len(transcripts) == 1
  164. assert len(dones) == 1
  165. assert stops[0]["seg_id"] == transcripts[0]["seg_id"] == dones[0]["seg_id"] == 1
  166. assert transcripts[0]["text"] == "hello"
  167. assert dones[0]["emitted"] is True
  168. assert dones[0]["end_reason"] == "end_of_turn"
  169. subscriber.close()
  170. daemon.stop()
  171. def test_transcribe_seconds_is_forwarded_to_sink(sock_path):
  172. """The worker measures wall time around transcribe() and passes it to emit()."""
  173. cfg = _base_config(sock_path)
  174. frames = [_frame() for _ in range(5)]
  175. vad = FakeVad([False, True, True, False, False])
  176. transcriber = FakeTranscriber(text="hello")
  177. daemon, collector, subscriber, sink = _start_daemon(
  178. cfg, FakeCapture(frames), vad, transcriber
  179. )
  180. collector.wait_for(lambda e: any(x["type"] == events.SEGMENT_DONE for x in e))
  181. assert len(sink.transcribe_times) == 1
  182. assert sink.transcribe_times[0] is not None
  183. assert sink.transcribe_times[0] >= 0.0
  184. subscriber.close()
  185. daemon.stop()
  186. def test_gated_segment_reports_emitted_false(sock_path):
  187. cfg = _base_config(sock_path)
  188. frames = [_frame() for _ in range(5)]
  189. vad = FakeVad([False, True, True, False, False])
  190. transcriber = FakeTranscriber(text="") # gated / no text
  191. daemon, collector, subscriber, _ = _start_daemon(cfg, FakeCapture(frames), vad, transcriber)
  192. collector.wait_for(lambda e: any(x["type"] == events.SEGMENT_DONE for x in e))
  193. assert collector.of(events.TRANSCRIPT) == []
  194. dones = collector.of(events.SEGMENT_DONE)
  195. assert len(dones) == 1
  196. assert dones[0]["emitted"] is False
  197. assert len(collector.of(events.SPEECH_STOP)) == 1
  198. subscriber.close()
  199. daemon.stop()
  200. def test_chunked_utterance_yields_one_pair_per_chunk(sock_path):
  201. cfg = _base_config(sock_path)
  202. cfg.vad.soft_max_s = 0.3 # 10 frames
  203. cfg.vad.chunk_pause_ms = 60 # 2 frames
  204. cfg.vad.silence_ms = 600 # 20 frames (never reached here)
  205. frames = [_frame() for _ in range(47)]
  206. decisions = [False] + [True] * 12 + [False] * 2 + [True] * 12 + [False] * 20
  207. transient = FakeTranscriber(text="chunk")
  208. daemon, collector, subscriber, _ = _start_daemon(
  209. cfg, FakeCapture(frames), FakeVad(decisions), transient
  210. )
  211. collector.wait_for(
  212. lambda e: len([x for x in e if x["type"] == events.SEGMENT_DONE]) >= 2
  213. )
  214. stops = collector.of(events.SPEECH_STOP)
  215. dones = collector.of(events.SEGMENT_DONE)
  216. assert len(stops) == len(dones) >= 2
  217. stop_ids = [s["seg_id"] for s in stops]
  218. done_ids = [d["seg_id"] for d in dones]
  219. assert stop_ids == sorted(stop_ids)
  220. assert len(set(stop_ids)) == len(stop_ids) # unique per chunk
  221. assert set(stop_ids) == set(done_ids)
  222. assert all(d["end_reason"] == "pause" for d in dones)
  223. subscriber.close()
  224. daemon.stop()
  225. def test_heartbeats_advance_and_carry_health(sock_path):
  226. cfg = _base_config(sock_path)
  227. cfg.events.heartbeat_ms = 40
  228. frames = [_frame() for _ in range(40)]
  229. daemon, collector, subscriber, _ = _start_daemon(
  230. cfg, FakeCapture(frames, delay=0.02), FakeVad([]), FakeTranscriber()
  231. )
  232. collector.wait_for(lambda e: len([x for x in e if x["type"] == events.HEARTBEAT]) >= 3)
  233. heartbeats = collector.of(events.HEARTBEAT)
  234. assert len(heartbeats) >= 3
  235. seqs = [h["seq"] for h in heartbeats]
  236. assert seqs == sorted(seqs)
  237. assert len(set(seqs)) == len(seqs)
  238. last = heartbeats[-1]
  239. assert last["model_loaded"] is True
  240. assert last["device_ok"] is True
  241. assert last["capture"] == cfg.audio.device
  242. for field in ("in_flight", "segments_done", "worker_stall_s", "queue_depth",
  243. "frames_since_last", "rms", "uptime_s", "last_transcript_age_s"):
  244. assert field in last
  245. subscriber.close()
  246. daemon.stop()
  247. def test_stalled_capture_stops_heartbeats(sock_path):
  248. cfg = _base_config(sock_path)
  249. cfg.events.heartbeat_ms = 60
  250. frames = [_frame() for _ in range(30)]
  251. capture = FakeCapture(frames, delay=0.01, stall_after=10)
  252. daemon, collector, subscriber, _ = _start_daemon(
  253. cfg, capture, FakeVad([]), FakeTranscriber()
  254. )
  255. assert collector.wait_for(
  256. lambda e: any(x["type"] == events.HEARTBEAT for x in e), timeout=3.0
  257. )
  258. time.sleep(0.2) # let it stall
  259. before = len(collector.of(events.HEARTBEAT))
  260. time.sleep(0.45) # ~7 heartbeat intervals at 60ms
  261. after = len(collector.of(events.HEARTBEAT))
  262. assert after == before, f"heartbeats continued during a stall ({before} -> {after})"
  263. subscriber.close()
  264. daemon.stop()
  265. def test_discarded_segment_still_emits_speech_stop(sock_path):
  266. """A turn that is discarded (blip / too quiet) must still emit speech_stop.
  267. Without it, consumers that track speech_open from speech_start/speech_stop
  268. latch true forever, which blocks readiness and chat dispatch.
  269. """
  270. cfg = _base_config(sock_path)
  271. cfg.vad.min_utterance_ms = 300 # a 1-frame blip is discarded
  272. frames = [_frame() for _ in range(4)]
  273. vad = FakeVad([True, False, False, False]) # confirm, then silence -> discard
  274. daemon, collector, subscriber, _ = _start_daemon(
  275. cfg, FakeCapture(frames), vad, FakeTranscriber()
  276. )
  277. collector.wait_for(lambda e: any(x["type"] == events.SPEECH_STOP for x in e))
  278. time.sleep(0.2)
  279. starts = collector.of(events.SPEECH_START)
  280. stops = collector.of(events.SPEECH_STOP)
  281. assert len(starts) == 1
  282. assert len(stops) == 1
  283. assert stops[0]["seg_id"] is None # discarded: no segment assigned
  284. assert stops[0]["end_reason"] == "discarded"
  285. assert collector.of(events.SEGMENT_DONE) == []
  286. assert collector.of(events.TRANSCRIPT) == []
  287. subscriber.close()
  288. daemon.stop()
  289. def test_inbound_data_on_subscriber_socket_is_ignored(sock_path):
  290. cfg = _base_config(sock_path)
  291. cfg.events.heartbeat_ms = 40
  292. frames = [_frame() for _ in range(60)]
  293. daemon, collector, subscriber, _ = _start_daemon(
  294. cfg, FakeCapture(frames, delay=0.02), FakeVad([]), FakeTranscriber()
  295. )
  296. # The bus is publish-only: a client writing data must have no effect.
  297. raw = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
  298. raw.connect(sock_path)
  299. raw.sendall(b'{"v":1,"type":"evil","seq":1,"ts":0}\n')
  300. time.sleep(0.1)
  301. assert collector.wait_for(
  302. lambda e: len([x for x in e if x["type"] == events.HEARTBEAT]) >= 2
  303. )
  304. assert all(x["type"] != "evil" for x in collector.events)
  305. raw.close()
  306. subscriber.close()
  307. daemon.stop()
  308. def test_request_stop_shuts_down_run_loop(tmp_path):
  309. """request_stop() (the signal-handler path) must unwind run() cleanly."""
  310. sock_path = str(tmp_path / "events.sock")
  311. cfg = _base_config(sock_path)
  312. capture = FakeCapture([_frame(0)] * 5, stall_after=1)
  313. sink = FakeSink()
  314. daemon = Daemon(
  315. cfg,
  316. log_level=logging.WARNING,
  317. capture_factory=lambda: capture,
  318. vad_factory=lambda _aggr: FakeVad([]),
  319. transcriber_factory=lambda: FakeTranscriber(),
  320. sink_factory=lambda: sink,
  321. )
  322. thread = threading.Thread(target=daemon.run, daemon=True)
  323. thread.start()
  324. time.sleep(0.2)
  325. daemon.request_stop()
  326. thread.join(timeout=5)
  327. assert not thread.is_alive()
  328. assert capture._closed.is_set()