test_daemon_events.py 11 KB

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