| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763 |
- """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"
|