test_speakers.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763
  1. """Tests for the speaker prototype store, matcher, and enrollment guards.
  2. Deterministic synthetic vectors only: no ONNX, no network, no audio hardware.
  3. """
  4. import json
  5. import logging
  6. import wave
  7. from pathlib import Path
  8. import numpy as np
  9. import pytest
  10. import stt_daemon.speakers as speakers_module
  11. from stt_daemon.speakers import Match, Outcome, SpeakerStore
  12. class FakeClock:
  13. """Injectable monotonic-enough clock for cooldown/TTL tests."""
  14. def __init__(self, now: float = 1000.0) -> None:
  15. self.now = now
  16. def __call__(self) -> float:
  17. return self.now
  18. def advance(self, seconds: float) -> None:
  19. self.now += seconds
  20. def at_angle(degrees: float) -> np.ndarray:
  21. """Unit 2-D vector at ``degrees`` from the x axis."""
  22. radians = np.deg2rad(degrees)
  23. return np.array([np.cos(radians), np.sin(radians)], dtype=np.float32)
  24. def unit(x: float, y: float) -> np.ndarray:
  25. """Unit 2-D vector from raw components (cosine with [1, 0] equals x/norm)."""
  26. vector = np.array([x, y], dtype=np.float32)
  27. return vector / np.linalg.norm(vector)
  28. def make_store(tmp_path: Path, **kwargs) -> SpeakerStore:
  29. """Store with mint cooldown disabled by default so multi-mint tests are simple."""
  30. kwargs.setdefault("mint_cooldown_s", 0.0)
  31. return SpeakerStore(tmp_path, **kwargs)
  32. def mint(store: SpeakerStore, emb: np.ndarray, dur_s: float = 2.5, rms: float = 0.1,
  33. audio: np.ndarray | None = None) -> Outcome:
  34. """Feed two consecutive identical misses so the utterance mints a provisional."""
  35. store.process(emb, dur_s, rms)
  36. outcome = store.process(emb, dur_s, rms, audio=audio)
  37. assert outcome.event == "mint"
  38. return outcome
  39. # -- match thresholds ---------------------------------------------------------
  40. def test_match_reports_matched_above_threshold(tmp_path):
  41. store = make_store(tmp_path)
  42. mint(store, at_angle(0))
  43. match = store.match(unit(0.8, 0.6))
  44. assert match.status == "matched"
  45. assert match.speaker_id == "spk_0001"
  46. assert match.score == pytest.approx(0.8, abs=1e-4)
  47. assert match.top_id is None
  48. def test_match_reports_uncertain_between_thresholds(tmp_path):
  49. store = make_store(tmp_path)
  50. mint(store, at_angle(0))
  51. match = store.match(unit(0.5, 0.8660254))
  52. assert match.status == "uncertain"
  53. assert match.speaker_id is None
  54. assert match.top_id == "spk_0001"
  55. assert match.score == pytest.approx(0.5, abs=1e-4)
  56. def test_match_reports_unknown_below_threshold(tmp_path):
  57. store = make_store(tmp_path)
  58. mint(store, at_angle(0))
  59. match = store.match(unit(0.2, 0.9797959))
  60. assert match == Match(None, "unknown", None, None)
  61. def test_match_normalizes_scaled_embedding(tmp_path):
  62. store = make_store(tmp_path)
  63. mint(store, at_angle(0))
  64. match = store.match(np.array([7.0, 0.0], dtype=np.float32))
  65. assert match.status == "matched"
  66. assert match.score == pytest.approx(1.0, abs=1e-6)
  67. def test_match_with_empty_store_is_unknown(tmp_path):
  68. store = make_store(tmp_path)
  69. assert store.match(at_angle(0)) == Match(None, "unknown", None, None)
  70. def test_match_unusable_embedding_is_unknown(tmp_path):
  71. store = make_store(tmp_path)
  72. mint(store, at_angle(0))
  73. unusable = [
  74. np.zeros(2, dtype=np.float32),
  75. np.array([], dtype=np.float32),
  76. np.array([np.nan, 1.0], dtype=np.float32),
  77. ]
  78. for emb in unusable:
  79. assert store.match(emb) == Match(None, "unknown", None, None)
  80. def test_dimension_mismatch_is_unknown_and_warns_once(tmp_path, caplog):
  81. store = make_store(tmp_path)
  82. mint(store, at_angle(0))
  83. with caplog.at_level(logging.WARNING):
  84. first = store.match(np.ones(3, dtype=np.float32))
  85. store.match(np.ones(3, dtype=np.float32))
  86. assert first == Match(None, "unknown", None, None)
  87. assert len([r for r in caplog.records if r.levelno == logging.WARNING]) == 1
  88. def test_process_unusable_embedding_returns_unknown_without_event(tmp_path):
  89. store = make_store(tmp_path)
  90. outcome = store.process(np.zeros(2, dtype=np.float32), 3.0, 0.1)
  91. assert outcome == Outcome(Match(None, "unknown", None, None), None)
  92. assert store.process(None, 3.0, 0.1) == Outcome(Match(None, "unknown", None, None), None)
  93. assert store.speaker_ids() == []
  94. # -- mint guards --------------------------------------------------------------
  95. def test_single_unknown_does_not_mint(tmp_path):
  96. store = make_store(tmp_path)
  97. outcome = store.process(at_angle(0), 2.5, 0.1)
  98. assert outcome == Outcome(Match(None, "unknown", None, None), None)
  99. assert store.speaker_ids() == []
  100. def test_two_dissimilar_misses_do_not_mint(tmp_path):
  101. store = make_store(tmp_path)
  102. store.process(at_angle(0), 2.5, 0.1)
  103. outcome = store.process(at_angle(90), 2.5, 0.1)
  104. assert outcome.event is None
  105. assert store.speaker_ids() == []
  106. def test_two_similar_misses_mint_provisional(tmp_path):
  107. store = make_store(tmp_path)
  108. outcome = mint(store, at_angle(0), dur_s=3.0)
  109. assert outcome.match == Match("spk_0001", "unknown", None, None)
  110. row = store.snapshot()[0]
  111. assert row["id"] == "spk_0001"
  112. assert row["name"] is None
  113. assert row["status"] == "provisional"
  114. assert row["cumulative_s"] == pytest.approx(3.0)
  115. assert row["sample_path"] is None
  116. def test_mint_writes_16bit_mono_sample(tmp_path):
  117. store = make_store(tmp_path)
  118. mint(store, at_angle(0), audio=np.zeros(1600, dtype=np.float32))
  119. sample = tmp_path / "samples" / "spk_0001.wav"
  120. assert sample.exists()
  121. with wave.open(str(sample), "rb") as wav:
  122. assert wav.getnchannels() == 1
  123. assert wav.getsampwidth() == 2
  124. assert wav.getframerate() == 16000
  125. assert wav.getnframes() == 1600
  126. assert store.snapshot()[0]["sample_path"] == str(sample)
  127. def test_mint_requires_min_enroll_duration(tmp_path):
  128. store = make_store(tmp_path, min_enroll_s=2.0)
  129. store.process(at_angle(0), 1.0, 0.1)
  130. outcome = store.process(at_angle(0), 1.0, 0.1)
  131. assert outcome.event is None
  132. assert store.speaker_ids() == []
  133. outcome = store.process(at_angle(0), 2.5, 0.1)
  134. assert outcome.event == "mint"
  135. def test_mint_requires_rms_floor(tmp_path):
  136. store = make_store(tmp_path, rms_floor=0.05)
  137. store.process(at_angle(0), 2.5, 0.01)
  138. outcome = store.process(at_angle(0), 2.5, 0.01)
  139. assert outcome.event is None
  140. assert store.speaker_ids() == []
  141. outcome = store.process(at_angle(0), 2.5, 0.2)
  142. assert outcome.event == "mint"
  143. def test_mint_cooldown_blocks_second_speaker(tmp_path):
  144. clock = FakeClock()
  145. store = make_store(tmp_path, clock=clock, mint_cooldown_s=60.0)
  146. mint(store, at_angle(0))
  147. store.process(at_angle(90), 2.5, 0.1)
  148. outcome = store.process(at_angle(90), 2.5, 0.1)
  149. assert outcome.event is None
  150. assert store.speaker_ids() == ["spk_0001"]
  151. clock.advance(60.0)
  152. outcome = store.process(at_angle(90), 2.5, 0.1)
  153. assert outcome.event == "mint"
  154. assert store.speaker_ids() == ["spk_0001", "spk_0002"]
  155. def test_mint_blocked_when_best_two_are_within_margin(tmp_path):
  156. store = make_store(tmp_path)
  157. mint(store, np.array([1.0, 0.0, 0.0], dtype=np.float32))
  158. mint(store, np.array([0.0, 1.0, 0.0], dtype=np.float32))
  159. between = np.array([0.4, 0.38, np.sqrt(1 - 0.16 - 0.1444)], dtype=np.float32)
  160. store.process(between, 2.5, 0.1)
  161. outcome = store.process(between, 2.5, 0.1)
  162. assert outcome.event is None
  163. assert store.speaker_ids() == ["spk_0001", "spk_0002"]
  164. def test_mint_allowed_when_unknown_is_clearly_novel(tmp_path):
  165. store = make_store(tmp_path)
  166. mint(store, np.array([1.0, 0.0, 0.0], dtype=np.float32))
  167. mint(store, np.array([0.0, 1.0, 0.0], dtype=np.float32))
  168. novel = np.array([0.3, 0.1, np.sqrt(1 - 0.09 - 0.01)], dtype=np.float32)
  169. store.process(novel, 2.5, 0.1)
  170. outcome = store.process(novel, 2.5, 0.1)
  171. assert outcome.event == "mint"
  172. assert store.speaker_ids() == ["spk_0001", "spk_0002", "spk_0003"]
  173. def test_matched_result_resets_miss_buffer(tmp_path):
  174. store = make_store(tmp_path)
  175. mint(store, at_angle(0))
  176. store.process(at_angle(90), 2.5, 0.1)
  177. store.process(at_angle(10), 2.5, 0.1)
  178. outcome = store.process(at_angle(90), 2.5, 0.1)
  179. assert outcome.event is None
  180. assert store.speaker_ids() == ["spk_0001"]
  181. def test_uncertain_result_resets_miss_buffer(tmp_path):
  182. store = make_store(tmp_path)
  183. mint(store, at_angle(0))
  184. store.process(at_angle(90), 2.5, 0.1)
  185. store.process(unit(0.5, 0.8660254), 2.5, 0.1)
  186. outcome = store.process(at_angle(90), 2.5, 0.1)
  187. assert outcome.event is None
  188. assert store.speaker_ids() == ["spk_0001"]
  189. def test_uncertain_never_adapts(tmp_path):
  190. store = make_store(tmp_path)
  191. mint(store, at_angle(0))
  192. outcome = store.process(unit(0.5, 0.8660254), 2.5, 0.1)
  193. assert outcome.match.status == "uncertain"
  194. assert outcome.match.top_id == "spk_0001"
  195. assert outcome.event is None
  196. assert store.speakers["spk_0001"]["cumulative_s"] == pytest.approx(2.5)
  197. assert store.speakers["spk_0001"]["prototypes"] == 1
  198. # -- adapt guards, cap, and promotion -----------------------------------------
  199. def test_short_utterance_matches_but_does_not_adapt(tmp_path):
  200. store = make_store(tmp_path)
  201. mint(store, at_angle(0))
  202. outcome = store.process(at_angle(20), 1.0, 0.1)
  203. assert outcome.match.status == "matched"
  204. assert outcome.event is None
  205. assert store.speakers["spk_0001"]["cumulative_s"] == pytest.approx(2.5)
  206. assert store.speakers["spk_0001"]["prototypes"] == 1
  207. def test_quiet_utterance_matches_but_does_not_adapt(tmp_path):
  208. store = make_store(tmp_path, rms_floor=0.05)
  209. mint(store, at_angle(0), rms=0.2)
  210. outcome = store.process(at_angle(20), 2.5, 0.01)
  211. assert outcome.match.status == "matched"
  212. assert outcome.event is None
  213. assert store.speakers["spk_0001"]["prototypes"] == 1
  214. def test_adapt_caps_prototypes_and_replaces_oldest(tmp_path):
  215. store = make_store(tmp_path, max_prototypes=3)
  216. mint(store, at_angle(0))
  217. for angle in (20, 40, 60):
  218. outcome = store.process(at_angle(angle), 2.5, 0.1)
  219. assert outcome.event == "adapt"
  220. assert store._proto_ids == ["spk_0001", "spk_0001", "spk_0001"]
  221. assert store.speakers["spk_0001"]["prototypes"] == 3
  222. angles = [
  223. float(np.rad2deg(np.arctan2(row[1], row[0]))) for row in store._vectors
  224. ]
  225. assert angles == pytest.approx([20, 40, 60], abs=0.05)
  226. def test_provisional_promotes_on_cumulative_seconds(tmp_path):
  227. store = make_store(tmp_path, confirm_s=5.0, min_enroll_s=1.0)
  228. mint(store, at_angle(0), dur_s=2.0)
  229. first = store.process(at_angle(20), 2.0, 0.1)
  230. assert first.event == "adapt"
  231. assert store.speakers["spk_0001"]["status"] == "provisional"
  232. second = store.process(at_angle(40), 2.0, 0.1)
  233. assert second.match.status == "matched"
  234. assert second.event == "confirm"
  235. assert store.speakers["spk_0001"]["status"] == "confirmed"
  236. assert store.speakers["spk_0001"]["cumulative_s"] == pytest.approx(6.0)
  237. third = store.process(at_angle(60), 2.0, 0.1)
  238. assert third.event == "adapt"
  239. assert store.speakers["spk_0001"]["status"] == "confirmed"
  240. # -- prune, rename, snapshot --------------------------------------------------
  241. def test_prune_removes_starved_provisional_and_sample(tmp_path):
  242. clock = FakeClock(1000.0)
  243. store = make_store(tmp_path, clock=clock, provisional_ttl_h=24.0, confirm_s=1.0)
  244. audio = np.zeros(1600, dtype=np.float32)
  245. mint(store, at_angle(0), dur_s=2.0, audio=audio)
  246. mint(store, at_angle(90), dur_s=2.0)
  247. store.process(at_angle(90), 2.0, 0.1)
  248. sample = tmp_path / "samples" / "spk_0001.wav"
  249. assert sample.exists()
  250. removed = store.prune(now=1000.0 + 25 * 3600)
  251. assert removed == ["spk_0001"]
  252. assert not sample.exists()
  253. assert store.speaker_ids() == ["spk_0002"]
  254. assert store.speakers["spk_0002"]["status"] == "confirmed"
  255. assert "spk_0001" not in store._proto_ids
  256. def test_prune_keeps_fresh_provisional(tmp_path):
  257. clock = FakeClock(1000.0)
  258. store = make_store(tmp_path, clock=clock, provisional_ttl_h=24.0)
  259. mint(store, at_angle(0))
  260. assert store.prune() == []
  261. assert store.speaker_ids() == ["spk_0001"]
  262. def test_load_does_not_prune(tmp_path):
  263. store = make_store(tmp_path, clock=FakeClock(1000.0))
  264. mint(store, at_angle(0))
  265. store.save()
  266. later = make_store(tmp_path, clock=FakeClock(1000.0 + 25 * 3600))
  267. later.load()
  268. assert later.speaker_ids() == ["spk_0001"]
  269. assert later.prune() == ["spk_0001"]
  270. def test_rename_unknown_id_returns_false(tmp_path):
  271. store = make_store(tmp_path)
  272. assert store.rename("spk_9999", "Nobody") is False
  273. assert not (tmp_path / "speakers.json").exists()
  274. def test_rename_names_speaker_and_deletes_sample(tmp_path):
  275. store = make_store(tmp_path)
  276. mint(store, at_angle(0), audio=np.zeros(1600, dtype=np.float32))
  277. sample = tmp_path / "samples" / "spk_0001.wav"
  278. assert sample.exists()
  279. renamed = store.rename("spk_0001", "Efren")
  280. assert renamed is True
  281. assert not sample.exists()
  282. assert store.snapshot()[0]["name"] == "Efren"
  283. assert store.snapshot()[0]["sample_path"] is None
  284. reloaded = SpeakerStore(tmp_path)
  285. reloaded.load()
  286. assert reloaded.snapshot()[0]["name"] == "Efren"
  287. def test_snapshot_shape_and_sorting(tmp_path):
  288. store = make_store(tmp_path)
  289. mint(store, at_angle(0))
  290. mint(store, at_angle(90))
  291. snapshot = store.snapshot()
  292. assert store.root == tmp_path
  293. assert [row["id"] for row in snapshot] == ["spk_0001", "spk_0002"]
  294. assert set(snapshot[0]) == {
  295. "id",
  296. "name",
  297. "status",
  298. "cumulative_s",
  299. "sample_path",
  300. "created_at",
  301. }
  302. assert snapshot[0]["cumulative_s"] == pytest.approx(2.5)
  303. assert isinstance(snapshot[0]["created_at"], float)
  304. # -- persistence --------------------------------------------------------------
  305. def test_save_load_round_trips_metadata_and_prototypes(tmp_path):
  306. store = make_store(tmp_path)
  307. mint(store, at_angle(0))
  308. store.process(at_angle(20), 2.5, 0.1)
  309. store.save()
  310. reloaded = make_store(tmp_path)
  311. reloaded.load()
  312. assert reloaded.snapshot() == store.snapshot()
  313. assert reloaded.speaker_ids() == ["spk_0001"]
  314. assert reloaded._vectors.shape == store._vectors.shape
  315. assert np.allclose(reloaded._vectors, store._vectors)
  316. assert reloaded.match(at_angle(0)).status == "matched"
  317. def test_load_continues_speaker_numbering(tmp_path):
  318. store = make_store(tmp_path)
  319. mint(store, at_angle(0))
  320. store.save()
  321. reloaded = make_store(tmp_path)
  322. reloaded.load()
  323. outcome = mint(reloaded, at_angle(90))
  324. assert outcome.match.speaker_id == "spk_0002"
  325. def test_failed_save_preserves_targets_and_cleans_tmp(tmp_path, monkeypatch):
  326. store = make_store(tmp_path)
  327. mint(store, at_angle(0))
  328. store.save()
  329. before_json = (tmp_path / "speakers.json").read_bytes()
  330. before_npz = (tmp_path / "prototypes.npz").read_bytes()
  331. def crash(src, dst):
  332. raise OSError("simulated crash")
  333. monkeypatch.setattr(speakers_module.os, "replace", crash)
  334. with pytest.raises(OSError):
  335. store.save()
  336. assert list(tmp_path.glob(".speakers-*.tmp")) == []
  337. assert (tmp_path / "speakers.json").read_bytes() == before_json
  338. assert (tmp_path / "prototypes.npz").read_bytes() == before_npz
  339. fresh = SpeakerStore(tmp_path)
  340. fresh.load()
  341. assert fresh.speaker_ids() == ["spk_0001"]
  342. def test_load_ignores_stray_tmp_file(tmp_path):
  343. store = make_store(tmp_path)
  344. mint(store, at_angle(0))
  345. store.save()
  346. (tmp_path / ".speakers-leftover.tmp").write_text("half-written")
  347. reloaded = SpeakerStore(tmp_path)
  348. reloaded.load()
  349. assert reloaded.speaker_ids() == ["spk_0001"]
  350. def test_load_corrupt_json_starts_empty(tmp_path, caplog):
  351. (tmp_path / "speakers.json").write_text("{not json")
  352. store = SpeakerStore(tmp_path)
  353. with caplog.at_level(logging.WARNING):
  354. store.load()
  355. assert store.speaker_ids() == []
  356. assert store.match(at_angle(0)) == Match(None, "unknown", None, None)
  357. assert len([r for r in caplog.records if r.levelno == logging.WARNING]) == 1
  358. def test_load_corrupt_npz_starts_empty(tmp_path, caplog):
  359. store = make_store(tmp_path)
  360. mint(store, at_angle(0))
  361. store.save()
  362. (tmp_path / "prototypes.npz").write_bytes(b"garbage")
  363. fresh = SpeakerStore(tmp_path)
  364. with caplog.at_level(logging.WARNING):
  365. fresh.load()
  366. assert fresh.speaker_ids() == []
  367. assert any(r.levelno == logging.WARNING for r in caplog.records)
  368. def test_store_root_samples_dir_and_wav_are_private(tmp_path):
  369. store = make_store(tmp_path)
  370. mint(store, at_angle(0), audio=np.zeros(1600, dtype=np.float32))
  371. store.save()
  372. speaker_id = store.speaker_ids()[0]
  373. wav = tmp_path / "samples" / f"{speaker_id}.wav"
  374. assert wav.exists()
  375. assert (tmp_path.stat().st_mode & 0o077) == 0
  376. assert ((tmp_path / "samples").stat().st_mode & 0o077) == 0
  377. assert (wav.stat().st_mode & 0o077) == 0
  378. def test_sample_escape_dropped_on_load_without_unlink(tmp_path, caplog):
  379. store = make_store(tmp_path)
  380. mint(store, at_angle(0))
  381. store.save()
  382. outside = tmp_path / "outside.wav"
  383. outside.write_bytes(b"sentinel")
  384. meta_path = tmp_path / "speakers.json"
  385. meta = json.loads(meta_path.read_text(encoding="utf-8"))
  386. speaker_id = next(iter(meta["speakers"]))
  387. meta["speakers"][speaker_id]["sample"] = "../outside.wav"
  388. meta_path.write_text(json.dumps(meta), encoding="utf-8")
  389. fresh = SpeakerStore(tmp_path)
  390. with caplog.at_level(logging.WARNING):
  391. fresh.load()
  392. assert fresh.speakers[speaker_id]["sample"] is None
  393. assert outside.read_bytes() == b"sentinel" # never unlinked outside the root
  394. assert "sample" in caplog.text.lower() or "store" in caplog.text.lower()
  395. # -- offline enrollment --------------------------------------------------------
  396. def test_enroll_creates_confirmed_named_profile(tmp_path):
  397. store = make_store(tmp_path)
  398. speaker_id = store.enroll("Efren", [at_angle(0), at_angle(10)], dur_s=7.4)
  399. assert speaker_id == "spk_0001"
  400. assert store.speaker_ids() == ["spk_0001"]
  401. meta = store.speakers[speaker_id]
  402. assert meta["name"] == "Efren"
  403. assert meta["status"] == "confirmed"
  404. assert meta["cumulative_s"] == pytest.approx(7.4)
  405. assert meta["prototypes"] == 2
  406. snapshot = store.snapshot()
  407. assert snapshot[0]["id"] == "spk_0001"
  408. assert snapshot[0]["name"] == "Efren"
  409. assert snapshot[0]["status"] == "confirmed"
  410. def test_enroll_same_name_reuses_id_and_appends(tmp_path):
  411. store = make_store(tmp_path)
  412. first = store.enroll("Efren", [at_angle(0)], dur_s=2.0)
  413. second = store.enroll("Efren", [at_angle(10)], dur_s=3.0)
  414. assert second == first
  415. assert store.speaker_ids() == [first]
  416. meta = store.speakers[first]
  417. assert meta["prototypes"] == 2
  418. assert meta["cumulative_s"] == pytest.approx(5.0)
  419. def test_enroll_respects_prototype_cap(tmp_path):
  420. store = make_store(tmp_path, max_prototypes=2)
  421. speaker_id = store.enroll(
  422. "Efren", [at_angle(0), at_angle(10), at_angle(20)], dur_s=6.0
  423. )
  424. assert store.speakers[speaker_id]["prototypes"] == 2
  425. assert store.speakers[speaker_id]["cumulative_s"] == pytest.approx(6.0)
  426. def test_enroll_round_trips_through_save_load(tmp_path):
  427. store = make_store(tmp_path)
  428. speaker_id = store.enroll("Efren", [at_angle(0)], dur_s=2.5)
  429. store.save()
  430. fresh = SpeakerStore(tmp_path)
  431. fresh.load()
  432. assert fresh.speaker_ids() == [speaker_id]
  433. assert fresh.speakers[speaker_id]["name"] == "Efren"
  434. assert fresh.speakers[speaker_id]["status"] == "confirmed"
  435. match = fresh.match(at_angle(0))
  436. assert match.status == "matched"
  437. assert match.speaker_id == speaker_id
  438. def test_enroll_empty_name_raises(tmp_path):
  439. store = make_store(tmp_path)
  440. with pytest.raises(ValueError):
  441. store.enroll(" ", [at_angle(0)], dur_s=1.0)
  442. def test_enroll_promotes_existing_named_profile_to_confirmed(tmp_path):
  443. store = make_store(tmp_path)
  444. mint(store, at_angle(0))
  445. speaker_id = store.speaker_ids()[0]
  446. assert store.speakers[speaker_id]["status"] == "provisional"
  447. store.rename(speaker_id, "Efren")
  448. reused = store.enroll("Efren", [at_angle(5)], dur_s=1.0)
  449. assert reused == speaker_id
  450. assert store.speakers[speaker_id]["status"] == "confirmed"
  451. assert store.speakers[speaker_id]["name"] == "Efren"
  452. # -- dedupe -------------------------------------------------------------------
  453. def test_dedupe_removes_exact_duplicate_and_keeps_first(tmp_path):
  454. store = make_store(tmp_path)
  455. speaker_id = store.enroll("Efren", [at_angle(0), at_angle(0), at_angle(90)])
  456. removed = store.dedupe(speaker_id)
  457. assert removed == 1
  458. assert store._proto_ids == [speaker_id, speaker_id]
  459. angles = [float(np.rad2deg(np.arctan2(row[1], row[0]))) for row in store._vectors]
  460. assert angles == pytest.approx([0, 90], abs=0.05)
  461. assert store.speakers[speaker_id]["prototypes"] == 2
  462. def test_dedupe_removes_near_duplicates_at_default_threshold(tmp_path):
  463. store = make_store(tmp_path)
  464. speaker_id = store.enroll("Efren", [at_angle(0), at_angle(5), at_angle(40)])
  465. removed = store.dedupe(speaker_id)
  466. assert removed == 1 # cos(5deg) ~= 0.996 >= 0.99; cos(40deg) ~= 0.77 kept
  467. assert store.speakers[speaker_id]["prototypes"] == 2
  468. def test_dedupe_threshold_controls_aggressiveness(tmp_path):
  469. store = make_store(tmp_path)
  470. speaker_id = store.enroll("Efren", [at_angle(0), at_angle(20)])
  471. assert store.dedupe(speaker_id) == 0 # cos(20deg) ~= 0.94 < 0.99
  472. assert store.speakers[speaker_id]["prototypes"] == 2
  473. assert store.dedupe(speaker_id, threshold=0.9) == 1
  474. assert store.speakers[speaker_id]["prototypes"] == 1
  475. def test_dedupe_none_covers_all_speakers(tmp_path):
  476. store = make_store(tmp_path)
  477. first = store.enroll("Efren", [at_angle(0), at_angle(0)])
  478. second = store.enroll("Rival", [at_angle(90), at_angle(90)])
  479. removed = store.dedupe()
  480. assert removed == 2
  481. assert store.speakers[first]["prototypes"] == 1
  482. assert store.speakers[second]["prototypes"] == 1
  483. assert store.match(at_angle(0)).speaker_id == first
  484. assert store.match(at_angle(90)).speaker_id == second
  485. def test_dedupe_single_id_leaves_other_speakers_untouched(tmp_path):
  486. store = make_store(tmp_path)
  487. first = store.enroll("Efren", [at_angle(0), at_angle(0)])
  488. second = store.enroll("Rival", [at_angle(90), at_angle(90)])
  489. removed = store.dedupe(first)
  490. assert removed == 1
  491. assert store.speakers[first]["prototypes"] == 1
  492. assert store.speakers[second]["prototypes"] == 2
  493. assert store._proto_ids.count(second) == 2
  494. def test_dedupe_accepts_display_name(tmp_path):
  495. store = make_store(tmp_path)
  496. store.enroll("Efren", [at_angle(0), at_angle(0)])
  497. removed = store.dedupe("Efren")
  498. assert removed == 1
  499. assert store.speakers["spk_0001"]["prototypes"] == 1
  500. def test_dedupe_empty_store_and_unknown_id_return_zero(tmp_path):
  501. store = make_store(tmp_path)
  502. assert store.dedupe() == 0
  503. assert store.dedupe("spk_9999") == 0
  504. assert store.dedupe("Nobody") == 0
  505. assert store.dedupe(None, threshold=0.5) == 0
  506. store.enroll("Efren", [at_angle(0)])
  507. assert store.dedupe("spk_9999") == 0
  508. assert store.speakers["spk_0001"]["prototypes"] == 1
  509. def test_dedupe_does_not_save(tmp_path):
  510. store = make_store(tmp_path)
  511. speaker_id = store.enroll("Efren", [at_angle(0), at_angle(0)])
  512. removed = store.dedupe(speaker_id)
  513. assert removed == 1
  514. assert not (tmp_path / "speakers.json").exists()
  515. assert not (tmp_path / "prototypes.npz").exists()
  516. def test_dedupe_keeps_at_least_one_prototype(tmp_path):
  517. store = make_store(tmp_path)
  518. speaker_id = store.enroll("Efren", [at_angle(0), at_angle(0), at_angle(0)])
  519. removed = store.dedupe(speaker_id, threshold=0.0)
  520. assert removed == 2
  521. assert store._proto_ids == [speaker_id]
  522. assert store.speakers[speaker_id]["prototypes"] == 1
  523. assert store.match(at_angle(0)).status == "matched"