test_daemon_speaker.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  1. """Daemon speaker-identification lane: embedder seam, join-before-emit, inbox.
  2. All hardware- and network-free: a fake embedder returns fixed vectors and the
  3. store lives in tmp_path. No ONNX model, no sherpa-onnx import.
  4. """
  5. import json
  6. import logging
  7. import os
  8. import sys
  9. import threading
  10. import time
  11. import types
  12. import numpy as np
  13. import pytest
  14. from stt_daemon import events
  15. from stt_daemon.config import Config
  16. from stt_daemon.daemon import Daemon
  17. from stt_daemon.embedder import SherpaEmbedder, build_embedder
  18. from stt_daemon.segmenter import Utterance
  19. from stt_daemon.transcriber import TranscriptResult
  20. VEC_A = np.array([1.0, 0.0, 0.0], dtype=np.float32)
  21. VEC_B = np.array([0.0, 1.0, 0.0], dtype=np.float32)
  22. class FakeEmbedder:
  23. """Returns canned vectors; tracks peak concurrent embed() calls."""
  24. def __init__(self, vectors=None, delay=0.0, error=None):
  25. self._vectors = list(vectors) if vectors else []
  26. self._delay = delay
  27. self._error = error
  28. self.calls = 0
  29. self.max_in_flight = 0
  30. self._in_flight = 0
  31. self._lock = threading.Lock()
  32. def embed(self, audio):
  33. with self._lock:
  34. self.calls += 1
  35. self._in_flight += 1
  36. self.max_in_flight = max(self.max_in_flight, self._in_flight)
  37. try:
  38. if self._delay:
  39. time.sleep(self._delay)
  40. if self._error is not None:
  41. raise self._error
  42. if self._vectors:
  43. return np.array(self._vectors.pop(0), dtype=np.float32)
  44. return np.array(VEC_A, dtype=np.float32)
  45. finally:
  46. with self._lock:
  47. self._in_flight -= 1
  48. class FakeTranscriber:
  49. def __init__(self, text="hello"):
  50. self.text = text
  51. def transcribe(self, audio):
  52. return TranscriptResult(language="en", language_probability=0.9, text=self.text)
  53. def close(self):
  54. pass
  55. class FakeSink:
  56. def __init__(self):
  57. self.records = []
  58. def emit(self, result, duration_s, rms=0.0, reason="unknown", audio=None,
  59. transcribe_s=None, **kwargs):
  60. if not result.text:
  61. return None
  62. self.records.append(dict(kwargs))
  63. return None
  64. def close(self):
  65. pass
  66. class FakeCapture:
  67. def frames(self):
  68. return iter(())
  69. def healthy(self):
  70. return True
  71. def close(self):
  72. pass
  73. def _utt(seg_id, duration_s=3.0, rms=0.05):
  74. return Utterance(
  75. audio=np.zeros(1600, dtype=np.float32),
  76. duration_s=duration_s,
  77. started_at=0.0,
  78. rms=rms,
  79. reason="pause",
  80. seg_id=seg_id,
  81. )
  82. def _config(tmp_path, **overrides):
  83. cfg = Config()
  84. cfg.events.socket = str(tmp_path / "events.sock")
  85. cfg.output.stdout = False
  86. cfg.output.jsonl_dir = None
  87. cfg.output.save_audio = False
  88. cfg.speaker.enabled = True
  89. cfg.speaker.model_path = str(tmp_path / "model.onnx")
  90. cfg.speaker.store_dir = str(tmp_path / "speakers")
  91. for key, value in overrides.items():
  92. setattr(cfg.speaker, key, value)
  93. return cfg
  94. def _daemon_with_lane(cfg, embedder, published=None):
  95. """Wire a daemon with fakes and a started speaker lane (no threads but lane)."""
  96. sink = FakeSink()
  97. daemon = Daemon(
  98. cfg,
  99. log_level=logging.WARNING,
  100. transcriber_factory=lambda: FakeTranscriber(),
  101. sink_factory=lambda: sink,
  102. embedder_factory=lambda: embedder,
  103. )
  104. daemon.transcriber = FakeTranscriber()
  105. daemon.sink = sink
  106. collected = [] if published is None else published
  107. daemon._publish = lambda type_, **fields: collected.append((type_, fields))
  108. daemon._start_speaker_lane()
  109. assert daemon._speaker_lane is not None
  110. daemon._speaker_lane.start()
  111. return daemon, sink, collected
  112. def _transcripts(collected):
  113. return [fields for type_, fields in collected if type_ == events.TRANSCRIPT]
  114. def _speakers_events(collected):
  115. return [fields for type_, fields in collected if type_ == events.SPEAKERS]
  116. def test_no_sherpa_import_at_module_level():
  117. import stt_daemon.embedder # noqa: F401 -- must import without the optional extra
  118. assert "sherpa_onnx" not in sys.modules
  119. def test_build_embedder_none_when_disabled_or_missing(tmp_path, caplog):
  120. cfg = _config(tmp_path)
  121. cfg.speaker.enabled = False
  122. with caplog.at_level(logging.WARNING):
  123. assert build_embedder(cfg) is None
  124. assert "speaker" in caplog.text.lower()
  125. cfg.speaker.enabled = True
  126. cfg.speaker.model_path = None
  127. with caplog.at_level(logging.WARNING):
  128. assert build_embedder(cfg) is None
  129. cfg.speaker.model_path = str(tmp_path / "nope.onnx")
  130. with caplog.at_level(logging.WARNING):
  131. assert build_embedder(cfg) is None
  132. def test_mint_then_matched_flow(tmp_path):
  133. cfg = _config(tmp_path)
  134. cfg.speaker.confirm_s = 100.0 # keep the third utterance at adapt, not confirm
  135. daemon, sink, collected = _daemon_with_lane(cfg, FakeEmbedder())
  136. daemon._process(_utt(1))
  137. daemon._process(_utt(2))
  138. daemon._process(_utt(3))
  139. daemon.stop()
  140. transcripts = _transcripts(collected)
  141. assert len(transcripts) == 3
  142. for fields in transcripts:
  143. for key in ("speaker_id", "speaker_status", "speaker", "speaker_score"):
  144. assert key in fields
  145. assert fields["seg_id"] in (1, 2, 3)
  146. assert transcripts[0]["speaker_status"] == "unknown"
  147. assert transcripts[0]["speaker_id"] is None
  148. assert transcripts[1]["speaker_status"] == "unknown"
  149. assert transcripts[1]["speaker_id"] == "spk_0001"
  150. assert transcripts[1]["speaker_score"] is None
  151. assert transcripts[2]["speaker_status"] == "matched"
  152. assert transcripts[2]["speaker_id"] == "spk_0001"
  153. assert transcripts[2]["speaker"] == "spk_0001" # unnamed: id as label
  154. assert transcripts[2]["speaker_score"] == pytest.approx(1.0)
  155. # Sink saw the same five fields, including seg_id.
  156. assert sink.records[2]["seg_id"] == 3
  157. assert sink.records[2]["speaker_id"] == "spk_0001"
  158. assert sink.records[2]["speaker_status"] == "matched"
  159. # Mint publishes SPEAKERS; adapt does not.
  160. assert len(_speakers_events(collected)) == 1
  161. # Matched + named resolves the display name.
  162. assert daemon.speaker_store.rename("spk_0001", "Efren") is True
  163. daemon2, sink2, collected2 = _daemon_with_lane(cfg, FakeEmbedder())
  164. daemon2._process(_utt(4))
  165. daemon2.stop()
  166. assert _transcripts(collected2)[0]["speaker"] == "Efren"
  167. def test_speakers_published_on_confirm_not_adapt(tmp_path):
  168. cfg = _config(tmp_path)
  169. cfg.speaker.confirm_s = 6.0
  170. daemon, _, collected = _daemon_with_lane(cfg, FakeEmbedder())
  171. daemon._process(_utt(1))
  172. daemon._process(_utt(2)) # mint (cumulative 3.0)
  173. assert len(_speakers_events(collected)) == 1
  174. daemon._process(_utt(3)) # adapt -> cumulative 6.0 -> confirm
  175. daemon.stop()
  176. assert len(_speakers_events(collected)) == 2
  177. assert _speakers_events(collected)[-1]["speakers"][0]["status"] == "confirmed"
  178. assert daemon.speaker_store.speakers["spk_0001"]["status"] == "confirmed"
  179. def test_stalled_embedder_times_out_without_blocking(tmp_path):
  180. cfg = _config(tmp_path, join_timeout_s=0.2)
  181. daemon, sink, collected = _daemon_with_lane(cfg, FakeEmbedder(delay=2.0))
  182. started = time.monotonic()
  183. daemon._process(_utt(1))
  184. elapsed = time.monotonic() - started
  185. daemon.stop()
  186. assert elapsed < 2.0
  187. transcripts = _transcripts(collected)
  188. assert len(transcripts) == 1 # transcription still emitted
  189. assert transcripts[0]["speaker_status"] == "none"
  190. assert transcripts[0]["speaker_id"] is None
  191. assert sink.records[0]["speaker_status"] == "none"
  192. def test_embedder_error_degrades_to_none(tmp_path):
  193. cfg = _config(tmp_path)
  194. daemon, _, collected = _daemon_with_lane(cfg, FakeEmbedder(error=RuntimeError("boom")))
  195. daemon._process(_utt(1)) # must not raise
  196. daemon.stop()
  197. transcripts = _transcripts(collected)
  198. assert len(transcripts) == 1
  199. assert transcripts[0]["speaker_status"] == "none"
  200. @pytest.mark.parametrize("enabled,model_path", [(False, None), (True, None)])
  201. def test_disabled_or_missing_model_has_no_lane(tmp_path, monkeypatch, enabled, model_path):
  202. monkeypatch.chdir(tmp_path)
  203. cfg = Config()
  204. cfg.output.stdout = False
  205. cfg.speaker.enabled = enabled
  206. cfg.speaker.model_path = model_path
  207. cfg.speaker.store_dir = "speakers"
  208. sink = FakeSink()
  209. daemon = Daemon(
  210. cfg,
  211. log_level=logging.WARNING,
  212. transcriber_factory=lambda: FakeTranscriber(),
  213. sink_factory=lambda: sink,
  214. )
  215. daemon.transcriber = FakeTranscriber()
  216. daemon.sink = sink
  217. collected = []
  218. daemon._publish = lambda type_, **fields: collected.append((type_, fields))
  219. daemon._start_speaker_lane()
  220. assert daemon._speaker_lane is None
  221. assert daemon.speaker_store is None
  222. daemon._process(_utt(1))
  223. transcripts = _transcripts(collected)
  224. assert transcripts[0]["speaker_status"] == "none"
  225. assert transcripts[0]["speaker_id"] is None
  226. assert transcripts[0]["speaker"] == "?"
  227. assert transcripts[0]["speaker_score"] is None
  228. assert not (tmp_path / "speakers").exists()
  229. def test_inbox_valid_request_renames_and_publishes(tmp_path):
  230. cfg = _config(tmp_path)
  231. daemon, _, collected = _daemon_with_lane(cfg, FakeEmbedder())
  232. daemon._process(_utt(1))
  233. daemon._process(_utt(2)) # mint spk_0001
  234. before = len(_speakers_events(collected))
  235. inbox = tmp_path / "speakers" / "inbox"
  236. inbox.mkdir(parents=True, exist_ok=True)
  237. request = inbox / "req-1.json"
  238. request.write_text(json.dumps({"speaker_id": "spk_0001", "name": "Efren"}))
  239. daemon._speaker_lane.drain_inbox()
  240. assert daemon.speaker_store.speakers["spk_0001"]["name"] == "Efren"
  241. assert not request.exists()
  242. assert len(_speakers_events(collected)) == before + 1
  243. daemon.stop()
  244. def test_inbox_malformed_and_unknown_id_quarantined(tmp_path, caplog):
  245. cfg = _config(tmp_path)
  246. daemon, _, collected = _daemon_with_lane(cfg, FakeEmbedder())
  247. before = len(_speakers_events(collected))
  248. inbox = tmp_path / "speakers" / "inbox"
  249. inbox.mkdir(parents=True, exist_ok=True)
  250. bad = inbox / "bad.json"
  251. bad.write_text("{not json")
  252. wrong_shape = inbox / "shape.json"
  253. wrong_shape.write_text(json.dumps({"speaker_id": "spk_0001"}))
  254. unknown = inbox / "unknown.json"
  255. unknown.write_text(json.dumps({"speaker_id": "spk_9999", "name": "Ghost"}))
  256. with caplog.at_level(logging.WARNING):
  257. daemon._speaker_lane.drain_inbox()
  258. daemon.stop()
  259. rejected = inbox / "rejected"
  260. assert (rejected / "bad.json").exists()
  261. assert (rejected / "shape.json").exists()
  262. assert (rejected / "unknown.json").exists()
  263. assert len(_speakers_events(collected)) == before # no publish on reject
  264. assert "speaker" in caplog.text.lower()
  265. def test_inbox_request_present_before_start_is_applied(tmp_path):
  266. cfg = _config(tmp_path)
  267. cfg.speaker.confirm_s = 100.0
  268. first, _, _ = _daemon_with_lane(cfg, FakeEmbedder())
  269. first._process(_utt(1))
  270. first._process(_utt(2)) # mint spk_0001, persisted
  271. first.stop()
  272. inbox = tmp_path / "speakers" / "inbox"
  273. inbox.mkdir(parents=True, exist_ok=True)
  274. (inbox / "pending.json").write_text(
  275. json.dumps({"speaker_id": "spk_0001", "name": "Efren"})
  276. )
  277. sink = FakeSink()
  278. daemon = Daemon(
  279. cfg,
  280. log_level=logging.WARNING,
  281. transcriber_factory=lambda: FakeTranscriber(),
  282. sink_factory=lambda: sink,
  283. embedder_factory=lambda: FakeEmbedder(),
  284. )
  285. daemon.transcriber = FakeTranscriber()
  286. daemon.sink = sink
  287. collected = []
  288. daemon._publish = lambda type_, **fields: collected.append((type_, fields))
  289. daemon._start_speaker_lane()
  290. daemon._speaker_lane.start()
  291. deadline = time.time() + 5.0
  292. while time.time() < deadline:
  293. if daemon.speaker_store.speakers.get("spk_0001", {}).get("name") == "Efren":
  294. break
  295. time.sleep(0.01)
  296. daemon.stop()
  297. assert daemon.speaker_store.speakers["spk_0001"]["name"] == "Efren"
  298. assert not (inbox / "pending.json").exists()
  299. def test_embedding_lane_is_serial(tmp_path):
  300. cfg = _config(tmp_path)
  301. embedder = FakeEmbedder(delay=0.02)
  302. daemon, _, _ = _daemon_with_lane(cfg, embedder)
  303. lane = daemon._speaker_lane
  304. futures = [lane.submit(i, np.zeros(8, dtype=np.float32), 3.0, 0.05) for i in range(6)]
  305. for future in futures:
  306. future.result(timeout=10)
  307. daemon.stop()
  308. assert embedder.calls == 6
  309. assert embedder.max_in_flight == 1
  310. # --- real SherpaEmbedder against a fake sherpa_onnx module --------------------
  311. def _fake_sherpa_module(calls, vector, valid=True):
  312. """Fake sherpa_onnx implementing the verified API surface.
  313. Mirrors the official API: ``SpeakerEmbeddingExtractorConfig(model,
  314. num_threads=1, debug=False, provider="cpu")`` with ``validate()``, and
  315. ``SpeakerEmbeddingExtractor`` with ``create_stream / is_ready / compute``
  316. plus a readonly ``dim``. Unknown config kwargs raise TypeError, like the
  317. real pybind class.
  318. """
  319. module = types.ModuleType("sherpa_onnx")
  320. class Config:
  321. def __init__(self, model, num_threads=1, debug=False, provider="cpu"):
  322. calls.append(
  323. (
  324. "config",
  325. {
  326. "model": model,
  327. "num_threads": num_threads,
  328. "debug": debug,
  329. "provider": provider,
  330. },
  331. )
  332. )
  333. def validate(self):
  334. calls.append(("validate",))
  335. return valid
  336. class Stream:
  337. def accept_waveform(self, sample_rate, waveform):
  338. calls.append(("accept_waveform", sample_rate, tuple(np.asarray(waveform).shape)))
  339. def input_finished(self):
  340. calls.append(("input_finished",))
  341. class Extractor:
  342. def __init__(self, config):
  343. calls.append(("extractor",))
  344. @property
  345. def dim(self):
  346. return len(vector)
  347. def create_stream(self):
  348. calls.append(("create_stream",))
  349. return Stream()
  350. def is_ready(self, stream):
  351. calls.append(("is_ready",))
  352. return True
  353. def compute(self, stream):
  354. calls.append(("compute",))
  355. return np.array(vector, dtype=np.float32)
  356. module.SpeakerEmbeddingExtractorConfig = Config
  357. module.SpeakerEmbeddingExtractor = Extractor
  358. return module
  359. def test_sherpa_embedder_uses_verified_api(monkeypatch):
  360. calls = []
  361. vector = np.arange(192, dtype=np.float32)
  362. monkeypatch.setitem(sys.modules, "sherpa_onnx", _fake_sherpa_module(calls, vector))
  363. embedder = SherpaEmbedder("model.onnx")
  364. out = embedder.embed(np.zeros(1600, dtype=np.float32))
  365. assert out.shape == (192,)
  366. assert out.dtype == np.float32
  367. np.testing.assert_array_equal(out, vector)
  368. assert [call[0] for call in calls] == [
  369. "config",
  370. "validate",
  371. "extractor",
  372. "create_stream",
  373. "accept_waveform",
  374. "input_finished",
  375. "is_ready",
  376. "compute",
  377. ]
  378. assert calls[0][1] == {
  379. "model": "model.onnx",
  380. "num_threads": 1,
  381. "debug": False,
  382. "provider": "cpu",
  383. }
  384. assert calls[4][1] == 16000 # extractor runs at 16 kHz
  385. def test_sherpa_embedder_invalid_config_raises(monkeypatch):
  386. calls = []
  387. monkeypatch.setitem(
  388. sys.modules, "sherpa_onnx", _fake_sherpa_module(calls, np.zeros(2), valid=False)
  389. )
  390. with pytest.raises(RuntimeError, match="failed to load"):
  391. SherpaEmbedder("model.onnx")
  392. def test_sherpa_embedder_without_package_raises(monkeypatch):
  393. monkeypatch.setitem(sys.modules, "sherpa_onnx", None) # import -> ImportError
  394. with pytest.raises(RuntimeError, match="sherpa-onnx is not installed"):
  395. SherpaEmbedder("model.onnx")
  396. def test_sherpa_embedder_native_lib_failure_surfaces_cause(monkeypatch):
  397. monkeypatch.setitem(sys.modules, "sherpa_onnx", None) # import -> ModuleNotFoundError
  398. with pytest.raises(RuntimeError, match="uv sync --extra speaker"):
  399. SherpaEmbedder("model.onnx")
  400. import builtins
  401. real_import = builtins.__import__
  402. def fake_import(name, *args, **kwargs):
  403. if name == "sherpa_onnx":
  404. raise ImportError(
  405. "libonnxruntime.so: cannot open shared object file: No such file or directory"
  406. )
  407. return real_import(name, *args, **kwargs)
  408. monkeypatch.delitem(sys.modules, "sherpa_onnx", raising=False)
  409. monkeypatch.setattr(builtins, "__import__", fake_import)
  410. with pytest.raises(RuntimeError, match="libonnxruntime.so"):
  411. SherpaEmbedder("model.onnx")
  412. def _lane_without_thread(cfg, embedder, collected):
  413. """Build a daemon + lane without starting the worker (deterministic drain)."""
  414. sink = FakeSink()
  415. daemon = Daemon(
  416. cfg,
  417. log_level=logging.WARNING,
  418. transcriber_factory=lambda: FakeTranscriber(),
  419. sink_factory=lambda: sink,
  420. embedder_factory=lambda: embedder,
  421. )
  422. daemon.transcriber = FakeTranscriber()
  423. daemon.sink = sink
  424. daemon._publish = lambda type_, **fields: collected.append((type_, fields))
  425. daemon._start_speaker_lane()
  426. assert daemon._speaker_lane is not None
  427. return daemon
  428. def test_inbox_fifo_quarantined_without_blocking(tmp_path):
  429. cfg = _config(tmp_path)
  430. collected: list = []
  431. daemon = _lane_without_thread(cfg, FakeEmbedder(), collected)
  432. lane = daemon._speaker_lane
  433. assert lane is not None
  434. try:
  435. inbox = tmp_path / "speakers" / "inbox"
  436. inbox.mkdir(parents=True, exist_ok=True)
  437. fifo = inbox / "evil.json"
  438. try:
  439. os.mkfifo(fifo)
  440. except (AttributeError, OSError, NotImplementedError) as e:
  441. pytest.skip(f"mkfifo unsupported: {e}")
  442. before = len(_speakers_events(collected))
  443. worker = threading.Thread(target=lane.drain_inbox)
  444. worker.start()
  445. worker.join(timeout=5)
  446. assert not worker.is_alive() # must not block on the FIFO
  447. assert not os.path.lexists(fifo)
  448. assert os.path.lexists(inbox / "rejected" / "evil.json")
  449. assert len(_speakers_events(collected)) == before
  450. finally:
  451. daemon.stop()
  452. def test_inbox_symlink_quarantined_target_untouched(tmp_path):
  453. cfg = _config(tmp_path)
  454. collected: list = []
  455. daemon = _lane_without_thread(cfg, FakeEmbedder(), collected)
  456. lane = daemon._speaker_lane
  457. assert lane is not None
  458. try:
  459. inbox = tmp_path / "speakers" / "inbox"
  460. inbox.mkdir(parents=True, exist_ok=True)
  461. target = tmp_path / "target.txt"
  462. target.write_text("secret", encoding="utf-8")
  463. link = inbox / "link.json"
  464. try:
  465. link.symlink_to(target)
  466. except (OSError, NotImplementedError) as e:
  467. pytest.skip(f"symlink unsupported: {e}")
  468. before = len(_speakers_events(collected))
  469. lane.drain_inbox()
  470. assert not os.path.lexists(link)
  471. assert os.path.lexists(inbox / "rejected" / "link.json")
  472. assert target.read_text(encoding="utf-8") == "secret"
  473. assert len(_speakers_events(collected)) == before
  474. finally:
  475. daemon.stop()
  476. def test_inbox_oversized_request_quarantined(tmp_path):
  477. cfg = _config(tmp_path)
  478. collected: list = []
  479. daemon = _lane_without_thread(cfg, FakeEmbedder(), collected)
  480. lane = daemon._speaker_lane
  481. assert lane is not None
  482. try:
  483. inbox = tmp_path / "speakers" / "inbox"
  484. inbox.mkdir(parents=True, exist_ok=True)
  485. big = inbox / "big.json"
  486. big.write_bytes(b"x" * (65536 + 1024))
  487. before = len(_speakers_events(collected))
  488. lane.drain_inbox()
  489. assert not big.exists()
  490. assert (inbox / "rejected" / "big.json").exists()
  491. assert len(_speakers_events(collected)) == before
  492. finally:
  493. daemon.stop()