test_enroll_assist.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867
  1. """Tests for seed-based assisted speaker enrollment.
  2. Hardware- and network-free: WAVs are synthesized with stdlib ``wave`` (a DC
  3. level encodes the "speaker id"), the embedder is a fake, reviews are scripted,
  4. and playback is a recording stub. The real sherpa-onnx package, questionary,
  5. and audio players are never required.
  6. """
  7. import argparse
  8. import io
  9. import shutil
  10. import subprocess
  11. import sys
  12. import wave
  13. from contextlib import redirect_stderr, redirect_stdout
  14. from pathlib import Path
  15. import numpy as np
  16. import pytest
  17. import stt_daemon.cli as cli_module
  18. import stt_daemon.enroll_assist as ea
  19. from stt_daemon.config import load_config
  20. from stt_daemon.enroll import enroll_from_files
  21. from stt_daemon.enroll_assist import (
  22. Candidate,
  23. EnrollmentAborted,
  24. QuestionaryReview,
  25. play_wav,
  26. rank_candidates,
  27. review_candidates,
  28. seed_enroll,
  29. )
  30. from stt_daemon.speakers import SpeakerStore
  31. A = 0.30
  32. B = -0.30
  33. def write_const_wav(path: Path, seconds: float, value: float,
  34. sample_rate: int = 16000, nchannels: int = 1) -> Path:
  35. """Write a constant-level int16 PCM WAV; the level encodes the speaker."""
  36. count = int(seconds * sample_rate)
  37. samples = np.full(count, value, dtype=np.float32)
  38. frames = (samples * 32767.0).astype(np.int16)
  39. if nchannels == 2:
  40. frames = np.stack([frames, frames], axis=1).reshape(-1)
  41. with wave.open(str(path), "wb") as handle:
  42. handle.setnchannels(nchannels)
  43. handle.setsampwidth(2)
  44. handle.setframerate(sample_rate)
  45. handle.writeframes(frames.tobytes())
  46. return path
  47. class ConstEmbedder:
  48. """Deterministic embedding from the clip DC level (the fake speaker id)."""
  49. def __init__(self, dim: int = 2) -> None:
  50. self.dim = dim
  51. self.calls = 0
  52. def embed(self, audio: np.ndarray) -> np.ndarray:
  53. self.calls += 1
  54. mean = float(np.mean(np.asarray(audio, dtype=np.float32)))
  55. if self.dim == 2:
  56. if mean > 0.15:
  57. vector = [1.0, 0.0]
  58. elif mean < -0.15:
  59. vector = [0.0, 1.0]
  60. else:
  61. vector = [0.7071, 0.7071]
  62. else:
  63. vector = [1.0, 0.0, 0.0] if mean > 0.15 else [0.0, 1.0, 0.0]
  64. return np.array(vector, dtype=np.float32)
  65. class VariedEmbedder:
  66. """Same-speaker clips with distinct-but-similar embeddings.
  67. Rotates the A-speaker vector 20 degrees per call, so same-speaker clips
  68. score high (neighbors at cosine 0.94: above ``min_score``, below the
  69. ``dup_threshold`` duplicate floor) instead of collapsing to exact
  70. duplicates the way ConstEmbedder does. B-speaker clips map opposite the
  71. seed so they are always filtered by ``min_score``. Tracks ``calls`` like
  72. ConstEmbedder.
  73. """
  74. def __init__(self, dim: int = 2) -> None:
  75. self.dim = dim
  76. self.calls = 0
  77. def embed(self, audio: np.ndarray) -> np.ndarray:
  78. self.calls += 1
  79. mean = float(np.mean(np.asarray(audio, dtype=np.float32)))
  80. if mean < -0.15:
  81. return np.array([-1.0, 0.0], dtype=np.float32)
  82. angle = np.deg2rad(20.0 * self.calls)
  83. vector = np.array([np.cos(angle), np.sin(angle)], dtype=np.float32)
  84. return vector / np.linalg.norm(vector)
  85. class ScriptReview:
  86. """Scripted ClipReview; records every choose() call."""
  87. def __init__(self, actions) -> None:
  88. self.actions = list(actions)
  89. self.seen: list = []
  90. def choose(self, candidate, accepted, target) -> str:
  91. self.seen.append((candidate, accepted, target))
  92. if not self.actions:
  93. return "skip"
  94. return self.actions.pop(0)
  95. def write_cfg(tmp_path: Path, *, min_enroll_s: float = 2.0,
  96. max_prototypes: int = 8, model_ok: bool = True):
  97. """Write a temp config; return (cfg, audio_dir, store_dir, model)."""
  98. model = tmp_path / "model.onnx"
  99. if model_ok:
  100. model.write_bytes(b"fake-model")
  101. audio_dir = tmp_path / "audio"
  102. audio_dir.mkdir(exist_ok=True)
  103. store_dir = tmp_path / "speakers"
  104. cfg_path = tmp_path / "config.toml"
  105. cfg_path.write_text(
  106. "[speaker]\n"
  107. f'model_path = "{model}"\n'
  108. f"min_enroll_s = {min_enroll_s}\n"
  109. f"max_prototypes = {max_prototypes}\n"
  110. f'store_dir = "{store_dir}"\n'
  111. "[output]\n"
  112. f'audio_dir = "{audio_dir}"\n'
  113. "[events]\n"
  114. f'socket = "{tmp_path / "bus.sock"}"\n',
  115. encoding="utf-8",
  116. )
  117. return load_config(str(cfg_path)), audio_dir, store_dir
  118. def make_store(cfg) -> SpeakerStore:
  119. """Build a SpeakerStore honoring cfg (mirrors the CLI handler)."""
  120. speaker_cfg = cfg.speaker
  121. return SpeakerStore(
  122. Path(speaker_cfg.store_dir),
  123. threshold_id=speaker_cfg.threshold_id,
  124. threshold_new=speaker_cfg.threshold_new,
  125. min_enroll_s=speaker_cfg.min_enroll_s,
  126. rms_floor=speaker_cfg.rms_floor,
  127. margin=speaker_cfg.margin,
  128. mint_cooldown_s=speaker_cfg.mint_cooldown_s,
  129. confirm_s=speaker_cfg.confirm_s,
  130. max_prototypes=speaker_cfg.max_prototypes,
  131. provisional_ttl_h=speaker_cfg.provisional_ttl_h,
  132. )
  133. def record_enroll(monkeypatch):
  134. """Wrap ea.enroll_from_files to record enrolled paths; return the log."""
  135. called: list = []
  136. real = ea.enroll_from_files
  137. def fake(store, embedder, name, paths, min_s):
  138. called.append(list(paths))
  139. return real(store, embedder, name, paths, min_s)
  140. monkeypatch.setattr(ea, "enroll_from_files", fake)
  141. return called
  142. def store_files(store_dir: Path) -> bool:
  143. return (store_dir / "speakers.json").exists()
  144. def fresh_speakers(store_dir: Path) -> dict:
  145. store = SpeakerStore(store_dir)
  146. store.load()
  147. return dict(store.speakers)
  148. # -- rank_candidates ------------------------------------------------------------
  149. def test_rank_orders_by_score_and_counts_skipped(tmp_path):
  150. seed = write_const_wav(tmp_path / "seed.wav", 2.5, A)
  151. good_a = write_const_wav(tmp_path / "a.wav", 2.5, A)
  152. good_b = write_const_wav(tmp_path / "b.wav", 2.5, B)
  153. (tmp_path / "notes.wav").write_text("not audio", encoding="utf-8")
  154. write_const_wav(tmp_path / "stereo.wav", 2.5, A, nchannels=2)
  155. write_const_wav(tmp_path / "lowrate.wav", 2.5, A, sample_rate=8000)
  156. from stt_daemon.enroll import read_wav
  157. seed_audio, _ = read_wav(seed)
  158. ranked, skipped = rank_candidates(
  159. ConstEmbedder(), seed_audio,
  160. [good_a, good_b, tmp_path / "notes.wav", tmp_path / "stereo.wav",
  161. tmp_path / "lowrate.wav", tmp_path / "missing.wav"],
  162. min_enroll_s=2.0, min_score=0.55)
  163. assert [c.path for c in ranked] == [good_a]
  164. assert skipped == 4
  165. assert ranked[0].score == pytest.approx(1.0)
  166. assert ranked[0].duration_s == pytest.approx(2.5)
  167. def test_rank_excludes_seed_and_short_clips(tmp_path):
  168. seed = write_const_wav(tmp_path / "seed.wav", 2.5, A)
  169. short = write_const_wav(tmp_path / "short.wav", 0.5, A)
  170. long_a = write_const_wav(tmp_path / "long.wav", 2.5, A)
  171. from stt_daemon.enroll import read_wav
  172. seed_audio, _ = read_wav(seed)
  173. ranked, _ = rank_candidates(
  174. ConstEmbedder(), seed_audio, [seed, short, long_a],
  175. min_enroll_s=2.0, min_score=0.55, exclude=(seed.resolve(),))
  176. assert [c.path for c in ranked] == [long_a]
  177. def test_rank_min_score_floor(tmp_path):
  178. seed = write_const_wav(tmp_path / "seed.wav", 2.5, A)
  179. mid = write_const_wav(tmp_path / "mid.wav", 2.5, 0.0)
  180. other = write_const_wav(tmp_path / "other.wav", 2.5, B)
  181. from stt_daemon.enroll import read_wav
  182. seed_audio, _ = read_wav(seed)
  183. ranked, _ = rank_candidates(
  184. ConstEmbedder(), seed_audio, [other, mid],
  185. min_enroll_s=2.0, min_score=0.55)
  186. assert [c.path for c in ranked] == [mid]
  187. assert ranked[0].score == pytest.approx(0.7071, abs=1e-3)
  188. # -- review_candidates ----------------------------------------------------------
  189. def test_review_accept_skip_play_quit(tmp_path):
  190. a = Candidate(Path("a.wav"), 0.9, 2.5)
  191. b = Candidate(Path("b.wav"), 0.8, 2.5)
  192. c = Candidate(Path("c.wav"), 0.7, 2.5)
  193. played: list = []
  194. review = ScriptReview(["accept", "skip", "play", "accept"])
  195. accepted = review_candidates(
  196. [a, b, c], review, 5, play=lambda p: played.append(p))
  197. assert accepted == [a, c]
  198. assert played == [c.path]
  199. assert [s[0] for s in review.seen] == [a, b, c, c]
  200. def test_review_play_does_not_consume_candidate():
  201. a = Candidate(Path("a.wav"), 0.9, 2.5)
  202. review = ScriptReview(["play", "play", "accept"])
  203. accepted = review_candidates([a], review, 1, play=lambda p: None)
  204. assert accepted == [a]
  205. assert len(review.seen) == 3
  206. def test_review_quit_returns_accepted_so_far():
  207. a = Candidate(Path("a.wav"), 0.9, 2.5)
  208. b = Candidate(Path("b.wav"), 0.8, 2.5)
  209. review = ScriptReview(["accept", "quit"])
  210. assert review_candidates([a, b], review, 5, play=lambda p: None) == [a]
  211. def test_review_count_stops_accepting():
  212. cands = [Candidate(Path(f"{i}.wav"), 0.9, 2.5) for i in range(4)]
  213. accepted = review_candidates(
  214. cands, ScriptReview(["accept"] * 4), 2, play=lambda p: None)
  215. assert len(accepted) == 2
  216. def test_review_rejects_unknown_action():
  217. with pytest.raises(ValueError, match="unknown review action"):
  218. review_candidates(
  219. [Candidate(Path("a.wav"), 0.9, 2.5)], ScriptReview(["dance"]),
  220. 1, play=lambda p: None)
  221. # -- play_wav -------------------------------------------------------------------
  222. def test_play_wav_no_players_returns_none(monkeypatch, tmp_path):
  223. monkeypatch.setattr(shutil, "which", lambda _name: None)
  224. assert play_wav(tmp_path / "clip.wav") is None
  225. def test_play_wav_uses_first_available(monkeypatch, tmp_path):
  226. monkeypatch.setattr(shutil, "which",
  227. lambda name: "/usr/bin/paplay" if name == "paplay" else None)
  228. ran: list = []
  229. monkeypatch.setattr(subprocess, "run",
  230. lambda *a, **k: ran.append(a[0]) or
  231. subprocess.CompletedProcess(a[0], 0))
  232. assert play_wav(tmp_path / "clip.wav") == "paplay"
  233. assert ran[0][0] == "paplay"
  234. # -- seed_enroll ----------------------------------------------------------------
  235. def seed_and_clips(audio_dir: Path, *, seed_value=A, seed_s=2.5,
  236. clips=((("a1.wav", A), ("a2.wav", A), ("b1.wav", B)))):
  237. seed = write_const_wav(audio_dir / "seed.wav", seed_s, seed_value)
  238. paths = [write_const_wav(audio_dir / name, 2.5, value)
  239. for name, value in clips]
  240. return seed, paths
  241. def test_seed_enroll_auto_enrolls_seed_plus_top_count(monkeypatch, tmp_path, capsys):
  242. cfg, audio_dir, store_dir = write_cfg(tmp_path)
  243. seed, _ = seed_and_clips(audio_dir)
  244. enrolled_log = record_enroll(monkeypatch)
  245. accepted = seed_enroll(cfg, VariedEmbedder(), make_store(cfg), "Efren",
  246. seed, count=2, auto=True)
  247. assert [c.path.name for c in accepted] == ["a1.wav", "a2.wav"]
  248. assert [p.name for p in enrolled_log[0]] == ["seed.wav", "a1.wav", "a2.wav"]
  249. assert store_files(store_dir)
  250. assert fresh_speakers(store_dir)["spk_0001"]["name"] == "Efren"
  251. def test_seed_enroll_short_seed_is_query_only(monkeypatch, tmp_path, capsys):
  252. cfg, audio_dir, store_dir = write_cfg(tmp_path)
  253. seed, _ = seed_and_clips(audio_dir, seed_s=0.5)
  254. enrolled_log = record_enroll(monkeypatch)
  255. accepted = seed_enroll(cfg, VariedEmbedder(), make_store(cfg), "Efren",
  256. seed, count=2, auto=True)
  257. assert len(accepted) == 2
  258. assert [p.name for p in enrolled_log[0]] == ["a1.wav", "a2.wav"]
  259. assert "query only" in capsys.readouterr().out
  260. def test_seed_enroll_seed_excluded_from_candidates(tmp_path):
  261. cfg, audio_dir, _ = write_cfg(tmp_path)
  262. seed, _ = seed_and_clips(audio_dir)
  263. accepted = seed_enroll(cfg, VariedEmbedder(), make_store(cfg), "Efren",
  264. seed, count=10, auto=True)
  265. assert seed not in [c.path for c in accepted]
  266. assert len(accepted) == 2
  267. def test_seed_enroll_fewer_candidates_than_count(monkeypatch, tmp_path):
  268. cfg, audio_dir, store_dir = write_cfg(tmp_path)
  269. seed, _ = seed_and_clips(audio_dir, clips=(("a1.wav", A),))
  270. enrolled_log = record_enroll(monkeypatch)
  271. accepted = seed_enroll(cfg, VariedEmbedder(), make_store(cfg), "Efren",
  272. seed, count=5, auto=True)
  273. assert len(accepted) == 1
  274. assert len(enrolled_log[0]) == 2
  275. assert store_files(store_dir)
  276. def test_seed_enroll_all_skipped_writes_nothing(tmp_path, capsys):
  277. cfg, audio_dir, store_dir = write_cfg(tmp_path)
  278. seed, _ = seed_and_clips(audio_dir)
  279. accepted = seed_enroll(cfg, ConstEmbedder(), make_store(cfg), "Efren",
  280. seed, review=ScriptReview(["skip"] * 3))
  281. assert accepted == []
  282. assert not store_files(store_dir)
  283. assert "nothing enrolled" in capsys.readouterr().out
  284. def test_seed_enroll_empty_dir_writes_nothing(tmp_path, capsys):
  285. cfg, audio_dir, store_dir = write_cfg(tmp_path)
  286. seed = write_const_wav(tmp_path / "seed.wav", 2.5, A)
  287. accepted = seed_enroll(cfg, ConstEmbedder(), make_store(cfg), "Efren",
  288. seed, count=3, auto=True)
  289. assert accepted == []
  290. assert not store_files(store_dir)
  291. assert "nothing enrolled" in capsys.readouterr().out
  292. def test_seed_enroll_quit_aborts_without_writing(tmp_path, capsys):
  293. cfg, audio_dir, store_dir = write_cfg(tmp_path)
  294. seed, _ = seed_and_clips(audio_dir)
  295. with pytest.raises(EnrollmentAborted):
  296. seed_enroll(cfg, ConstEmbedder(), make_store(cfg), "Efren", seed,
  297. review=ScriptReview(["accept", "quit"]))
  298. assert not store_files(store_dir)
  299. out = capsys.readouterr().out
  300. assert "nothing enrolled" in out
  301. assert "speaker-enroll" in out
  302. def test_seed_enroll_keyboard_interrupt_aborts_without_writing(tmp_path):
  303. cfg, audio_dir, store_dir = write_cfg(tmp_path)
  304. seed, _ = seed_and_clips(audio_dir)
  305. class ExplodingReview:
  306. def choose(self, candidate, accepted, target):
  307. raise KeyboardInterrupt
  308. with pytest.raises(EnrollmentAborted):
  309. seed_enroll(cfg, ConstEmbedder(), make_store(cfg), "Efren", seed,
  310. review=ExplodingReview())
  311. assert not store_files(store_dir)
  312. def test_seed_enroll_dim_mismatch_errors_before_writing(tmp_path):
  313. cfg, audio_dir, store_dir = write_cfg(tmp_path)
  314. seed, _ = seed_and_clips(audio_dir)
  315. old = make_store(cfg)
  316. enroll_from_files(old, ConstEmbedder(dim=3), "Efren", [seed], 2.0)
  317. store = make_store(cfg)
  318. store.load()
  319. with pytest.raises(ValueError, match="different model"):
  320. seed_enroll(cfg, ConstEmbedder(dim=2), store, "Efren", seed, auto=True)
  321. assert fresh_speakers(store_dir)["spk_0001"]["prototypes"] == 1
  322. def test_seed_enroll_max_prototypes_warns_and_evicts(tmp_path, capsys):
  323. cfg, audio_dir, store_dir = write_cfg(tmp_path, max_prototypes=2)
  324. seed, _ = seed_and_clips(audio_dir)
  325. seed_enroll(cfg, VariedEmbedder(), make_store(cfg), "Efren", seed,
  326. count=2, auto=True)
  327. assert "evict" in capsys.readouterr().out
  328. assert fresh_speakers(store_dir)["spk_0001"]["prototypes"] == 2
  329. def test_seed_enroll_max_scan_caps_embedding(tmp_path, capsys):
  330. cfg, audio_dir, _ = write_cfg(tmp_path)
  331. seed = write_const_wav(audio_dir / "seed.wav", 2.5, A)
  332. for i in range(5):
  333. write_const_wav(audio_dir / f"clip{i}.wav", 2.5, A)
  334. embedder = VariedEmbedder()
  335. accepted = seed_enroll(cfg, embedder, make_store(cfg), "Efren", seed,
  336. count=5, max_scan=2, auto=True)
  337. assert len(accepted) == 2
  338. # seed embed (dim check) + seed embed (rank) + 2 candidates + enroll of 3
  339. assert embedder.calls == 7
  340. assert "max-scan 2" in capsys.readouterr().out
  341. # -- CLI handler ----------------------------------------------------------------
  342. def run_seed_handler(monkeypatch, tmp_path, *, name="Efren", seed=None,
  343. count=5, min_score=0.55, audio_dir=None, max_scan=0,
  344. yes=False, no_play=False, force=False, model_ok=True,
  345. tty=True, review=None, min_enroll_s=2.0,
  346. max_prototypes=8, dim=2, pre_enroll_dim=None,
  347. embedder_cls: type = ConstEmbedder):
  348. """Drive ``_handle_speaker_enroll_seed`` with fakes; return (rc, out, err)."""
  349. cfg, default_audio, store_dir = write_cfg(
  350. tmp_path, min_enroll_s=min_enroll_s, max_prototypes=max_prototypes,
  351. model_ok=model_ok)
  352. if audio_dir is None:
  353. audio_dir = default_audio
  354. if seed is None:
  355. seed = write_const_wav(Path(audio_dir) / "seed.wav", 2.5, A)
  356. if pre_enroll_dim is not None:
  357. old = SpeakerStore(store_dir)
  358. enroll_from_files(old, ConstEmbedder(dim=pre_enroll_dim), name,
  359. [seed], min_enroll_s)
  360. fake = embedder_cls(dim=dim)
  361. class FakeSherpa:
  362. def __init__(self, model_path, provider="cpu"):
  363. self._embedder = fake
  364. def embed(self, audio):
  365. return fake.embed(audio)
  366. monkeypatch.setattr(cli_module, "SherpaEmbedder", FakeSherpa)
  367. if review is not None:
  368. monkeypatch.setattr(cli_module, "QuestionaryReview", lambda **k: review)
  369. if tty:
  370. monkeypatch.setattr(sys, "stdin", _TtyStdin())
  371. else:
  372. monkeypatch.setattr(sys, "stdin", io.StringIO())
  373. args = argparse.Namespace(
  374. config=str(tmp_path / "config.toml"), name=name, seed=str(seed),
  375. count=count, min_score=min_score, audio_dir=str(audio_dir),
  376. max_scan=max_scan, yes=yes, no_play=no_play, force=force,
  377. )
  378. out, err = io.StringIO(), io.StringIO()
  379. with redirect_stdout(out), redirect_stderr(err):
  380. rc = cli_module._handle_speaker_enroll_seed(args)
  381. return rc, out.getvalue(), err.getvalue()
  382. class _TtyStdin(io.StringIO):
  383. def isatty(self):
  384. return True
  385. def _audio_with_clips(tmp_path: Path, sub="audio") -> Path:
  386. audio_dir = tmp_path / sub
  387. audio_dir.mkdir(exist_ok=True)
  388. write_const_wav(audio_dir / "seed.wav", 2.5, A)
  389. write_const_wav(audio_dir / "a1.wav", 2.5, A)
  390. write_const_wav(audio_dir / "a2.wav", 2.5, A)
  391. write_const_wav(audio_dir / "b1.wav", 2.5, B)
  392. return audio_dir
  393. def test_cli_auto_success(monkeypatch, tmp_path):
  394. audio_dir = _audio_with_clips(tmp_path)
  395. rc, out, _ = run_seed_handler(monkeypatch, tmp_path, audio_dir=audio_dir,
  396. count=2, yes=True,
  397. embedder_cls=VariedEmbedder)
  398. assert rc == 0
  399. assert "enrolled 'Efren' as spk_0001 from seed" in out
  400. assert "3 clip(s) (seed + 2 reviewed" in out
  401. def test_cli_short_seed_query_only(monkeypatch, tmp_path):
  402. audio_dir = tmp_path / "audio"
  403. audio_dir.mkdir(exist_ok=True)
  404. seed = write_const_wav(audio_dir / "seed.wav", 0.5, A)
  405. write_const_wav(audio_dir / "a1.wav", 2.5, A)
  406. rc, out, _ = run_seed_handler(monkeypatch, tmp_path, audio_dir=audio_dir,
  407. seed=seed, count=1, yes=True)
  408. assert rc == 0
  409. assert "query-only" in out
  410. def test_cli_non_tty_without_yes_errors(monkeypatch, tmp_path):
  411. audio_dir = _audio_with_clips(tmp_path)
  412. rc, _, err = run_seed_handler(monkeypatch, tmp_path, audio_dir=audio_dir,
  413. tty=False, yes=False)
  414. assert rc == 1
  415. assert "--yes" in err
  416. def test_cli_non_tty_with_yes_auto_accepts(monkeypatch, tmp_path):
  417. audio_dir = _audio_with_clips(tmp_path)
  418. rc, out, _ = run_seed_handler(monkeypatch, tmp_path, audio_dir=audio_dir,
  419. tty=False, yes=True, count=1,
  420. embedder_cls=VariedEmbedder)
  421. assert rc == 0
  422. assert "from seed" in out
  423. def test_cli_daemon_guard_and_force(monkeypatch, tmp_path):
  424. audio_dir = _audio_with_clips(tmp_path)
  425. (tmp_path / "bus.sock").write_text("", encoding="utf-8")
  426. rc, _, err = run_seed_handler(monkeypatch, tmp_path, audio_dir=audio_dir,
  427. yes=True)
  428. assert rc == 1
  429. assert "daemon" in err.lower()
  430. rc, _, _ = run_seed_handler(monkeypatch, tmp_path, audio_dir=audio_dir,
  431. yes=True, force=True,
  432. embedder_cls=VariedEmbedder)
  433. assert rc == 0
  434. def test_cli_missing_model(monkeypatch, tmp_path):
  435. audio_dir = _audio_with_clips(tmp_path)
  436. rc, _, err = run_seed_handler(monkeypatch, tmp_path, audio_dir=audio_dir,
  437. yes=True, model_ok=False)
  438. assert rc == 1
  439. assert "model" in err.lower()
  440. def test_cli_dim_mismatch_no_write(monkeypatch, tmp_path):
  441. audio_dir = _audio_with_clips(tmp_path)
  442. seed = audio_dir / "seed.wav"
  443. rc, _, err = run_seed_handler(monkeypatch, tmp_path, audio_dir=audio_dir,
  444. seed=seed, yes=True, dim=2, pre_enroll_dim=3)
  445. assert rc == 1
  446. assert "different model" in err
  447. assert fresh_speakers(tmp_path / "speakers")["spk_0001"]["prototypes"] == 1
  448. def test_cli_quit_no_write(monkeypatch, tmp_path):
  449. audio_dir = _audio_with_clips(tmp_path)
  450. rc, out, _ = run_seed_handler(
  451. monkeypatch, tmp_path, audio_dir=audio_dir,
  452. review=ScriptReview(["accept", "quit"]))
  453. assert rc == 1
  454. assert not (tmp_path / "speakers" / "speakers.json").exists()
  455. assert "speaker-enroll" in out
  456. def test_cli_no_play_disables_playback(monkeypatch, tmp_path):
  457. audio_dir = _audio_with_clips(tmp_path)
  458. seen: dict = {}
  459. class SpyReviewFactory:
  460. def __init__(self, **kwargs):
  461. seen.update(kwargs)
  462. def choose(self, candidate, accepted, target):
  463. return "accept" if accepted < 1 else "quit"
  464. monkeypatch.setattr(cli_module, "QuestionaryReview", SpyReviewFactory)
  465. monkeypatch.setattr(ea, "play_wav", lambda p: (_ for _ in ()).throw(
  466. AssertionError("playback attempted")))
  467. rc, _, _ = run_seed_handler(monkeypatch, tmp_path, audio_dir=audio_dir,
  468. count=2, no_play=True)
  469. assert seen.get("allow_play") is False
  470. assert rc == 1
  471. def test_cli_no_candidates_no_write(monkeypatch, tmp_path):
  472. audio_dir = tmp_path / "audio"
  473. audio_dir.mkdir(exist_ok=True)
  474. seed = write_const_wav(audio_dir / "seed.wav", 2.5, A)
  475. rc, out, _ = run_seed_handler(monkeypatch, tmp_path, audio_dir=audio_dir,
  476. seed=seed, yes=True)
  477. assert rc == 1
  478. assert "nothing enrolled" in out
  479. assert not (tmp_path / "speakers" / "speakers.json").exists()
  480. def test_questionary_review_defaults():
  481. import dataclasses
  482. review = QuestionaryReview()
  483. assert review.allow_play is True
  484. candidate = Candidate(Path("x.wav"), 0.5, 1.0)
  485. with pytest.raises(dataclasses.FrozenInstanceError):
  486. candidate.score = 0.1 # type: ignore[misc]
  487. # -- duplicate protection -------------------------------------------------------
  488. def _store_with(root: Path, profiles) -> SpeakerStore:
  489. store = SpeakerStore(root)
  490. for name, vecs in profiles:
  491. store.enroll(name, [np.array(v, dtype=np.float32) for v in vecs])
  492. return store
  493. def test_candidate_equality_ignores_embedding_and_flags():
  494. first = Candidate(Path("a.wav"), 0.9, 2.5,
  495. embedding=np.array([1.0, 0.0], dtype=np.float32),
  496. flags=("duplicate",))
  497. second = Candidate(Path("a.wav"), 0.9, 2.5,
  498. embedding=np.array([0.0, 1.0], dtype=np.float32),
  499. flags=())
  500. assert first == second
  501. assert hash(first) == hash(second)
  502. assert first != Candidate(Path("b.wav"), 0.9, 2.5)
  503. def test_rank_sets_unit_embeddings_without_store(tmp_path):
  504. seed = write_const_wav(tmp_path / "seed.wav", 2.5, A)
  505. clip = write_const_wav(tmp_path / "a.wav", 2.5, A)
  506. from stt_daemon.enroll import read_wav
  507. seed_audio, _ = read_wav(seed)
  508. ranked, _ = rank_candidates(ConstEmbedder(), seed_audio, [clip],
  509. min_enroll_s=2.0, min_score=0.55)
  510. assert len(ranked) == 1
  511. embedding = ranked[0].embedding
  512. assert embedding is not None
  513. assert embedding.dtype == np.float32
  514. assert float(np.linalg.norm(embedding)) == pytest.approx(1.0)
  515. assert ranked[0].flags == ()
  516. def test_rank_flags_duplicate_against_target(tmp_path):
  517. seed = write_const_wav(tmp_path / "seed.wav", 2.5, A)
  518. clip = write_const_wav(tmp_path / "a.wav", 2.5, A)
  519. from stt_daemon.enroll import read_wav
  520. seed_audio, _ = read_wav(seed)
  521. store = _store_with(tmp_path / "store", [("Efren", [[1.0, 0.0]])])
  522. ranked, _ = rank_candidates(ConstEmbedder(), seed_audio, [clip],
  523. min_enroll_s=2.0, min_score=0.55,
  524. store=store, speaker_name="Efren")
  525. assert ranked[0].flags == ("duplicate",)
  526. def test_rank_flags_best_other_speaker_by_display_name(tmp_path):
  527. seed = write_const_wav(tmp_path / "seed.wav", 2.5, A)
  528. clip = write_const_wav(tmp_path / "b.wav", 2.5, B)
  529. from stt_daemon.enroll import read_wav
  530. seed_audio, _ = read_wav(seed)
  531. store = _store_with(tmp_path / "store", [("Efren", [[1.0, 0.0]]),
  532. ("Rival", [[0.0, 1.0]]),
  533. ("Mid", [[0.7071, 0.7071]])])
  534. ranked, _ = rank_candidates(ConstEmbedder(), seed_audio, [clip],
  535. min_enroll_s=2.0, min_score=-1.0,
  536. store=store, speaker_name="Efren")
  537. assert ranked[0].flags == ("other-speaker:Rival",)
  538. def test_rank_no_flags_without_store_or_name(tmp_path):
  539. seed = write_const_wav(tmp_path / "seed.wav", 2.5, A)
  540. clip = write_const_wav(tmp_path / "a.wav", 2.5, A)
  541. from stt_daemon.enroll import read_wav
  542. seed_audio, _ = read_wav(seed)
  543. store = _store_with(tmp_path / "store", [("Efren", [[1.0, 0.0]])])
  544. ranked, _ = rank_candidates(ConstEmbedder(), seed_audio, [clip],
  545. min_enroll_s=2.0, min_score=0.55,
  546. store=store)
  547. assert ranked[0].flags == ()
  548. def test_review_refuses_duplicate_accept_without_consuming_budget(capsys):
  549. dup = Candidate(Path("dup.wav"), 0.99, 2.5, flags=("duplicate",))
  550. good = Candidate(Path("good.wav"), 0.9, 2.5)
  551. review = ScriptReview(["accept", "accept"])
  552. accepted = review_candidates([dup, good], review, 1, play=lambda p: None)
  553. assert accepted == [good]
  554. out = capsys.readouterr().out
  555. assert "dup.wav" in out
  556. assert "already covered" in out
  557. def test_seed_enroll_auto_dedupes_incoming_set(monkeypatch, tmp_path, capsys):
  558. cfg, audio_dir, _ = write_cfg(tmp_path)
  559. seed, _ = seed_and_clips(audio_dir)
  560. enrolled_log = record_enroll(monkeypatch)
  561. accepted = seed_enroll(cfg, ConstEmbedder(), make_store(cfg), "Efren",
  562. seed, count=2, auto=True)
  563. assert accepted == []
  564. assert [p.name for p in enrolled_log[0]] == ["seed.wav"]
  565. assert "duplicate" in capsys.readouterr().out
  566. def test_seed_enroll_review_path_dedupes_incoming_set(monkeypatch, tmp_path, capsys):
  567. cfg, audio_dir, _ = write_cfg(tmp_path)
  568. seed, _ = seed_and_clips(audio_dir)
  569. enrolled_log = record_enroll(monkeypatch)
  570. accepted = seed_enroll(cfg, ConstEmbedder(), make_store(cfg), "Efren",
  571. seed, review=ScriptReview(["accept", "accept"]))
  572. assert accepted == []
  573. assert [p.name for p in enrolled_log[0]] == ["seed.wav"]
  574. assert "duplicate" in capsys.readouterr().out
  575. def test_seed_enroll_review_vetoes_store_duplicate(tmp_path, capsys):
  576. cfg, audio_dir, store_dir = write_cfg(tmp_path)
  577. seed, _ = seed_and_clips(audio_dir)
  578. store = make_store(cfg)
  579. enroll_from_files(store, ConstEmbedder(), "Efren", [seed], 2.0)
  580. accepted = seed_enroll(cfg, ConstEmbedder(), store, "Efren", seed,
  581. review=ScriptReview(["accept"] * 5))
  582. assert accepted == []
  583. out = capsys.readouterr().out
  584. assert "already covered" in out
  585. assert "nothing enrolled" in out
  586. assert fresh_speakers(store_dir)["spk_0001"]["prototypes"] == 1
  587. def test_seed_enroll_removes_store_duplicates_and_saves(tmp_path, capsys):
  588. cfg, audio_dir, store_dir = write_cfg(tmp_path)
  589. seed, _ = seed_and_clips(audio_dir)
  590. store = make_store(cfg)
  591. enroll_from_files(store, ConstEmbedder(), "Efren",
  592. [seed, audio_dir / "a1.wav"], 2.0)
  593. saves: list = []
  594. real_save = store.save
  595. def counting_save():
  596. saves.append(1)
  597. return real_save()
  598. store.save = counting_save # type: ignore[method-assign]
  599. accepted = seed_enroll(cfg, VariedEmbedder(), store, "Efren", seed,
  600. count=2, auto=True)
  601. assert [c.path.name for c in accepted] == ["a1.wav", "a2.wav"]
  602. out = capsys.readouterr().out
  603. assert "removed 1 duplicate" in out
  604. assert len(saves) == 2 # enroll_from_files save + post-dedupe save
  605. assert (store_dir / "speakers.json").exists()
  606. assert fresh_speakers(store_dir)["spk_0001"]["prototypes"] == 4
  607. def test_cli_forwards_dedupe_and_other_thresholds(monkeypatch, tmp_path):
  608. audio_dir = _audio_with_clips(tmp_path)
  609. seed = audio_dir / "seed.wav"
  610. write_cfg(tmp_path)
  611. seen: dict = {}
  612. def fake_seed_enroll(cfg, embedder, store, name, seed_path, **kwargs):
  613. seen.update(kwargs)
  614. return [Candidate(audio_dir / "a1.wav", 0.99, 2.5)]
  615. monkeypatch.setattr(cli_module, "seed_enroll", fake_seed_enroll)
  616. fake = ConstEmbedder()
  617. class FakeSherpa:
  618. def __init__(self, model_path, provider="cpu"):
  619. pass
  620. def embed(self, audio):
  621. return fake.embed(audio)
  622. monkeypatch.setattr(cli_module, "SherpaEmbedder", FakeSherpa)
  623. args = argparse.Namespace(
  624. config=str(tmp_path / "config.toml"), name="Efren", seed=str(seed),
  625. count=2, min_score=0.55, audio_dir=str(audio_dir),
  626. max_scan=0, yes=True, no_play=True, force=True,
  627. dedupe_threshold=0.97, other_threshold=0.44,
  628. )
  629. out, err = io.StringIO(), io.StringIO()
  630. with redirect_stdout(out), redirect_stderr(err):
  631. rc = cli_module._handle_speaker_enroll_seed(args)
  632. assert rc == 0
  633. assert seen.get("dup_threshold") == pytest.approx(0.97)
  634. assert seen.get("other_threshold") == pytest.approx(0.44)