"""Tests for the speaker prototype store, matcher, and enrollment guards. Deterministic synthetic vectors only: no ONNX, no network, no audio hardware. """ import json import logging import wave from pathlib import Path import numpy as np import pytest import stt_daemon.speakers as speakers_module from stt_daemon.speakers import Match, Outcome, SpeakerStore class FakeClock: """Injectable monotonic-enough clock for cooldown/TTL tests.""" def __init__(self, now: float = 1000.0) -> None: self.now = now def __call__(self) -> float: return self.now def advance(self, seconds: float) -> None: self.now += seconds def at_angle(degrees: float) -> np.ndarray: """Unit 2-D vector at ``degrees`` from the x axis.""" radians = np.deg2rad(degrees) return np.array([np.cos(radians), np.sin(radians)], dtype=np.float32) def unit(x: float, y: float) -> np.ndarray: """Unit 2-D vector from raw components (cosine with [1, 0] equals x/norm).""" vector = np.array([x, y], dtype=np.float32) return vector / np.linalg.norm(vector) def make_store(tmp_path: Path, **kwargs) -> SpeakerStore: """Store with mint cooldown disabled by default so multi-mint tests are simple.""" kwargs.setdefault("mint_cooldown_s", 0.0) return SpeakerStore(tmp_path, **kwargs) def mint(store: SpeakerStore, emb: np.ndarray, dur_s: float = 2.5, rms: float = 0.1, audio: np.ndarray | None = None) -> Outcome: """Feed two consecutive identical misses so the utterance mints a provisional.""" store.process(emb, dur_s, rms) outcome = store.process(emb, dur_s, rms, audio=audio) assert outcome.event == "mint" return outcome # -- match thresholds --------------------------------------------------------- def test_match_reports_matched_above_threshold(tmp_path): store = make_store(tmp_path) mint(store, at_angle(0)) match = store.match(unit(0.8, 0.6)) assert match.status == "matched" assert match.speaker_id == "spk_0001" assert match.score == pytest.approx(0.8, abs=1e-4) assert match.top_id is None def test_match_reports_uncertain_between_thresholds(tmp_path): store = make_store(tmp_path) mint(store, at_angle(0)) match = store.match(unit(0.5, 0.8660254)) assert match.status == "uncertain" assert match.speaker_id is None assert match.top_id == "spk_0001" assert match.score == pytest.approx(0.5, abs=1e-4) def test_match_reports_unknown_below_threshold(tmp_path): store = make_store(tmp_path) mint(store, at_angle(0)) match = store.match(unit(0.2, 0.9797959)) assert match == Match(None, "unknown", None, None) def test_match_normalizes_scaled_embedding(tmp_path): store = make_store(tmp_path) mint(store, at_angle(0)) match = store.match(np.array([7.0, 0.0], dtype=np.float32)) assert match.status == "matched" assert match.score == pytest.approx(1.0, abs=1e-6) def test_match_with_empty_store_is_unknown(tmp_path): store = make_store(tmp_path) assert store.match(at_angle(0)) == Match(None, "unknown", None, None) def test_match_unusable_embedding_is_unknown(tmp_path): store = make_store(tmp_path) mint(store, at_angle(0)) unusable = [ np.zeros(2, dtype=np.float32), np.array([], dtype=np.float32), np.array([np.nan, 1.0], dtype=np.float32), ] for emb in unusable: assert store.match(emb) == Match(None, "unknown", None, None) def test_dimension_mismatch_is_unknown_and_warns_once(tmp_path, caplog): store = make_store(tmp_path) mint(store, at_angle(0)) with caplog.at_level(logging.WARNING): first = store.match(np.ones(3, dtype=np.float32)) store.match(np.ones(3, dtype=np.float32)) assert first == Match(None, "unknown", None, None) assert len([r for r in caplog.records if r.levelno == logging.WARNING]) == 1 def test_process_unusable_embedding_returns_unknown_without_event(tmp_path): store = make_store(tmp_path) outcome = store.process(np.zeros(2, dtype=np.float32), 3.0, 0.1) assert outcome == Outcome(Match(None, "unknown", None, None), None) assert store.process(None, 3.0, 0.1) == Outcome(Match(None, "unknown", None, None), None) assert store.speaker_ids() == [] # -- mint guards -------------------------------------------------------------- def test_single_unknown_does_not_mint(tmp_path): store = make_store(tmp_path) outcome = store.process(at_angle(0), 2.5, 0.1) assert outcome == Outcome(Match(None, "unknown", None, None), None) assert store.speaker_ids() == [] def test_two_dissimilar_misses_do_not_mint(tmp_path): store = make_store(tmp_path) store.process(at_angle(0), 2.5, 0.1) outcome = store.process(at_angle(90), 2.5, 0.1) assert outcome.event is None assert store.speaker_ids() == [] def test_two_similar_misses_mint_provisional(tmp_path): store = make_store(tmp_path) outcome = mint(store, at_angle(0), dur_s=3.0) assert outcome.match == Match("spk_0001", "unknown", None, None) row = store.snapshot()[0] assert row["id"] == "spk_0001" assert row["name"] is None assert row["status"] == "provisional" assert row["cumulative_s"] == pytest.approx(3.0) assert row["sample_path"] is None def test_mint_writes_16bit_mono_sample(tmp_path): store = make_store(tmp_path) mint(store, at_angle(0), audio=np.zeros(1600, dtype=np.float32)) sample = tmp_path / "samples" / "spk_0001.wav" assert sample.exists() with wave.open(str(sample), "rb") as wav: assert wav.getnchannels() == 1 assert wav.getsampwidth() == 2 assert wav.getframerate() == 16000 assert wav.getnframes() == 1600 assert store.snapshot()[0]["sample_path"] == str(sample) def test_mint_requires_min_enroll_duration(tmp_path): store = make_store(tmp_path, min_enroll_s=2.0) store.process(at_angle(0), 1.0, 0.1) outcome = store.process(at_angle(0), 1.0, 0.1) assert outcome.event is None assert store.speaker_ids() == [] outcome = store.process(at_angle(0), 2.5, 0.1) assert outcome.event == "mint" def test_mint_requires_rms_floor(tmp_path): store = make_store(tmp_path, rms_floor=0.05) store.process(at_angle(0), 2.5, 0.01) outcome = store.process(at_angle(0), 2.5, 0.01) assert outcome.event is None assert store.speaker_ids() == [] outcome = store.process(at_angle(0), 2.5, 0.2) assert outcome.event == "mint" def test_mint_cooldown_blocks_second_speaker(tmp_path): clock = FakeClock() store = make_store(tmp_path, clock=clock, mint_cooldown_s=60.0) mint(store, at_angle(0)) store.process(at_angle(90), 2.5, 0.1) outcome = store.process(at_angle(90), 2.5, 0.1) assert outcome.event is None assert store.speaker_ids() == ["spk_0001"] clock.advance(60.0) outcome = store.process(at_angle(90), 2.5, 0.1) assert outcome.event == "mint" assert store.speaker_ids() == ["spk_0001", "spk_0002"] def test_mint_blocked_when_best_two_are_within_margin(tmp_path): store = make_store(tmp_path) mint(store, np.array([1.0, 0.0, 0.0], dtype=np.float32)) mint(store, np.array([0.0, 1.0, 0.0], dtype=np.float32)) between = np.array([0.4, 0.38, np.sqrt(1 - 0.16 - 0.1444)], dtype=np.float32) store.process(between, 2.5, 0.1) outcome = store.process(between, 2.5, 0.1) assert outcome.event is None assert store.speaker_ids() == ["spk_0001", "spk_0002"] def test_mint_allowed_when_unknown_is_clearly_novel(tmp_path): store = make_store(tmp_path) mint(store, np.array([1.0, 0.0, 0.0], dtype=np.float32)) mint(store, np.array([0.0, 1.0, 0.0], dtype=np.float32)) novel = np.array([0.3, 0.1, np.sqrt(1 - 0.09 - 0.01)], dtype=np.float32) store.process(novel, 2.5, 0.1) outcome = store.process(novel, 2.5, 0.1) assert outcome.event == "mint" assert store.speaker_ids() == ["spk_0001", "spk_0002", "spk_0003"] def test_matched_result_resets_miss_buffer(tmp_path): store = make_store(tmp_path) mint(store, at_angle(0)) store.process(at_angle(90), 2.5, 0.1) store.process(at_angle(10), 2.5, 0.1) outcome = store.process(at_angle(90), 2.5, 0.1) assert outcome.event is None assert store.speaker_ids() == ["spk_0001"] def test_uncertain_result_resets_miss_buffer(tmp_path): store = make_store(tmp_path) mint(store, at_angle(0)) store.process(at_angle(90), 2.5, 0.1) store.process(unit(0.5, 0.8660254), 2.5, 0.1) outcome = store.process(at_angle(90), 2.5, 0.1) assert outcome.event is None assert store.speaker_ids() == ["spk_0001"] def test_uncertain_never_adapts(tmp_path): store = make_store(tmp_path) mint(store, at_angle(0)) outcome = store.process(unit(0.5, 0.8660254), 2.5, 0.1) assert outcome.match.status == "uncertain" assert outcome.match.top_id == "spk_0001" assert outcome.event is None assert store.speakers["spk_0001"]["cumulative_s"] == pytest.approx(2.5) assert store.speakers["spk_0001"]["prototypes"] == 1 # -- adapt guards, cap, and promotion ----------------------------------------- def test_short_utterance_matches_but_does_not_adapt(tmp_path): store = make_store(tmp_path) mint(store, at_angle(0)) outcome = store.process(at_angle(20), 1.0, 0.1) assert outcome.match.status == "matched" assert outcome.event is None assert store.speakers["spk_0001"]["cumulative_s"] == pytest.approx(2.5) assert store.speakers["spk_0001"]["prototypes"] == 1 def test_quiet_utterance_matches_but_does_not_adapt(tmp_path): store = make_store(tmp_path, rms_floor=0.05) mint(store, at_angle(0), rms=0.2) outcome = store.process(at_angle(20), 2.5, 0.01) assert outcome.match.status == "matched" assert outcome.event is None assert store.speakers["spk_0001"]["prototypes"] == 1 def test_adapt_caps_prototypes_and_replaces_oldest(tmp_path): store = make_store(tmp_path, max_prototypes=3) mint(store, at_angle(0)) for angle in (20, 40, 60): outcome = store.process(at_angle(angle), 2.5, 0.1) assert outcome.event == "adapt" assert store._proto_ids == ["spk_0001", "spk_0001", "spk_0001"] assert store.speakers["spk_0001"]["prototypes"] == 3 angles = [ float(np.rad2deg(np.arctan2(row[1], row[0]))) for row in store._vectors ] assert angles == pytest.approx([20, 40, 60], abs=0.05) def test_provisional_promotes_on_cumulative_seconds(tmp_path): store = make_store(tmp_path, confirm_s=5.0, min_enroll_s=1.0) mint(store, at_angle(0), dur_s=2.0) first = store.process(at_angle(20), 2.0, 0.1) assert first.event == "adapt" assert store.speakers["spk_0001"]["status"] == "provisional" second = store.process(at_angle(40), 2.0, 0.1) assert second.match.status == "matched" assert second.event == "confirm" assert store.speakers["spk_0001"]["status"] == "confirmed" assert store.speakers["spk_0001"]["cumulative_s"] == pytest.approx(6.0) third = store.process(at_angle(60), 2.0, 0.1) assert third.event == "adapt" assert store.speakers["spk_0001"]["status"] == "confirmed" # -- prune, rename, snapshot -------------------------------------------------- def test_prune_removes_starved_provisional_and_sample(tmp_path): clock = FakeClock(1000.0) store = make_store(tmp_path, clock=clock, provisional_ttl_h=24.0, confirm_s=1.0) audio = np.zeros(1600, dtype=np.float32) mint(store, at_angle(0), dur_s=2.0, audio=audio) mint(store, at_angle(90), dur_s=2.0) store.process(at_angle(90), 2.0, 0.1) sample = tmp_path / "samples" / "spk_0001.wav" assert sample.exists() removed = store.prune(now=1000.0 + 25 * 3600) assert removed == ["spk_0001"] assert not sample.exists() assert store.speaker_ids() == ["spk_0002"] assert store.speakers["spk_0002"]["status"] == "confirmed" assert "spk_0001" not in store._proto_ids def test_prune_keeps_fresh_provisional(tmp_path): clock = FakeClock(1000.0) store = make_store(tmp_path, clock=clock, provisional_ttl_h=24.0) mint(store, at_angle(0)) assert store.prune() == [] assert store.speaker_ids() == ["spk_0001"] def test_load_does_not_prune(tmp_path): store = make_store(tmp_path, clock=FakeClock(1000.0)) mint(store, at_angle(0)) store.save() later = make_store(tmp_path, clock=FakeClock(1000.0 + 25 * 3600)) later.load() assert later.speaker_ids() == ["spk_0001"] assert later.prune() == ["spk_0001"] def test_rename_unknown_id_returns_false(tmp_path): store = make_store(tmp_path) assert store.rename("spk_9999", "Nobody") is False assert not (tmp_path / "speakers.json").exists() def test_rename_names_speaker_and_deletes_sample(tmp_path): store = make_store(tmp_path) mint(store, at_angle(0), audio=np.zeros(1600, dtype=np.float32)) sample = tmp_path / "samples" / "spk_0001.wav" assert sample.exists() renamed = store.rename("spk_0001", "Efren") assert renamed is True assert not sample.exists() assert store.snapshot()[0]["name"] == "Efren" assert store.snapshot()[0]["sample_path"] is None reloaded = SpeakerStore(tmp_path) reloaded.load() assert reloaded.snapshot()[0]["name"] == "Efren" def test_snapshot_shape_and_sorting(tmp_path): store = make_store(tmp_path) mint(store, at_angle(0)) mint(store, at_angle(90)) snapshot = store.snapshot() assert store.root == tmp_path assert [row["id"] for row in snapshot] == ["spk_0001", "spk_0002"] assert set(snapshot[0]) == { "id", "name", "status", "cumulative_s", "sample_path", "created_at", } assert snapshot[0]["cumulative_s"] == pytest.approx(2.5) assert isinstance(snapshot[0]["created_at"], float) # -- persistence -------------------------------------------------------------- def test_save_load_round_trips_metadata_and_prototypes(tmp_path): store = make_store(tmp_path) mint(store, at_angle(0)) store.process(at_angle(20), 2.5, 0.1) store.save() reloaded = make_store(tmp_path) reloaded.load() assert reloaded.snapshot() == store.snapshot() assert reloaded.speaker_ids() == ["spk_0001"] assert reloaded._vectors.shape == store._vectors.shape assert np.allclose(reloaded._vectors, store._vectors) assert reloaded.match(at_angle(0)).status == "matched" def test_load_continues_speaker_numbering(tmp_path): store = make_store(tmp_path) mint(store, at_angle(0)) store.save() reloaded = make_store(tmp_path) reloaded.load() outcome = mint(reloaded, at_angle(90)) assert outcome.match.speaker_id == "spk_0002" def test_failed_save_preserves_targets_and_cleans_tmp(tmp_path, monkeypatch): store = make_store(tmp_path) mint(store, at_angle(0)) store.save() before_json = (tmp_path / "speakers.json").read_bytes() before_npz = (tmp_path / "prototypes.npz").read_bytes() def crash(src, dst): raise OSError("simulated crash") monkeypatch.setattr(speakers_module.os, "replace", crash) with pytest.raises(OSError): store.save() assert list(tmp_path.glob(".speakers-*.tmp")) == [] assert (tmp_path / "speakers.json").read_bytes() == before_json assert (tmp_path / "prototypes.npz").read_bytes() == before_npz fresh = SpeakerStore(tmp_path) fresh.load() assert fresh.speaker_ids() == ["spk_0001"] def test_load_ignores_stray_tmp_file(tmp_path): store = make_store(tmp_path) mint(store, at_angle(0)) store.save() (tmp_path / ".speakers-leftover.tmp").write_text("half-written") reloaded = SpeakerStore(tmp_path) reloaded.load() assert reloaded.speaker_ids() == ["spk_0001"] def test_load_corrupt_json_starts_empty(tmp_path, caplog): (tmp_path / "speakers.json").write_text("{not json") store = SpeakerStore(tmp_path) with caplog.at_level(logging.WARNING): store.load() assert store.speaker_ids() == [] assert store.match(at_angle(0)) == Match(None, "unknown", None, None) assert len([r for r in caplog.records if r.levelno == logging.WARNING]) == 1 def test_load_corrupt_npz_starts_empty(tmp_path, caplog): store = make_store(tmp_path) mint(store, at_angle(0)) store.save() (tmp_path / "prototypes.npz").write_bytes(b"garbage") fresh = SpeakerStore(tmp_path) with caplog.at_level(logging.WARNING): fresh.load() assert fresh.speaker_ids() == [] assert any(r.levelno == logging.WARNING for r in caplog.records) def test_store_root_samples_dir_and_wav_are_private(tmp_path): store = make_store(tmp_path) mint(store, at_angle(0), audio=np.zeros(1600, dtype=np.float32)) store.save() speaker_id = store.speaker_ids()[0] wav = tmp_path / "samples" / f"{speaker_id}.wav" assert wav.exists() assert (tmp_path.stat().st_mode & 0o077) == 0 assert ((tmp_path / "samples").stat().st_mode & 0o077) == 0 assert (wav.stat().st_mode & 0o077) == 0 def test_sample_escape_dropped_on_load_without_unlink(tmp_path, caplog): store = make_store(tmp_path) mint(store, at_angle(0)) store.save() outside = tmp_path / "outside.wav" outside.write_bytes(b"sentinel") meta_path = tmp_path / "speakers.json" meta = json.loads(meta_path.read_text(encoding="utf-8")) speaker_id = next(iter(meta["speakers"])) meta["speakers"][speaker_id]["sample"] = "../outside.wav" meta_path.write_text(json.dumps(meta), encoding="utf-8") fresh = SpeakerStore(tmp_path) with caplog.at_level(logging.WARNING): fresh.load() assert fresh.speakers[speaker_id]["sample"] is None assert outside.read_bytes() == b"sentinel" # never unlinked outside the root assert "sample" in caplog.text.lower() or "store" in caplog.text.lower() # -- offline enrollment -------------------------------------------------------- def test_enroll_creates_confirmed_named_profile(tmp_path): store = make_store(tmp_path) speaker_id = store.enroll("Efren", [at_angle(0), at_angle(10)], dur_s=7.4) assert speaker_id == "spk_0001" assert store.speaker_ids() == ["spk_0001"] meta = store.speakers[speaker_id] assert meta["name"] == "Efren" assert meta["status"] == "confirmed" assert meta["cumulative_s"] == pytest.approx(7.4) assert meta["prototypes"] == 2 snapshot = store.snapshot() assert snapshot[0]["id"] == "spk_0001" assert snapshot[0]["name"] == "Efren" assert snapshot[0]["status"] == "confirmed" def test_enroll_same_name_reuses_id_and_appends(tmp_path): store = make_store(tmp_path) first = store.enroll("Efren", [at_angle(0)], dur_s=2.0) second = store.enroll("Efren", [at_angle(10)], dur_s=3.0) assert second == first assert store.speaker_ids() == [first] meta = store.speakers[first] assert meta["prototypes"] == 2 assert meta["cumulative_s"] == pytest.approx(5.0) def test_enroll_respects_prototype_cap(tmp_path): store = make_store(tmp_path, max_prototypes=2) speaker_id = store.enroll( "Efren", [at_angle(0), at_angle(10), at_angle(20)], dur_s=6.0 ) assert store.speakers[speaker_id]["prototypes"] == 2 assert store.speakers[speaker_id]["cumulative_s"] == pytest.approx(6.0) def test_enroll_round_trips_through_save_load(tmp_path): store = make_store(tmp_path) speaker_id = store.enroll("Efren", [at_angle(0)], dur_s=2.5) store.save() fresh = SpeakerStore(tmp_path) fresh.load() assert fresh.speaker_ids() == [speaker_id] assert fresh.speakers[speaker_id]["name"] == "Efren" assert fresh.speakers[speaker_id]["status"] == "confirmed" match = fresh.match(at_angle(0)) assert match.status == "matched" assert match.speaker_id == speaker_id def test_enroll_empty_name_raises(tmp_path): store = make_store(tmp_path) with pytest.raises(ValueError): store.enroll(" ", [at_angle(0)], dur_s=1.0) def test_enroll_promotes_existing_named_profile_to_confirmed(tmp_path): store = make_store(tmp_path) mint(store, at_angle(0)) speaker_id = store.speaker_ids()[0] assert store.speakers[speaker_id]["status"] == "provisional" store.rename(speaker_id, "Efren") reused = store.enroll("Efren", [at_angle(5)], dur_s=1.0) assert reused == speaker_id assert store.speakers[speaker_id]["status"] == "confirmed" assert store.speakers[speaker_id]["name"] == "Efren" # -- dedupe ------------------------------------------------------------------- def test_dedupe_removes_exact_duplicate_and_keeps_first(tmp_path): store = make_store(tmp_path) speaker_id = store.enroll("Efren", [at_angle(0), at_angle(0), at_angle(90)]) removed = store.dedupe(speaker_id) assert removed == 1 assert store._proto_ids == [speaker_id, speaker_id] angles = [float(np.rad2deg(np.arctan2(row[1], row[0]))) for row in store._vectors] assert angles == pytest.approx([0, 90], abs=0.05) assert store.speakers[speaker_id]["prototypes"] == 2 def test_dedupe_removes_near_duplicates_at_default_threshold(tmp_path): store = make_store(tmp_path) speaker_id = store.enroll("Efren", [at_angle(0), at_angle(5), at_angle(40)]) removed = store.dedupe(speaker_id) assert removed == 1 # cos(5deg) ~= 0.996 >= 0.99; cos(40deg) ~= 0.77 kept assert store.speakers[speaker_id]["prototypes"] == 2 def test_dedupe_threshold_controls_aggressiveness(tmp_path): store = make_store(tmp_path) speaker_id = store.enroll("Efren", [at_angle(0), at_angle(20)]) assert store.dedupe(speaker_id) == 0 # cos(20deg) ~= 0.94 < 0.99 assert store.speakers[speaker_id]["prototypes"] == 2 assert store.dedupe(speaker_id, threshold=0.9) == 1 assert store.speakers[speaker_id]["prototypes"] == 1 def test_dedupe_none_covers_all_speakers(tmp_path): store = make_store(tmp_path) first = store.enroll("Efren", [at_angle(0), at_angle(0)]) second = store.enroll("Rival", [at_angle(90), at_angle(90)]) removed = store.dedupe() assert removed == 2 assert store.speakers[first]["prototypes"] == 1 assert store.speakers[second]["prototypes"] == 1 assert store.match(at_angle(0)).speaker_id == first assert store.match(at_angle(90)).speaker_id == second def test_dedupe_single_id_leaves_other_speakers_untouched(tmp_path): store = make_store(tmp_path) first = store.enroll("Efren", [at_angle(0), at_angle(0)]) second = store.enroll("Rival", [at_angle(90), at_angle(90)]) removed = store.dedupe(first) assert removed == 1 assert store.speakers[first]["prototypes"] == 1 assert store.speakers[second]["prototypes"] == 2 assert store._proto_ids.count(second) == 2 def test_dedupe_accepts_display_name(tmp_path): store = make_store(tmp_path) store.enroll("Efren", [at_angle(0), at_angle(0)]) removed = store.dedupe("Efren") assert removed == 1 assert store.speakers["spk_0001"]["prototypes"] == 1 def test_dedupe_empty_store_and_unknown_id_return_zero(tmp_path): store = make_store(tmp_path) assert store.dedupe() == 0 assert store.dedupe("spk_9999") == 0 assert store.dedupe("Nobody") == 0 assert store.dedupe(None, threshold=0.5) == 0 store.enroll("Efren", [at_angle(0)]) assert store.dedupe("spk_9999") == 0 assert store.speakers["spk_0001"]["prototypes"] == 1 def test_dedupe_does_not_save(tmp_path): store = make_store(tmp_path) speaker_id = store.enroll("Efren", [at_angle(0), at_angle(0)]) removed = store.dedupe(speaker_id) assert removed == 1 assert not (tmp_path / "speakers.json").exists() assert not (tmp_path / "prototypes.npz").exists() def test_dedupe_keeps_at_least_one_prototype(tmp_path): store = make_store(tmp_path) speaker_id = store.enroll("Efren", [at_angle(0), at_angle(0), at_angle(0)]) removed = store.dedupe(speaker_id, threshold=0.0) assert removed == 2 assert store._proto_ids == [speaker_id] assert store.speakers[speaker_id]["prototypes"] == 1 assert store.match(at_angle(0)).status == "matched"