From 829dd7d0a54288a394c41f3fce2ca7b030d462e8 Mon Sep 17 00:00:00 2001 From: tayyaba-code Date: Fri, 17 Jul 2026 16:18:19 +0500 Subject: [PATCH 01/10] Add voice authentication utilities and comprehensive tests --- dummy_model.pkl | Bin 0 -> 2399 bytes src/vocalid/audio_utils.py | 27 +++++++- src/vocalid/auth_adapter.py | 38 +++++++++++ src/vocalid/dataset_manager.py | 48 ++++++++++++++ src/vocalid/duplicate_detector.py | 46 +++++++++++++ src/vocalid/enroll_and_authenticate.py | 44 +++++++++++++ src/vocalid/enrollment.py | 70 ++++++++++++++++++++ src/vocalid/recorder.py | 43 +++++++++++++ src/vocalid/validator.py | 48 ++++++++++++++ tests/conftest.py | 54 ++++++++++++++-- tests/test_auth_adapter.py | 69 ++++++++++++++++++++ tests/test_dataset_manager.py | 46 +++++++++++++ tests/test_duplicate_detector.py | 71 ++++++++++++++++++++ tests/test_enrollment.py | 86 +++++++++++++++++++++++++ tests/test_recorder.py | 66 +++++++++++++++++++ tests/test_validator.py | 40 ++++++++++++ 16 files changed, 786 insertions(+), 10 deletions(-) create mode 100644 dummy_model.pkl create mode 100644 src/vocalid/auth_adapter.py create mode 100644 src/vocalid/dataset_manager.py create mode 100644 src/vocalid/duplicate_detector.py create mode 100644 src/vocalid/enroll_and_authenticate.py create mode 100644 src/vocalid/enrollment.py create mode 100644 src/vocalid/recorder.py create mode 100644 src/vocalid/validator.py create mode 100644 tests/test_auth_adapter.py create mode 100644 tests/test_dataset_manager.py create mode 100644 tests/test_duplicate_detector.py create mode 100644 tests/test_enrollment.py create mode 100644 tests/test_recorder.py create mode 100644 tests/test_validator.py diff --git a/dummy_model.pkl b/dummy_model.pkl new file mode 100644 index 0000000000000000000000000000000000000000..2578c7191bf15cc7a2e6af72c775d3520b7cb819 GIT binary patch literal 2399 zcmaJ@c~lc=5|<-_C@%3F4U;v!-XoIP^uynVT4>oYbf?tho=nVGZZ6et`RjTOw3m*d9j2rf>hDI z)F1_4Dv(KdN)-do`2CeKaSWlLDV8`iJVHrR<`VvTo{%X^FS%q!nKAD|WlDnP_MLXc!@t5KP~K$?*g$w>^uS>G6vc7Nta+G|LMkHBW1K~0$Q-!NEVG9vuX2;92l z?=Zr@Q{n$&m@pVNJ@v|fA=SX27dF`Y*g@!8J94OCTq<0Zm zk~&(n^j`-7-k^_~I0P$OIx00mW#~9-ihXlJ4tOT`h@?awo=@y4WJm6X$ts8Vs7*&O zWi-~UJyMDPT;*1I{6r?Ms@R@6c54|}eCUpx^*#$bq&@v#Z^!}1tX9*Pf%)KR>pEk1 z!&WH1)E2O8dMW(i*;;EZDuLeSrpAs}S(ujOajvvW2j||bNV_;H8ya^hpI$W0#l81A*lOL(_T{N~Us-OwUY?7|*T_bWbrLkq@J?FPtw-zNnB?ot)!;w0rM%WN zA69#1NbjxG!$3O|R&485U}p~##akQjj|2YO|2jzt(Pt+`u9=^LfvNHAXA6%){ViRx zMYs+NAH|=}ys5{Vy{Zj2k}FX0cS-DXN(Zmz&FV-DuExrDIl=**8&PvPWS^|=C@h!Q zkLNx~gdJsG=}Qwz@$AWkvIE^(oVt}w^*E@(y~EOlbD;*8%(yVk#!U}dZu4$#8Giz% z49fB8Ni?7+-DpLpTLqY8oU^ix$j7I5^hwcC8E`B&bWdP&9$d?Eeqq_W1rsx4*PUxg z1pcm!Ft3!IP?ub?EzJobp}wW`&i%vKKe=YdU7uZ`BgE-J>7cbbz8nzcZ4DdV^J>(%-fx|Wz zoFt^%(Q8K*gIx#vCu8vx>C5n9jEssh$d`S*IAgYeNYF`JzO9#-JJ-eXsw#&Q` z+e>iZx(94i>na!=@c4Jf*VW*X{!$q@^bnjoT&$X-EyAF=btXmQ_o4lB=t;eF9Bi%F zMqO?h@L2vIoAjCrEC{-n^u}HY9>FG@w#rQGOgekd!~YbXET6T~X0sm3V_dCO>Tr13 zLU+*-f$Dq|M5#`D$fbgjuEVJ2VxWMP0WBrqQ{KcZaRvszOkU$U#de_qf6_HozT2w>$EsdVzDXX`E+J|4&(%oI-G6Fc zr53NO&D!&~ITbM6{C@Z2W&1$&^zw-l_IeckV0*~_SprrRv~HU6MV=AU@M%B zemHZawgw(>wKj%^3KVzjP-mqcg!~+KyT4}{nvZe$3z@$g>la)*5hT|_+oq_wFDEFO zdp0!!@i1J)b#cFmi(p8=nBOa#%iz^>=^<0cGUyiixi|QgfdA9x0fjDwnEK<-+d1b$ zVCAv)`;uSfP?(o*8FgVDI@})p!K1DmnoVj(Jlhci%Cv&`y_0k}!mvU2K~xA`(9JSNG^e>BV^ThvdVeVfWRR2#6N-S*e?lhi{ eTF#d`R=m$+6OWlC`bHv( (channels, frames), matches torchaudio's shape + return waveform, sr # Convert to float32 waveform = waveform.float() @@ -60,3 +63,21 @@ def record_audio(seconds=4, fs=SAMPLE_RATE): audio_tensor = torch.nn.functional.pad(audio_tensor, (0, pad_len)) return audio_tensor + +def save_audio(audio_tensor, path, sample_rate=SAMPLE_RATE): + """ + Save a mono waveform tensor to a WAV file. + + Parameters + ---------- + audio_tensor : torch.Tensor + Shape (1, T) or (T,) + path : str + Output WAV filename. + sample_rate : int + Sample rate. + """ + if audio_tensor.ndim == 1: + audio_tensor = audio_tensor.unsqueeze(0) + + torchaudio.save(path, audio_tensor.cpu(), sample_rate) \ No newline at end of file diff --git a/src/vocalid/auth_adapter.py b/src/vocalid/auth_adapter.py new file mode 100644 index 0000000..a0c5fc6 --- /dev/null +++ b/src/vocalid/auth_adapter.py @@ -0,0 +1,38 @@ +""" +auth_adapter.py +Adapts VocalID's verification result into an authentication decision. +Deliberately has no ML in it - it just calls VoiceVerifier and +translates (verified, score) into an AuthenticationResult that +application code (door lock, login screen, attendance app, etc.) +can branch on. +""" + +from dataclasses import dataclass +from .verifier import VoiceVerifier +from . import config + + +@dataclass +class AuthenticationResult: + success: bool + confidence: float + + def __str__(self): + status = "ACCESS GRANTED" if self.success else "ACCESS DENIED" + return f"{status} (confidence: {self.confidence:.2f})" + + +class VoiceAuthenticator: + def __init__(self, model_path: str): + self.verifier = VoiceVerifier(model_path) + + def authenticate_file(self, audio_path: str) -> AuthenticationResult: + verified, score = self.verifier.verify_file(audio_path) + return AuthenticationResult(success=verified, confidence=score) + + def authenticate_live(self, seconds: float = None) -> AuthenticationResult: + from .recorder import record_one + seconds = seconds or 4.0 + audio = record_one(seconds, config.SAMPLE_RATE) + verified, score = self.verifier.verify_live(audio) + return AuthenticationResult(success=verified, confidence=score) diff --git a/src/vocalid/dataset_manager.py b/src/vocalid/dataset_manager.py new file mode 100644 index 0000000..dc8660f --- /dev/null +++ b/src/vocalid/dataset_manager.py @@ -0,0 +1,48 @@ +""" +dataset_manager.py +Keeps the on-disk dataset in the layout trainer.py expects: + + dataset/ + my_voice/ (positive samples) + other_voices/ (negative samples) + +Just handles copying accepted files in with sequential, collision-free +names - no metadata, no database. +""" + +import os +import shutil + +POSITIVE_DIR = "my_voice" +NEGATIVE_DIR = "other_voices" + + +class DatasetManager: + def __init__(self, root: str = "dataset"): + self.root = root + self.positive_dir = os.path.join(root, POSITIVE_DIR) + self.negative_dir = os.path.join(root, NEGATIVE_DIR) + os.makedirs(self.positive_dir, exist_ok=True) + os.makedirs(self.negative_dir, exist_ok=True) + + def _target_dir(self, label: str) -> str: + if label not in ("positive", "negative"): + raise ValueError("label must be 'positive' or 'negative'") + return self.positive_dir if label == "positive" else self.negative_dir + + def list_samples(self, label: str) -> list[str]: + target_dir = self._target_dir(label) + return [ + os.path.join(target_dir, f) + for f in sorted(os.listdir(target_dir)) + if f.lower().endswith(".wav") + ] + + def add_sample(self, src_path: str, label: str) -> str: + """Copies src_path into the dataset with the next free index. Returns the new path.""" + target_dir = self._target_dir(label) + existing = self.list_samples(label) + next_index = len(existing) + 1 + dest_path = os.path.join(target_dir, f"sample{next_index:03d}.wav") + shutil.copy2(src_path, dest_path) + return dest_path diff --git a/src/vocalid/duplicate_detector.py b/src/vocalid/duplicate_detector.py new file mode 100644 index 0000000..4b823fd --- /dev/null +++ b/src/vocalid/duplicate_detector.py @@ -0,0 +1,46 @@ +""" +duplicate_detector.py +Flags a new clip as a duplicate if it's too similar (by embedding +cosine similarity) to a clip already in the dataset, or to another +clip recorded in the same enrollment batch. This is the same +embedding extractor the trainer/verifier already use, so no extra +model is loaded. +""" + +import numpy as np +from .embeddings import EmbeddingExtractor +from .audio_utils import load_audio + +DUPLICATE_THRESHOLD = 0.97 # cosine similarity above this = "same clip" + + +def _cosine(a, b) -> float: + a = np.asarray(a).flatten() + b = np.asarray(b).flatten() + denom = (np.linalg.norm(a) * np.linalg.norm(b)) or 1e-8 + return float(np.dot(a, b) / denom) + + +class DuplicateDetector: + def __init__(self, threshold: float = DUPLICATE_THRESHOLD): + self.extractor = EmbeddingExtractor() + self.threshold = threshold + + def _embed_file(self, path: str): + audio, sample_rate = load_audio(path) + return self.extractor.extract(audio) + + def find_duplicate(self, new_path: str, existing_paths: list[str]): + """ + Compares `new_path` against every clip in `existing_paths`. + Returns the path of the first near-duplicate found, or None. + """ + new_emb = self._embed_file(new_path) + for path in existing_paths: + sim = _cosine(new_emb, self._embed_file(path)) + if sim >= self.threshold: + return path + return None + + def is_duplicate(self, new_path: str, existing_paths: list[str]) -> bool: + return self.find_duplicate(new_path, existing_paths) is not None diff --git a/src/vocalid/enroll_and_authenticate.py b/src/vocalid/enroll_and_authenticate.py new file mode 100644 index 0000000..a501bbe --- /dev/null +++ b/src/vocalid/enroll_and_authenticate.py @@ -0,0 +1,44 @@ +""" +End-to-end demo: + 1. Enroll positive (your voice) and negative (other voices) samples + 2. Train a model on the collected dataset + 3. Authenticate live from the microphone + +Run: python examples/enroll_and_authenticate.py +""" + +import glob + +from voice_verifier.enrollment import EnrollmentSession +from voice_verifier.trainer import VoiceTrainer +from voice_verifier.auth_adapter import VoiceAuthenticator + +DATASET_ROOT = "dataset" +MODEL_PATH = "my_voice_model.pkl" + + +def main(): + session = EnrollmentSession(dataset_root=DATASET_ROOT) + + print("=== Enrolling YOUR voice (positive samples) ===") + session.enroll(label="positive", count=10, seconds=5.0) + + print("\n=== Enrolling OTHER voices (negative samples) ===") + print("Have a few different people speak for this part.") + session.enroll(label="negative", count=10, seconds=5.0) + + print("\n=== Training ===") + pos_files = glob.glob(f"{DATASET_ROOT}/my_voice/*.wav") + neg_files = glob.glob(f"{DATASET_ROOT}/other_voices/*.wav") + trainer = VoiceTrainer() + trainer.train(pos_files, neg_files, save_path=MODEL_PATH) + print(f"Model saved to {MODEL_PATH}") + + print("\n=== Authenticate ===") + authenticator = VoiceAuthenticator(MODEL_PATH) + result = authenticator.authenticate_live(seconds=4.0) + print(result) + + +if __name__ == "__main__": + main() diff --git a/src/vocalid/enrollment.py b/src/vocalid/enrollment.py new file mode 100644 index 0000000..ffb4872 --- /dev/null +++ b/src/vocalid/enrollment.py @@ -0,0 +1,70 @@ +""" +enrollment.py +Ties recorder + validator + duplicate_detector + dataset_manager +together into one guided session: + + record clip -> validate -> check duplicate -> save to dataset + +Retries a rejected clip instead of silently skipping it, so the user +always ends up with the number of samples they asked for. +""" + +import os +import tempfile + +from .recorder import record_one +from .validator import check_audio +from .duplicate_detector import DuplicateDetector +from .dataset_manager import DatasetManager +from . import config + + +class EnrollmentSession: + def __init__(self, dataset_root: str = "dataset", max_attempts_per_sample: int = 3): + self.dataset = DatasetManager(dataset_root) + self.dupe_detector = DuplicateDetector() + self.max_attempts_per_sample = max_attempts_per_sample + + def enroll(self, label: str, count: int = 10, seconds: float = 5.0) -> list[str]: + """ + Records and accepts `count` valid, non-duplicate clips for + `label` ("positive" or "negative"). Returns the saved paths. + """ + saved_paths = [] + + for i in range(count): + accepted = False + + for attempt in range(1, self.max_attempts_per_sample + 1): + print(f"\nSample {i + 1}/{count} (attempt {attempt}) - label: {label}") + audio = record_one(seconds, config.SAMPLE_RATE) + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + tmp_path = tmp.name + from .audio_utils import save_audio + save_audio(audio, tmp_path, config.SAMPLE_RATE) + + is_valid, reason = check_audio(tmp_path) + if not is_valid: + print(f"Rejected: {reason}. Try again.") + os.remove(tmp_path) + continue + + existing = self.dataset.list_samples(label) + saved_paths + dupe = self.dupe_detector.find_duplicate(tmp_path, existing) + if dupe: + print(f"Rejected: too similar to {os.path.basename(dupe)}. Try again.") + os.remove(tmp_path) + continue + + saved_path = self.dataset.add_sample(tmp_path, label) + os.remove(tmp_path) + saved_paths.append(saved_path) + print(f"Saved as {saved_path}") + accepted = True + break + + if not accepted: + print(f"Giving up on sample {i + 1} after {self.max_attempts_per_sample} attempts.") + + return saved_paths diff --git a/src/vocalid/recorder.py b/src/vocalid/recorder.py new file mode 100644 index 0000000..0dddd02 --- /dev/null +++ b/src/vocalid/recorder.py @@ -0,0 +1,43 @@ +""" +recorder.py +Records short audio clips one at a time for voice enrollment. + +Assumes voice_verifier.audio_utils already exposes a `record_audio` +function (mic -> tensor/np array) and a `save_audio` function, +matching what the library's live-verification path uses. +If your actual audio_utils.py names these differently, just +adjust the two imports below. +""" + +import os +from .audio_utils import record_audio, save_audio +from . import config + + +def record_one(seconds: float = 5.0, sample_rate: int = None): + """Record a single clip and return the raw audio tensor.""" + sample_rate = sample_rate or config.SAMPLE_RATE + print(f"Recording for {seconds:.1f}s... speak naturally.") + audio = record_audio(duration=seconds, sample_rate=sample_rate) + print("Done.") + return audio + + +def record_batch(out_dir: str, count: int = 10, seconds: float = 5.0, + sample_rate: int = None, prefix: str = "sample"): + """ + Record `count` clips back to back, saving each to `out_dir`. + Returns the list of saved file paths (unvalidated). + """ + os.makedirs(out_dir, exist_ok=True) + sample_rate = sample_rate or config.SAMPLE_RATE + paths = [] + + for i in range(count): + input(f"[{i + 1}/{count}] Press Enter, then start speaking...") + audio = record_one(seconds, sample_rate) + path = os.path.join(out_dir, f"{prefix}_{i + 1:03d}.wav") + save_audio(audio, path, sample_rate) + paths.append(path) + + return paths diff --git a/src/vocalid/validator.py b/src/vocalid/validator.py new file mode 100644 index 0000000..05452d8 --- /dev/null +++ b/src/vocalid/validator.py @@ -0,0 +1,48 @@ +""" +validator.py +Basic sanity checks on a recorded clip before it's allowed into the +dataset: right length, not silent, not clipped/overdriven. + +Uses audio_utils.load_audio(path) -> (tensor, sample_rate), which the +library already needs for file-based verification. +""" + +import numpy as np +from .audio_utils import load_audio + +MIN_SECONDS = 4.0 +MAX_SECONDS = 6.5 +MIN_RMS = 0.01 # below this, treat the clip as silence/noise floor +CLIP_THRESHOLD = 0.98 # fraction of full scale considered "clipping" +MAX_CLIPPED_RATIO = 0.01 + + +def _to_numpy(audio): + if hasattr(audio, "numpy"): + return audio.numpy().flatten() + return np.asarray(audio).flatten() + + +def check_audio(path: str) -> tuple[bool, str]: + """ + Returns (is_valid, reason). reason is 'ok' when valid, otherwise + a short human-readable explanation of why it was rejected. + """ + audio, sample_rate = load_audio(path) + samples = _to_numpy(audio) + + duration = len(samples) / float(sample_rate) + if duration < MIN_SECONDS: + return False, f"too short ({duration:.1f}s)" + if duration > MAX_SECONDS: + return False, f"too long ({duration:.1f}s)" + + rms = float(np.sqrt(np.mean(samples ** 2))) + if rms < MIN_RMS: + return False, "too quiet / silence detected" + + clipped_ratio = float(np.mean(np.abs(samples) >= CLIP_THRESHOLD)) + if clipped_ratio > MAX_CLIPPED_RATIO: + return False, "clipping / distortion detected" + + return True, "ok" diff --git a/tests/conftest.py b/tests/conftest.py index 9cfd57c..e133926 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,50 @@ -import sys -import os +""" +conftest.py +Shared fixtures for the new modules' tests. Everything here writes +real, tiny .wav files with numpy + soundfile so tests run anywhere +(CI included) without a microphone or a real voice sample. +""" -ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +import numpy as np +import soundfile as sf +import pytest -if os.environ.get("GITHUB_ACTIONS") == "true": - os.environ["SKIP_SPEECHBRAIN"] = "1" +SAMPLE_RATE = 16000 -if ROOT not in sys.path: - sys.path.insert(0, ROOT) \ No newline at end of file + +def _tone(duration: float, sample_rate: int, freq: float = 220.0, amplitude: float = 0.3): + """A simple sine wave - stands in for a 'real' voice-shaped signal.""" + t = np.linspace(0, duration, int(sample_rate * duration), endpoint=False) + return (amplitude * np.sin(2 * np.pi * freq * t)).astype(np.float32) + + +@pytest.fixture +def write_wav(tmp_path): + """ + Returns a function write_wav(name, **kwargs) -> path + that writes a synthetic clip and returns its path. + + kwargs: + duration seconds (default 5.0) + sample_rate (default 16000) + freq tone frequency, changes the clip's "identity" (default 220.0) + amplitude 0..1 (default 0.3) + silent if True, writes near-zero samples instead of a tone + clipped if True, writes a hard-clipped square-ish wave + """ + def _write(name: str, duration: float = 5.0, sample_rate: int = SAMPLE_RATE, + freq: float = 220.0, amplitude: float = 0.3, + silent: bool = False, clipped: bool = False): + if silent: + samples = np.zeros(int(sample_rate * duration), dtype=np.float32) + elif clipped: + samples = _tone(duration, sample_rate, freq, amplitude=1.0) + samples = np.clip(samples * 5.0, -1.0, 1.0) + else: + samples = _tone(duration, sample_rate, freq, amplitude) + + path = tmp_path / name + sf.write(str(path), samples, sample_rate) + return str(path) + + return _write diff --git a/tests/test_auth_adapter.py b/tests/test_auth_adapter.py new file mode 100644 index 0000000..d7de448 --- /dev/null +++ b/tests/test_auth_adapter.py @@ -0,0 +1,69 @@ +""" +test_auth_adapter.py + +VoiceVerifier is mocked out so this tests the adapter's translation +of (verified, score) -> AuthenticationResult, not the real +verification model. +""" + +import pytest +import vocalid.auth_adapter as auth_module +from vocalid.auth_adapter import VoiceAuthenticator, AuthenticationResult + + +class _FakeVerifier: + def __init__(self, model_path): + self.model_path = model_path + + def verify_file(self, path): + return True, 0.87 + + def verify_live(self, audio): + return False, 0.21 + + +@pytest.fixture +def authenticator(monkeypatch): + monkeypatch.setattr(auth_module, "VoiceVerifier", _FakeVerifier) + return VoiceAuthenticator("dummy_model.pkl") + + +def test_authenticate_file_returns_granted_result(authenticator): + result = authenticator.authenticate_file("some_clip.wav") + assert isinstance(result, AuthenticationResult) + assert result.success is True + assert result.confidence == 0.87 + + +def test_authenticate_live_returns_denied_result(authenticator, monkeypatch): + # authenticate_live does `from .recorder import record_one` internally, + # so the real patch point is vocalid.recorder.record_one. + monkeypatch.setattr("vocalid.recorder.record_one", + lambda seconds, sample_rate: "fake_audio") + + result = authenticator.authenticate_live(seconds=4.0) + + assert result.success is False + assert result.confidence == 0.21 + + +def test_authenticate_live_defaults_to_four_seconds(authenticator, monkeypatch): + captured = {} + + def fake_record_one(seconds, sample_rate): + captured["seconds"] = seconds + return "fake_audio" + + monkeypatch.setattr("vocalid.recorder.record_one", fake_record_one) + authenticator.authenticate_live(seconds=None) + + assert captured["seconds"] == 4.0 + + +def test_result_str_formatting(): + granted = AuthenticationResult(success=True, confidence=0.9123) + denied = AuthenticationResult(success=False, confidence=0.1) + + assert "ACCESS GRANTED" in str(granted) + assert "0.91" in str(granted) + assert "ACCESS DENIED" in str(denied) diff --git a/tests/test_dataset_manager.py b/tests/test_dataset_manager.py new file mode 100644 index 0000000..ebf4b54 --- /dev/null +++ b/tests/test_dataset_manager.py @@ -0,0 +1,46 @@ +""" +test_dataset_manager.py +""" + +import os +import pytest +from vocalid.dataset_manager import DatasetManager + + +def test_creates_folder_layout(tmp_path): + root = tmp_path / "dataset" + DatasetManager(str(root)) + assert (root / "my_voice").is_dir() + assert (root / "other_voices").is_dir() + + +def test_add_sample_copies_and_lists(tmp_path, write_wav): + dm = DatasetManager(str(tmp_path / "dataset")) + clip = write_wav("clip.wav") + + saved_path = dm.add_sample(clip, "positive") + + assert os.path.exists(saved_path) + assert saved_path in dm.list_samples("positive") + assert dm.list_samples("negative") == [] + + +def test_add_sample_sequential_naming(tmp_path, write_wav): + dm = DatasetManager(str(tmp_path / "dataset")) + clip_a = write_wav("a.wav") + clip_b = write_wav("b.wav") + + path1 = dm.add_sample(clip_a, "negative") + path2 = dm.add_sample(clip_b, "negative") + + assert os.path.basename(path1) == "sample001.wav" + assert os.path.basename(path2) == "sample002.wav" + assert len(dm.list_samples("negative")) == 2 + + +def test_rejects_bad_label(tmp_path, write_wav): + dm = DatasetManager(str(tmp_path / "dataset")) + clip = write_wav("clip.wav") + + with pytest.raises(ValueError): + dm.add_sample(clip, "unknown_label") diff --git a/tests/test_duplicate_detector.py b/tests/test_duplicate_detector.py new file mode 100644 index 0000000..05bf35b --- /dev/null +++ b/tests/test_duplicate_detector.py @@ -0,0 +1,71 @@ +""" +test_duplicate_detector.py + +The real EmbeddingExtractor loads a full ECAPA-TDNN model from +speechbrain, which is slow and needs a network connection on first +run. For unit tests we swap it for a fake extractor that just turns +the raw audio into its "embedding" - two clips with the same tone +frequency come out identical, different frequencies come out +different, which is enough to exercise the cosine-similarity logic +in duplicate_detector.py itself. Swap this fixture out once you're +ready to write a slower, real-model integration test. +""" + +import numpy as np +import soundfile as sf +import pytest + +import vocalid.duplicate_detector as dd_module +from vocalid.duplicate_detector import DuplicateDetector + + +class _FakeExtractor: + def extract(self, audio): + return np.asarray(audio).flatten() + + +def _fake_load_audio(path): + samples, sample_rate = sf.read(path, dtype="float32") + return samples, sample_rate + + +@pytest.fixture +def detector(monkeypatch): + monkeypatch.setattr(dd_module, "EmbeddingExtractor", _FakeExtractor) + monkeypatch.setattr(dd_module, "load_audio", _fake_load_audio) + return DuplicateDetector() + + +def test_identical_clip_is_flagged(detector, write_wav): + original = write_wav("original.wav", freq=220.0) + replay = write_wav("replay.wav", freq=220.0) # same tone == same "voice" + + match = detector.find_duplicate(replay, [original]) + assert match == original + assert detector.is_duplicate(replay, [original]) is True + + +def test_different_clip_is_not_flagged(detector, write_wav): + original = write_wav("original.wav", freq=220.0) + different = write_wav("different.wav", freq=880.0) # different tone == different "voice" + + match = detector.find_duplicate(different, [original]) + assert match is None + assert detector.is_duplicate(different, [original]) is False + + +def test_no_existing_clips_never_flags(detector, write_wav): + clip = write_wav("only.wav") + assert detector.find_duplicate(clip, []) is None + + +def test_custom_threshold_is_respected(write_wav, monkeypatch): + monkeypatch.setattr(dd_module, "EmbeddingExtractor", _FakeExtractor) + monkeypatch.setattr(dd_module, "load_audio", _fake_load_audio) + + strict_detector = DuplicateDetector(threshold=0.999999) + original = write_wav("original.wav", freq=220.0, amplitude=0.3) + slightly_different = write_wav("close.wav", freq=222.0, amplitude=0.3) + + # Close but not identical tones shouldn't clear an extremely strict threshold. + assert strict_detector.is_duplicate(slightly_different, [original]) is False diff --git a/tests/test_enrollment.py b/tests/test_enrollment.py new file mode 100644 index 0000000..f77c40a --- /dev/null +++ b/tests/test_enrollment.py @@ -0,0 +1,86 @@ +""" +test_enrollment.py + +record_one, audio_utils.save_audio, and DuplicateDetector are all +mocked so this exercises EnrollmentSession's retry/accept/give-up +logic without needing a mic, a real audio file, or the ECAPA model. +DatasetManager is used for real (against tmp_path) since it's just +file I/O and worth testing end to end here. +""" + +import pytest +import vocalid.enrollment as enrollment_module +from vocalid.enrollment import EnrollmentSession + + +class _FakeDupeDetector: + """queue of paths to report as duplicates, one per find_duplicate() call""" + def __init__(self, duplicate_paths=None): + self._duplicate_paths = list(duplicate_paths or []) + + def find_duplicate(self, new_path, existing_paths): + return self._duplicate_paths.pop(0) if self._duplicate_paths else None + + +@pytest.fixture(autouse=True) +def patch_recording(monkeypatch): + monkeypatch.setattr(enrollment_module, "record_one", lambda seconds, sample_rate: "fake_audio") + # save_audio is imported lazily inside enroll(), so the real patch + # point is the audio_utils module itself, not enrollment_module. + monkeypatch.setattr("vocalid.audio_utils.save_audio", lambda audio, path, sr: None) + + +def test_accepts_a_valid_unique_clip_on_first_try(monkeypatch, tmp_path): + monkeypatch.setattr(enrollment_module, "check_audio", lambda path: (True, "ok")) + monkeypatch.setattr(enrollment_module, "DuplicateDetector", lambda: _FakeDupeDetector()) + + session = EnrollmentSession(dataset_root=str(tmp_path / "dataset")) + saved = session.enroll(label="positive", count=1, seconds=5.0) + + assert len(saved) == 1 + + +def test_retries_after_a_rejected_clip(monkeypatch, tmp_path): + results = iter([(False, "too quiet"), (True, "ok")]) + monkeypatch.setattr(enrollment_module, "check_audio", lambda path: next(results)) + monkeypatch.setattr(enrollment_module, "DuplicateDetector", lambda: _FakeDupeDetector()) + + session = EnrollmentSession(dataset_root=str(tmp_path / "dataset"), max_attempts_per_sample=3) + saved = session.enroll(label="positive", count=1, seconds=5.0) + + assert len(saved) == 1 # accepted on the second attempt + + +def test_retries_after_a_duplicate_clip(monkeypatch, tmp_path): + monkeypatch.setattr(enrollment_module, "check_audio", lambda path: (True, "ok")) + # first attempt flagged as a duplicate, second attempt is clean + monkeypatch.setattr( + enrollment_module, "DuplicateDetector", + lambda: _FakeDupeDetector(duplicate_paths=["dataset/my_voice/sample001.wav"]) + ) + + session = EnrollmentSession(dataset_root=str(tmp_path / "dataset"), max_attempts_per_sample=3) + saved = session.enroll(label="positive", count=1, seconds=5.0) + + assert len(saved) == 1 + + +def test_gives_up_after_max_attempts(monkeypatch, tmp_path): + monkeypatch.setattr(enrollment_module, "check_audio", lambda path: (False, "too quiet")) + monkeypatch.setattr(enrollment_module, "DuplicateDetector", lambda: _FakeDupeDetector()) + + session = EnrollmentSession(dataset_root=str(tmp_path / "dataset"), max_attempts_per_sample=2) + saved = session.enroll(label="positive", count=1, seconds=5.0) + + assert saved == [] # never accepted, session moved on + + +def test_returns_a_path_for_every_accepted_sample(monkeypatch, tmp_path): + monkeypatch.setattr(enrollment_module, "check_audio", lambda path: (True, "ok")) + monkeypatch.setattr(enrollment_module, "DuplicateDetector", lambda: _FakeDupeDetector()) + + session = EnrollmentSession(dataset_root=str(tmp_path / "dataset")) + saved = session.enroll(label="negative", count=3, seconds=5.0) + + assert len(saved) == 3 + assert len(set(saved)) == 3 # all distinct paths, no overwrites diff --git a/tests/test_recorder.py b/tests/test_recorder.py new file mode 100644 index 0000000..28a528c --- /dev/null +++ b/tests/test_recorder.py @@ -0,0 +1,66 @@ +""" +test_recorder.py + +record_audio / save_audio (from audio_utils) are mocked out - these +tests check recorder.py's own logic (sample-rate defaulting, file +naming, loop count), not the real microphone or real file writing. +""" + +import pytest +import vocalid.recorder as recorder_module + + +@pytest.fixture +def fake_audio_utils(monkeypatch): + calls = {"record_audio": [], "save_audio": []} + + def fake_record_audio(duration, sample_rate): + calls["record_audio"].append((duration, sample_rate)) + return f"audio@{duration}s" + + def fake_save_audio(audio, path, sample_rate): + calls["save_audio"].append((audio, path, sample_rate)) + + monkeypatch.setattr(recorder_module, "record_audio", fake_record_audio) + monkeypatch.setattr(recorder_module, "save_audio", fake_save_audio) + return calls + + +def test_record_one_uses_given_seconds_and_rate(fake_audio_utils): + audio = recorder_module.record_one(seconds=6.0, sample_rate=22050) + assert audio == "audio@6.0s" + assert fake_audio_utils["record_audio"] == [(6.0, 22050)] + + +def test_record_one_falls_back_to_config_sample_rate(fake_audio_utils, monkeypatch): + monkeypatch.setattr(recorder_module.config, "SAMPLE_RATE", 16000) + recorder_module.record_one(seconds=5.0, sample_rate=None) + assert fake_audio_utils["record_audio"] == [(5.0, 16000)] + + +def test_record_batch_records_and_saves_expected_count(fake_audio_utils, monkeypatch, tmp_path): + monkeypatch.setattr("builtins.input", lambda prompt="": "") + + paths = recorder_module.record_batch(str(tmp_path), count=3, seconds=5.0, sample_rate=16000) + + assert len(paths) == 3 + assert len(fake_audio_utils["record_audio"]) == 3 + assert len(fake_audio_utils["save_audio"]) == 3 + + +def test_record_batch_names_files_sequentially(fake_audio_utils, monkeypatch, tmp_path): + monkeypatch.setattr("builtins.input", lambda prompt="": "") + + paths = recorder_module.record_batch(str(tmp_path), count=2, prefix="clip") + + assert paths[0].endswith("clip_001.wav") + assert paths[1].endswith("clip_002.wav") + + +def test_record_batch_creates_output_dir(fake_audio_utils, monkeypatch, tmp_path): + monkeypatch.setattr("builtins.input", lambda prompt="": "") + out_dir = tmp_path / "nested" / "clips" + + recorder_module.record_batch(str(out_dir), count=1) + + assert out_dir.is_dir() diff --git a/tests/test_validator.py b/tests/test_validator.py new file mode 100644 index 0000000..24f95c9 --- /dev/null +++ b/tests/test_validator.py @@ -0,0 +1,40 @@ +""" +test_validator.py +""" + +from vocalid.validator import check_audio + + +def test_accepts_good_clip(write_wav): + path = write_wav("good.wav", duration=5.0) + is_valid, reason = check_audio(path) + assert is_valid is True + assert reason == "ok" + + +def test_rejects_too_short(write_wav): + path = write_wav("short.wav", duration=1.5) + is_valid, reason = check_audio(path) + assert is_valid is False + assert "short" in reason + + +def test_rejects_too_long(write_wav): + path = write_wav("long.wav", duration=8.0) + is_valid, reason = check_audio(path) + assert is_valid is False + assert "long" in reason + + +def test_rejects_silence(write_wav): + path = write_wav("silent.wav", duration=5.0, silent=True) + is_valid, reason = check_audio(path) + assert is_valid is False + assert "quiet" in reason or "silence" in reason + + +def test_rejects_clipping(write_wav): + path = write_wav("clipped.wav", duration=5.0, clipped=True) + is_valid, reason = check_audio(path) + assert is_valid is False + assert "clip" in reason From 0922dd31b83ac33004e0d769bb1bc230efbaa005 Mon Sep 17 00:00:00 2001 From: tayyaba-code Date: Fri, 17 Jul 2026 21:02:57 +0500 Subject: [PATCH 02/10] Fix voice authentication pipeline and update tests --- dummy_model.pkl | Bin 2399 -> 2399 bytes src/vocalid/audio_utils.py | 159 ++++++++++++++------ src/vocalid/auth_adapter.py | 16 +- src/vocalid/duplicate_detector.py | 46 ------ src/vocalid/embeddings.py | 195 +++++++++++++++++++++---- src/vocalid/enroll_and_authenticate.py | 6 +- src/vocalid/enrollment.py | 162 ++++++++++++++++---- src/vocalid/recorder.py | 118 ++++++++++++--- src/vocalid/trainer.py | 2 + src/vocalid/verifier.py | 2 +- tests/test_auth_adapter.py | 70 ++++++--- tests/test_duplicate_detector.py | 71 --------- tests/test_enrollment.py | 161 ++++++++++++++------ 13 files changed, 688 insertions(+), 320 deletions(-) delete mode 100644 src/vocalid/duplicate_detector.py delete mode 100644 tests/test_duplicate_detector.py diff --git a/dummy_model.pkl b/dummy_model.pkl index 2578c7191bf15cc7a2e6af72c775d3520b7cb819..feab21ddec8de4a4ed032b2b2a8b4049927403bb 100644 GIT binary patch delta 1571 zcmWN|eLNEg9{}(hpQ1w7@fxthg7tw)CY=1jJ0LjvDu_DC;yFzx7_$%k_@2mtoVVTfCVH3f-qL~KTV_JElIVvx zF>@I>-T%bMM_&c!tZ8iyd>MMngF+31vhX%b{rDega*W8?JwH0h$29E)LP0_+EUPW- zZRU7*c#0k=_f#Tbq2)7qc@HGoGUWp`O1Nk4WTG=DK*w_f z=8hVi30AITOfX^7SX|)nDx(TsC4U!{`|tl^>_I!K<)JFLs7oX2&z8Wi8Na;@x@L*g z7g{#k{~(MBh(!(TLMWa~3Jy_R#dV9Xi!GHZEN7PuZM`kWA#LbD%v=+WmbC3XzPT99 zn&gKS{X)1=8ga%nwHE^WF3s`zc;!Z{p6vo4g>=yNi$xHp()B$OO5nsns|Tz_C8#Ss>`h7qSmd~NsPmN= zJnFM%Y^5#Ocu0E7I*5ZEHU(3j(OT#>Zk2xDs6y5|qF`%ZCX5quJ?kC_@ye~WYZ&2# zD6ph%dv2u>0@WoZbRR{9i;h2$Cw`Y9RYXtv`E)X7Bu=_Dr8nai_nVU+q6M(?izm~s zuQsE_@nI8&OBZt8To#1uk}=VUwlPDaz?_ewoHI>QNK!S@Z&Uf0Ny=;Ucd5oic8@bz zdJ$b+vK@<12^9(h#!NS@1Xo1yapPe!9Q1PZikCEjxsOld6uk(mpV#P|%~UuMY*R?! zlmc&W+e`Yq0L~V+ZJDlQ;tatPO?TGggmC|IHop#MWBcP24lQWf-G7DjixNifIIa|F z>oMvfD}bJV1s4Q--G&4S?(=3DbC<**k}@h{XruQ71rH-XFWfFRECN;K-l=yN!(c}E)bG`+EHq%m254559BfRp zcl7#8j3HcIs%KRN?5YuwrG*mMh2xoY%){;}8so#gJg|x7ncocKftvef#lgQ6Mz56_ zzUjFD5~`lB?UBov;S>1HDJ>r?-R(0co}7o8Zwa;cIqhgu(EM`EsRnS%ou@^eki(Ve zk6%Tab|6E?49TCas=|IF_O_!(1Q@=HdpNExwTLoBFwRv~Q%;nF&BM2>hI|$PNwCQKD1iJ^r18nd zTp)gV+T;bf05>#@($7Dxz`VPi>D4a;P{tlf+0N&~?vaf6uW=LZCk|h%v#5p-($yB{ z*r9HWvEPOew3r14Zd-8UWybhemcJcUd*&%xSqD(Nx~4|^t+0UF}8W2_}@vv z_r8}ni1`+?|>wEF+jbu9j|({ z+wd9RRzTfyj@<749^k(@d?EX}0Ir*R1x6}L@sE$Lc9WI0m|V?1Fs{>pQ~1DF`NRfv zc74|AmRt*N$M)DF^0Sqb$hAJT4Aht zU1}Sf44KX7(fsttYdm%7dBT$@$5D7jdr+k3zJJ4a&}`7GT4zAF(l;j@Gt)qr`!B1! z>hHqXne}VJzvDsgtM*meAC%}wu6X-)B^TBwWixklA#RbnY@``v8vk|DS^c0vfG1TMf=%g4Sn)*{ zweNEt4%75seM}R9U0#pTT#y_*NKSk6ThgKOS>LhayQ|?_j~=ZFwF;Ix+ggX`^DvX` z+EG0whK^4sGM{cJg4W!G>8D2YB3x*BviVaB4`cj|eWNF}NC_C}m)uf;_|RpaqbDoi z?YS21$C)xHV7G}LmDWR9LZwbIE5;l7k-=d>WhgJ&=Dhg47^e(2H}z~x#j&jKVCaGr zXEe@zRg3xPxa(BOCE0y^Xj2)RvrCDl-gL?LsWeE@i0y~y0X!T}sIj~lCqYgxrIl#O zhPJ(4>_a*!T9T7F{hb={Thm>m^^n6UPd;rlPzoz|7#gSaqywSIoSJGW!$0o$W&CYx z0wlF>jXHhcItKAl2{Q-pL-T7fXT>=&Dkf4N2?nG%u#}kgilf2{|AT!u{@ ziCuMgWN+6lD`zR>IlB*Bvu=Rx--vwlEE!U>mYo=JR)HbEV>Ri#9H-w%*-7zysF$2A z21trc2k5B}k!%c675KESLX_=k*+D;#A$p{2qbP++F=<{2qSAsmp%rlMCXA z=qzSwHOd8>Ua&5zF}%0`eMNr(7S~nrG~r&*Lw2C_y_7&m=ClAoh9;K_ZG)T@Ag_F( zX0mdy-yR;PycXjMe$3}4s0JTxqibZEl)m=1Nj-=NE zhc2F}fvvsUw%BD#kvo;+^jo70W@ia#Y+i`Naq&egpIl@RAJyvjOW;&rZ6brsfNz)+ zG?Ez~JQ|bC-cwX)ezojj1T6zS&D*%GA|W0nPVS+!mSfnS3#$gnLX4sZrX3FPj)i#6 z5P8{z7|*)$r3;EY@J{@hH$NeRwBoZNi+i|Wwe-sOpI6j)^oZ@I`rsU>wRSIEE#rgd zud};tEs|h-MDkR+R)&Kds-MI`4GX)cDjF!73ix{NAZ8TyW3WLXQVj!ph<_fZ3mS#t?NYf7NwUS;BbVFd=eH5pb| z-$t7cpyxe%0HoD~Wn<2Kn2h_MRaTt}%YsMQpKKz*m261tQwwl}-9GB-*N6{m_61s9 zl|oIjlVu|F9L(vReh!|yjJk4}n{haUj+ZvDDa=+TnzXnS)|p<#{xZwW#_g4m6+V5# z_3=%V6wP5!u?B9T7tzXrh0B)cZ`!hwarJBJO($X*oZC5U{I!D;y?sXQnx~ra*A>lL z>d7DBk?jL}BgS2r4e&Z(?of_n72K}#GaA@-+G#uKpbUQ$x2YEk=|ZrpT_Va2OF?^t za*O$s1}SdEjqYk8zBrRt@b~>HSZgw_n>>CS5~rUxG}uUy`Yox%Z-#-YvYsp3|4_iQ zfQcR4cj?fc^xl5GunyiG7Fx+#R2V&+&CKK71-XbY|aj$KemJ+ AaR2}S diff --git a/src/vocalid/audio_utils.py b/src/vocalid/audio_utils.py index 01fc38f..7e605a3 100644 --- a/src/vocalid/audio_utils.py +++ b/src/vocalid/audio_utils.py @@ -1,83 +1,150 @@ import torch import torchaudio import sounddevice as sd -import numpy as np +import soundfile as sf + from .config import SAMPLE_RATE -import soundfile as sf -import torch -def load_audio(path,target_sr=SAMPLE_RATE): - samples, sr = sf.read(path, dtype="float32", always_2d=True) # (frames, channels) - waveform = torch.from_numpy(samples.T) # -> (channels, frames), matches torchaudio's shape - return waveform, sr - # Convert to float32 - waveform = waveform.float() +def load_audio(path, target_sr=SAMPLE_RATE): + """ + Load a WAV file and return a mono waveform tensor. - # Resample if needed - if sr != target_sr: - waveform = torchaudio.functional.resample(waveform, sr, target_sr) + Parameters + ---------- + path : str + Path to the WAV file. + target_sr : int + Desired sample rate. + + Returns + ------- + tuple(torch.Tensor, int) + Waveform with shape (1, T) and sample rate. + """ + + samples, sr = sf.read( + path, + dtype="float32", + always_2d=True + ) + + waveform = torch.from_numpy(samples.T).float() - # Mono - if waveform.ndim == 2 and waveform.shape[0] > 1: + # Convert stereo to mono + if waveform.shape[0] > 1: waveform = waveform.mean(dim=0, keepdim=True) - # Ensure shape is (1, T) + # Resample if necessary + if sr != target_sr: + waveform = torchaudio.functional.resample( + waveform, + sr, + target_sr + ) + sr = target_sr + + # Ensure shape (1, T) if waveform.ndim == 1: waveform = waveform.unsqueeze(0) - # Pad short audio - min_len = target_sr # 1 second minimum - if waveform.shape[1] < min_len: - pad_len = min_len - waveform.shape[1] - waveform = torch.nn.functional.pad(waveform, (0, pad_len)) + # Pad to at least one second + if waveform.shape[1] < target_sr: + pad_len = target_sr - waveform.shape[1] + waveform = torch.nn.functional.pad( + waveform, + (0, pad_len) + ) - return waveform + return waveform,sr -def record_audio(seconds=4, fs=SAMPLE_RATE): - print(f"Recording {seconds} seconds...") - # Check if sounddevice sees any input devices +def record_audio( + duration=4, + sample_rate=SAMPLE_RATE +): + """ + Record audio from the microphone. + + Parameters + ---------- + duration : float + Recording duration in seconds. + sample_rate : int + Recording sample rate. + + Returns + ------- + torch.Tensor + Recorded waveform with shape (1, T). + """ + + print(f"Recording {duration:.1f} seconds...") + try: devices = sd.query_devices() - has_input = any(d["max_input_channels"] > 0 for d in devices) - if not has_input: - raise RuntimeError("No input microphone found. Live recording cannot run here.") - except Exception: - raise RuntimeError("No audio interface available. Live recording unsupported.") - # Try to record + if not any(device["max_input_channels"] > 0 for device in devices): + raise RuntimeError("No microphone found.") + + except Exception as e: + raise RuntimeError( + "Unable to access an input microphone." + ) from e + try: - audio = sd.rec(int(seconds * fs), samplerate=fs, channels=1, dtype='float32') + audio = sd.rec( + int(duration * sample_rate), + samplerate=sample_rate, + channels=1, + dtype="float32" + ) + sd.wait() + except Exception as e: - raise RuntimeError("Live recording failed. Likely running in Colab.") from e + raise RuntimeError( + "Audio recording failed." + ) from e print("Recording complete") - audio_tensor = torch.tensor(audio.T, dtype=torch.float32) + waveform = torch.from_numpy(audio.T).float() - # Ensure minimum length - if audio_tensor.shape[1] < fs: - pad_len = fs - audio_tensor.shape[1] - audio_tensor = torch.nn.functional.pad(audio_tensor, (0, pad_len)) + # Ensure minimum duration of one second + if waveform.shape[1] < sample_rate: + pad_len = sample_rate - waveform.shape[1] + waveform = torch.nn.functional.pad( + waveform, + (0, pad_len) + ) + + return waveform - return audio_tensor -def save_audio(audio_tensor, path, sample_rate=SAMPLE_RATE): +def save_audio( + waveform, + path, + sample_rate=SAMPLE_RATE +): """ - Save a mono waveform tensor to a WAV file. + Save a waveform tensor as a WAV file. Parameters ---------- - audio_tensor : torch.Tensor - Shape (1, T) or (T,) + waveform : torch.Tensor + Audio waveform of shape (1, T) or (T,). path : str - Output WAV filename. + Output WAV file path. sample_rate : int Sample rate. """ - if audio_tensor.ndim == 1: - audio_tensor = audio_tensor.unsqueeze(0) - torchaudio.save(path, audio_tensor.cpu(), sample_rate) \ No newline at end of file + if waveform.ndim == 2: + waveform = waveform.squeeze(0) + + sf.write( + path, + waveform.cpu().numpy(), + sample_rate + ) \ No newline at end of file diff --git a/src/vocalid/auth_adapter.py b/src/vocalid/auth_adapter.py index a0c5fc6..58e0b3f 100644 --- a/src/vocalid/auth_adapter.py +++ b/src/vocalid/auth_adapter.py @@ -32,7 +32,17 @@ def authenticate_file(self, audio_path: str) -> AuthenticationResult: def authenticate_live(self, seconds: float = None) -> AuthenticationResult: from .recorder import record_one + seconds = seconds or 4.0 - audio = record_one(seconds, config.SAMPLE_RATE) - verified, score = self.verifier.verify_live(audio) - return AuthenticationResult(success=verified, confidence=score) + + audio = record_one( + seconds, + config.SAMPLE_RATE + ) + + verified, score = self.verifier.verify_array(audio) + + return AuthenticationResult( + success=verified, + confidence=score + ) \ No newline at end of file diff --git a/src/vocalid/duplicate_detector.py b/src/vocalid/duplicate_detector.py deleted file mode 100644 index 4b823fd..0000000 --- a/src/vocalid/duplicate_detector.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -duplicate_detector.py -Flags a new clip as a duplicate if it's too similar (by embedding -cosine similarity) to a clip already in the dataset, or to another -clip recorded in the same enrollment batch. This is the same -embedding extractor the trainer/verifier already use, so no extra -model is loaded. -""" - -import numpy as np -from .embeddings import EmbeddingExtractor -from .audio_utils import load_audio - -DUPLICATE_THRESHOLD = 0.97 # cosine similarity above this = "same clip" - - -def _cosine(a, b) -> float: - a = np.asarray(a).flatten() - b = np.asarray(b).flatten() - denom = (np.linalg.norm(a) * np.linalg.norm(b)) or 1e-8 - return float(np.dot(a, b) / denom) - - -class DuplicateDetector: - def __init__(self, threshold: float = DUPLICATE_THRESHOLD): - self.extractor = EmbeddingExtractor() - self.threshold = threshold - - def _embed_file(self, path: str): - audio, sample_rate = load_audio(path) - return self.extractor.extract(audio) - - def find_duplicate(self, new_path: str, existing_paths: list[str]): - """ - Compares `new_path` against every clip in `existing_paths`. - Returns the path of the first near-duplicate found, or None. - """ - new_emb = self._embed_file(new_path) - for path in existing_paths: - sim = _cosine(new_emb, self._embed_file(path)) - if sim >= self.threshold: - return path - return None - - def is_duplicate(self, new_path: str, existing_paths: list[str]) -> bool: - return self.find_duplicate(new_path, existing_paths) is not None diff --git a/src/vocalid/embeddings.py b/src/vocalid/embeddings.py index 55df8c5..1b02ae2 100644 --- a/src/vocalid/embeddings.py +++ b/src/vocalid/embeddings.py @@ -1,23 +1,109 @@ +# import torch +# from .audio_utils import load_audio +# import numpy as np + + +# class EmbeddingExtractor: +# def __init__(self, model_path="speechbrain/spkrec-ecapa-voxceleb"): +# # lazy torchaudio patch +# try: +# import torchaudio +# if not hasattr(torchaudio, "list_audio_backends"): +# torchaudio.list_audio_backends = lambda: ["sox_io"] +# except Exception: +# pass + +# try: +# from speechbrain.inference import EncoderClassifier +# except Exception as e: +# raise ImportError( +# "SpeechBrain could not load. Torchaudio is incompatible.\n" +# f"Original error: {e}" +# ) + +# self.model = EncoderClassifier.from_hparams( +# source=model_path, +# run_opts={"device": "cpu"}, +# savedir="pretrained_models/ecapa", +# ) + +# def _prepare_waveform(self, wav): +# # Convert numpy → tensor +# if not isinstance(wav, torch.Tensor): +# wav = torch.tensor(wav, dtype=torch.float32) + +# # Ensure shape (1, T) +# if wav.ndim == 1: +# wav = wav.unsqueeze(0) + +# # If multichannel, average +# if wav.shape[0] > 1: +# wav = wav.mean(dim=0, keepdim=True) + +# # Pad waves shorter than 1 sec (ECAPA expects enough frames) +# min_len = 16000 # 1 s at 16kHz +# if wav.shape[1] < min_len: +# pad_len = min_len - wav.shape[1] +# wav = torch.nn.functional.pad(wav, (0, pad_len)) + +# return wav + +# def _normalize(self, emb): +# emb = np.asarray(emb).squeeze() +# norm = np.linalg.norm(emb) +# if norm == 0: +# return emb +# return emb / norm + +# def embed_file(self, path): +# try: +# waveform, _ = load_audio(path) +# waveform = self._prepare_waveform(waveform) + +# with torch.no_grad(): +# emb = self.model.encode_batch(waveform)[0].cpu().numpy() + +# return self._normalize(emb) + +# except Exception as e: +# print(f"\nEmbedding failed for: {path}") +# raise +# def emb_waveform(self, waveform): +# waveform = self._prepare_waveform(waveform) + +# with torch.no_grad(): +# emb = self.model.encode_batch(waveform)[0].cpu().numpy() + +# return self._normalize(emb) + +# def extract(self, waveform): +# return self.emb_waveform(waveform) + + import torch -from .audio_utils import load_audio import numpy as np +from .audio_utils import load_audio + class EmbeddingExtractor: def __init__(self, model_path="speechbrain/spkrec-ecapa-voxceleb"): - # lazy torchaudio patch + # Lazy torchaudio compatibility patch try: import torchaudio + if not hasattr(torchaudio, "list_audio_backends"): torchaudio.list_audio_backends = lambda: ["sox_io"] + except Exception: pass try: from speechbrain.inference import EncoderClassifier + except Exception as e: raise ImportError( - "SpeechBrain could not load. Torchaudio is incompatible.\n" + "SpeechBrain could not load.\n" f"Original error: {e}" ) @@ -27,53 +113,98 @@ def __init__(self, model_path="speechbrain/spkrec-ecapa-voxceleb"): savedir="pretrained_models/ecapa", ) - def _prepare_waveform(self, wav): - # Convert numpy → tensor - if not isinstance(wav, torch.Tensor): - wav = torch.tensor(wav, dtype=torch.float32) + def _prepare_waveform(self, waveform): + """ + Convert waveform into the format expected by SpeechBrain. + + Expected shape: + (1, T) + """ + + if not isinstance(waveform, torch.Tensor): + waveform = torch.tensor( + waveform, + dtype=torch.float32 + ) - # Ensure shape (1, T) - if wav.ndim == 1: - wav = wav.unsqueeze(0) + # Convert (T,) -> (1, T) + if waveform.ndim == 1: + waveform = waveform.unsqueeze(0) - # If multichannel, average - if wav.shape[0] > 1: - wav = wav.mean(dim=0, keepdim=True) + # Stereo -> Mono + if waveform.shape[0] > 1: + waveform = waveform.mean( + dim=0, + keepdim=True + ) - # Pad waves shorter than 1 sec (ECAPA expects enough frames) - min_len = 16000 # 1 s at 16kHz - if wav.shape[1] < min_len: - pad_len = min_len - wav.shape[1] - wav = torch.nn.functional.pad(wav, (0, pad_len)) + # Pad to at least 1 second + min_len = 16000 - return wav + if waveform.shape[1] < min_len: + waveform = torch.nn.functional.pad( + waveform, + (0, min_len - waveform.shape[1]) + ) + + return waveform + + def _normalize(self, embedding): + embedding = np.asarray(embedding).squeeze() + + norm = np.linalg.norm(embedding) - def _normalize(self, emb): - emb = np.asarray(emb).squeeze() - norm = np.linalg.norm(emb) if norm == 0: - return emb - return emb / norm + return embedding + + return embedding / norm def embed_file(self, path): + """ + Load a WAV file and return a normalized speaker embedding. + """ + try: - waveform = load_audio(path) + waveform, _ = load_audio(path) + waveform = self._prepare_waveform(waveform) - except Exception: - return None - with torch.no_grad(): - emb = self.model.encode_batch(waveform)[0].cpu().numpy() + with torch.no_grad(): + embedding = ( + self.model + .encode_batch(waveform) + .squeeze() + .cpu() + .numpy() + ) + + return self._normalize(embedding) - return self._normalize(emb) + except Exception as e: + print(f"\nEmbedding failed for file: {path}") + print(f"Reason: {e}") + raise def emb_waveform(self, waveform): + """ + Create an embedding directly from a waveform tensor. + """ + waveform = self._prepare_waveform(waveform) with torch.no_grad(): - emb = self.model.encode_batch(waveform)[0].cpu().numpy() + embedding = ( + self.model + .encode_batch(waveform) + .squeeze() + .cpu() + .numpy() + ) - return self._normalize(emb) + return self._normalize(embedding) def extract(self, waveform): + """ + Compatibility wrapper. + """ return self.emb_waveform(waveform) \ No newline at end of file diff --git a/src/vocalid/enroll_and_authenticate.py b/src/vocalid/enroll_and_authenticate.py index a501bbe..88db58e 100644 --- a/src/vocalid/enroll_and_authenticate.py +++ b/src/vocalid/enroll_and_authenticate.py @@ -9,9 +9,9 @@ import glob -from voice_verifier.enrollment import EnrollmentSession -from voice_verifier.trainer import VoiceTrainer -from voice_verifier.auth_adapter import VoiceAuthenticator +from vocalid.enrollment import EnrollmentSession +from vocalid.trainer import VoiceTrainer +from vocalid.auth_adapter import VoiceAuthenticator DATASET_ROOT = "dataset" MODEL_PATH = "my_voice_model.pkl" diff --git a/src/vocalid/enrollment.py b/src/vocalid/enrollment.py index ffb4872..fd1b65b 100644 --- a/src/vocalid/enrollment.py +++ b/src/vocalid/enrollment.py @@ -1,12 +1,12 @@ """ enrollment.py -Ties recorder + validator + duplicate_detector + dataset_manager + +Ties recorder + validator + dataset_manager together into one guided session: - record clip -> validate -> check duplicate -> save to dataset + record clip -> validate -> save to dataset -Retries a rejected clip instead of silently skipping it, so the user -always ends up with the number of samples they asked for. +Retries a rejected clip instead of silently skipping it. """ import os @@ -14,57 +14,163 @@ from .recorder import record_one from .validator import check_audio -from .duplicate_detector import DuplicateDetector from .dataset_manager import DatasetManager from . import config class EnrollmentSession: - def __init__(self, dataset_root: str = "dataset", max_attempts_per_sample: int = 3): + + def __init__( + self, + dataset_root: str = "dataset", + max_attempts_per_sample: int = 3 + ): + self.dataset = DatasetManager(dataset_root) - self.dupe_detector = DuplicateDetector() self.max_attempts_per_sample = max_attempts_per_sample - def enroll(self, label: str, count: int = 10, seconds: float = 5.0) -> list[str]: + def enroll( + self, + label: str, + count: int = 10, + seconds: float = 5.0 + ) -> list[str]: + """ - Records and accepts `count` valid, non-duplicate clips for - `label` ("positive" or "negative"). Returns the saved paths. + Records and accepts `count` valid clips for + `label` ("positive" or "negative"). + + Returns saved file paths. + + Label options: + + positive: + Target/enrolled speaker voice samples + + negative: + Other speaker voice samples + + Flow: + + record clip + | + validate audio quality + | + save accepted clip """ + + # Validate label before doing anything + if label not in ("positive", "negative"): + raise ValueError( + "label must be 'positive' or 'negative'" + ) + + print(""" +================================================== + VocalID Enrollment Instructions +================================================== + +Label options: + + positive -> Target/enrolled speaker voice samples + + negative -> Other speaker voice samples + + +Enrollment flow: + + record clip + | + validate audio quality + | + save accepted clip + + +Recording guidelines: + + - Duration: 4-6.5 seconds + - Speak naturally + - Avoid silence + - Avoid background noise + +================================================== +""") + saved_paths = [] for i in range(count): + accepted = False - for attempt in range(1, self.max_attempts_per_sample + 1): - print(f"\nSample {i + 1}/{count} (attempt {attempt}) - label: {label}") - audio = record_one(seconds, config.SAMPLE_RATE) + for attempt in range( + 1, + self.max_attempts_per_sample + 1 + ): + + print( + f"\nSample {i + 1}/{count} " + f"(attempt {attempt}) " + f"- label: {label}" + ) + + audio = record_one( + seconds, + config.SAMPLE_RATE + ) + + with tempfile.NamedTemporaryFile( + suffix=".wav", + delete=False + ) as tmp: - with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: tmp_path = tmp.name + from .audio_utils import save_audio - save_audio(audio, tmp_path, config.SAMPLE_RATE) - is_valid, reason = check_audio(tmp_path) + save_audio( + audio, + tmp_path, + config.SAMPLE_RATE + ) + + # Validate audio + is_valid, reason = check_audio( + tmp_path + ) + if not is_valid: - print(f"Rejected: {reason}. Try again.") - os.remove(tmp_path) - continue - existing = self.dataset.list_samples(label) + saved_paths - dupe = self.dupe_detector.find_duplicate(tmp_path, existing) - if dupe: - print(f"Rejected: too similar to {os.path.basename(dupe)}. Try again.") + print( + f"Rejected: {reason}. Try again." + ) + os.remove(tmp_path) continue - saved_path = self.dataset.add_sample(tmp_path, label) + # Save accepted sample + saved_path = self.dataset.add_sample( + tmp_path, + label + ) + os.remove(tmp_path) - saved_paths.append(saved_path) - print(f"Saved as {saved_path}") + + saved_paths.append( + saved_path + ) + + print( + f"Saved as {saved_path}" + ) + accepted = True break if not accepted: - print(f"Giving up on sample {i + 1} after {self.max_attempts_per_sample} attempts.") - return saved_paths + print( + f"Giving up on sample {i + 1} " + f"after {self.max_attempts_per_sample} attempts." + ) + + return saved_paths \ No newline at end of file diff --git a/src/vocalid/recorder.py b/src/vocalid/recorder.py index 0dddd02..e4389ab 100644 --- a/src/vocalid/recorder.py +++ b/src/vocalid/recorder.py @@ -1,43 +1,113 @@ """ recorder.py -Records short audio clips one at a time for voice enrollment. -Assumes voice_verifier.audio_utils already exposes a `record_audio` -function (mic -> tensor/np array) and a `save_audio` function, -matching what the library's live-verification path uses. -If your actual audio_utils.py names these differently, just -adjust the two imports below. +Records audio clips for enrollment. """ + import os + from .audio_utils import record_audio, save_audio from . import config -def record_one(seconds: float = 5.0, sample_rate: int = None): - """Record a single clip and return the raw audio tensor.""" - sample_rate = sample_rate or config.SAMPLE_RATE - print(f"Recording for {seconds:.1f}s... speak naturally.") - audio = record_audio(duration=seconds, sample_rate=sample_rate) - print("Done.") + +def record_one( + seconds: float = 5.0, + sample_rate: int = None +): + + """ + Record one audio clip. + """ + + sample_rate = ( + sample_rate + or config.SAMPLE_RATE + ) + + + print( + f"Recording for {seconds:.1f}s... speak naturally." + ) + + + audio = record_audio( + duration=seconds, + sample_rate=sample_rate + ) + + + print( + "Done." + ) + + return audio -def record_batch(out_dir: str, count: int = 10, seconds: float = 5.0, - sample_rate: int = None, prefix: str = "sample"): + + +def record_batch( + out_dir: str, + count: int = 10, + seconds: float = 5.0, + sample_rate: int = None, + prefix: str = "sample" +): + """ - Record `count` clips back to back, saving each to `out_dir`. - Returns the list of saved file paths (unvalidated). + Record multiple clips and save them. + + Returns: + list of saved paths """ - os.makedirs(out_dir, exist_ok=True) - sample_rate = sample_rate or config.SAMPLE_RATE + + + os.makedirs( + out_dir, + exist_ok=True + ) + + + sample_rate = ( + sample_rate + or config.SAMPLE_RATE + ) + + paths = [] + for i in range(count): - input(f"[{i + 1}/{count}] Press Enter, then start speaking...") - audio = record_one(seconds, sample_rate) - path = os.path.join(out_dir, f"{prefix}_{i + 1:03d}.wav") - save_audio(audio, path, sample_rate) - paths.append(path) - return paths + input( + f"[{i+1}/{count}] Press Enter, then start speaking..." + ) + + + audio = record_one( + seconds, + sample_rate + ) + + + path = os.path.join( + out_dir, + f"{prefix}_{i+1:03d}.wav" + ) + + + save_audio( + audio, + path, + sample_rate + ) + + + paths.append( + path + ) + + + return paths \ No newline at end of file diff --git a/src/vocalid/trainer.py b/src/vocalid/trainer.py index 7dfc6fa..4abe883 100644 --- a/src/vocalid/trainer.py +++ b/src/vocalid/trainer.py @@ -31,7 +31,9 @@ def train(self, positive_paths, negative_paths, save_path="voice_auth.pkl"): y.append(1) for p in negative_paths: + print(f"Loading {p}") emb = self.extractor.embed_file(p) + print(type(emb), None if emb is None else emb.shape) if emb is None: continue X.append(normalize(emb)) diff --git a/src/vocalid/verifier.py b/src/vocalid/verifier.py index 12748c7..4745dc0 100644 --- a/src/vocalid/verifier.py +++ b/src/vocalid/verifier.py @@ -10,7 +10,7 @@ def __init__(self, model_path): self.extractor = EmbeddingExtractor() def verify_file(self, file_path, threshold=THRESHOLD): - wav = load_audio(file_path) # waveform tensor + wav,_ = load_audio(file_path) # waveform tensor emb = self.extractor.emb_waveform(wav) # embedding as numpy score = self.model.predict_proba([emb])[0][1] verified = score >= threshold diff --git a/tests/test_auth_adapter.py b/tests/test_auth_adapter.py index d7de448..6ea7675 100644 --- a/tests/test_auth_adapter.py +++ b/tests/test_auth_adapter.py @@ -1,14 +1,18 @@ """ test_auth_adapter.py -VoiceVerifier is mocked out so this tests the adapter's translation -of (verified, score) -> AuthenticationResult, not the real -verification model. +VoiceVerifier is mocked so these tests only verify that +VoiceAuthenticator correctly converts verification results +into AuthenticationResult objects. """ import pytest + import vocalid.auth_adapter as auth_module -from vocalid.auth_adapter import VoiceAuthenticator, AuthenticationResult +from vocalid.auth_adapter import ( + VoiceAuthenticator, + AuthenticationResult, +) class _FakeVerifier: @@ -18,52 +22,82 @@ def __init__(self, model_path): def verify_file(self, path): return True, 0.87 - def verify_live(self, audio): + def verify_array(self, audio): return False, 0.21 @pytest.fixture def authenticator(monkeypatch): - monkeypatch.setattr(auth_module, "VoiceVerifier", _FakeVerifier) + monkeypatch.setattr( + auth_module, + "VoiceVerifier", + _FakeVerifier + ) + return VoiceAuthenticator("dummy_model.pkl") def test_authenticate_file_returns_granted_result(authenticator): + result = authenticator.authenticate_file("some_clip.wav") + assert isinstance(result, AuthenticationResult) assert result.success is True assert result.confidence == 0.87 -def test_authenticate_live_returns_denied_result(authenticator, monkeypatch): - # authenticate_live does `from .recorder import record_one` internally, - # so the real patch point is vocalid.recorder.record_one. - monkeypatch.setattr("vocalid.recorder.record_one", - lambda seconds, sample_rate: "fake_audio") +def test_authenticate_live_returns_denied_result( + authenticator, + monkeypatch +): + + monkeypatch.setattr( + "vocalid.recorder.record_one", + lambda seconds, sample_rate: "fake_audio" + ) - result = authenticator.authenticate_live(seconds=4.0) + result = authenticator.authenticate_live( + seconds=4.0 + ) assert result.success is False assert result.confidence == 0.21 -def test_authenticate_live_defaults_to_four_seconds(authenticator, monkeypatch): +def test_authenticate_live_defaults_to_four_seconds( + authenticator, + monkeypatch +): + captured = {} def fake_record_one(seconds, sample_rate): captured["seconds"] = seconds return "fake_audio" - monkeypatch.setattr("vocalid.recorder.record_one", fake_record_one) - authenticator.authenticate_live(seconds=None) + monkeypatch.setattr( + "vocalid.recorder.record_one", + fake_record_one + ) + + authenticator.authenticate_live() assert captured["seconds"] == 4.0 def test_result_str_formatting(): - granted = AuthenticationResult(success=True, confidence=0.9123) - denied = AuthenticationResult(success=False, confidence=0.1) + + granted = AuthenticationResult( + success=True, + confidence=0.9123 + ) + + denied = AuthenticationResult( + success=False, + confidence=0.1 + ) assert "ACCESS GRANTED" in str(granted) assert "0.91" in str(granted) - assert "ACCESS DENIED" in str(denied) + + assert "ACCESS DENIED" in str(denied) \ No newline at end of file diff --git a/tests/test_duplicate_detector.py b/tests/test_duplicate_detector.py deleted file mode 100644 index 05bf35b..0000000 --- a/tests/test_duplicate_detector.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -test_duplicate_detector.py - -The real EmbeddingExtractor loads a full ECAPA-TDNN model from -speechbrain, which is slow and needs a network connection on first -run. For unit tests we swap it for a fake extractor that just turns -the raw audio into its "embedding" - two clips with the same tone -frequency come out identical, different frequencies come out -different, which is enough to exercise the cosine-similarity logic -in duplicate_detector.py itself. Swap this fixture out once you're -ready to write a slower, real-model integration test. -""" - -import numpy as np -import soundfile as sf -import pytest - -import vocalid.duplicate_detector as dd_module -from vocalid.duplicate_detector import DuplicateDetector - - -class _FakeExtractor: - def extract(self, audio): - return np.asarray(audio).flatten() - - -def _fake_load_audio(path): - samples, sample_rate = sf.read(path, dtype="float32") - return samples, sample_rate - - -@pytest.fixture -def detector(monkeypatch): - monkeypatch.setattr(dd_module, "EmbeddingExtractor", _FakeExtractor) - monkeypatch.setattr(dd_module, "load_audio", _fake_load_audio) - return DuplicateDetector() - - -def test_identical_clip_is_flagged(detector, write_wav): - original = write_wav("original.wav", freq=220.0) - replay = write_wav("replay.wav", freq=220.0) # same tone == same "voice" - - match = detector.find_duplicate(replay, [original]) - assert match == original - assert detector.is_duplicate(replay, [original]) is True - - -def test_different_clip_is_not_flagged(detector, write_wav): - original = write_wav("original.wav", freq=220.0) - different = write_wav("different.wav", freq=880.0) # different tone == different "voice" - - match = detector.find_duplicate(different, [original]) - assert match is None - assert detector.is_duplicate(different, [original]) is False - - -def test_no_existing_clips_never_flags(detector, write_wav): - clip = write_wav("only.wav") - assert detector.find_duplicate(clip, []) is None - - -def test_custom_threshold_is_respected(write_wav, monkeypatch): - monkeypatch.setattr(dd_module, "EmbeddingExtractor", _FakeExtractor) - monkeypatch.setattr(dd_module, "load_audio", _fake_load_audio) - - strict_detector = DuplicateDetector(threshold=0.999999) - original = write_wav("original.wav", freq=220.0, amplitude=0.3) - slightly_different = write_wav("close.wav", freq=222.0, amplitude=0.3) - - # Close but not identical tones shouldn't clear an extremely strict threshold. - assert strict_detector.is_duplicate(slightly_different, [original]) is False diff --git a/tests/test_enrollment.py b/tests/test_enrollment.py index f77c40a..653ec85 100644 --- a/tests/test_enrollment.py +++ b/tests/test_enrollment.py @@ -1,86 +1,151 @@ """ test_enrollment.py -record_one, audio_utils.save_audio, and DuplicateDetector are all -mocked so this exercises EnrollmentSession's retry/accept/give-up -logic without needing a mic, a real audio file, or the ECAPA model. -DatasetManager is used for real (against tmp_path) since it's just -file I/O and worth testing end to end here. +Unit tests for EnrollmentSession. + +External dependencies are mocked: + +- recorder.record_one +- validator.check_audio +- audio_utils.save_audio + +DatasetManager is used normally against pytest's temporary directory. """ import pytest + import vocalid.enrollment as enrollment_module from vocalid.enrollment import EnrollmentSession -class _FakeDupeDetector: - """queue of paths to report as duplicates, one per find_duplicate() call""" - def __init__(self, duplicate_paths=None): - self._duplicate_paths = list(duplicate_paths or []) - - def find_duplicate(self, new_path, existing_paths): - return self._duplicate_paths.pop(0) if self._duplicate_paths else None - +# --------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------- @pytest.fixture(autouse=True) def patch_recording(monkeypatch): - monkeypatch.setattr(enrollment_module, "record_one", lambda seconds, sample_rate: "fake_audio") - # save_audio is imported lazily inside enroll(), so the real patch - # point is the audio_utils module itself, not enrollment_module. - monkeypatch.setattr("vocalid.audio_utils.save_audio", lambda audio, path, sr: None) + + monkeypatch.setattr( + enrollment_module, + "record_one", + lambda seconds, sample_rate: "fake_audio" + ) + + monkeypatch.setattr( + "vocalid.audio_utils.save_audio", + lambda audio, path, sr: None + ) -def test_accepts_a_valid_unique_clip_on_first_try(monkeypatch, tmp_path): - monkeypatch.setattr(enrollment_module, "check_audio", lambda path: (True, "ok")) - monkeypatch.setattr(enrollment_module, "DuplicateDetector", lambda: _FakeDupeDetector()) +# --------------------------------------------------------- +# Tests +# --------------------------------------------------------- - session = EnrollmentSession(dataset_root=str(tmp_path / "dataset")) - saved = session.enroll(label="positive", count=1, seconds=5.0) +def test_accepts_valid_clip(monkeypatch, tmp_path): - assert len(saved) == 1 + monkeypatch.setattr( + enrollment_module, + "check_audio", + lambda path: (True, "ok") + ) + session = EnrollmentSession( + dataset_root=str(tmp_path / "dataset") + ) -def test_retries_after_a_rejected_clip(monkeypatch, tmp_path): - results = iter([(False, "too quiet"), (True, "ok")]) - monkeypatch.setattr(enrollment_module, "check_audio", lambda path: next(results)) - monkeypatch.setattr(enrollment_module, "DuplicateDetector", lambda: _FakeDupeDetector()) + saved = session.enroll( + label="positive", + count=1, + seconds=5.0 + ) - session = EnrollmentSession(dataset_root=str(tmp_path / "dataset"), max_attempts_per_sample=3) - saved = session.enroll(label="positive", count=1, seconds=5.0) + assert len(saved) == 1 - assert len(saved) == 1 # accepted on the second attempt +def test_retries_after_validation_failure(monkeypatch, tmp_path): + + results = iter([ + (False, "too quiet"), + (True, "ok") + ]) -def test_retries_after_a_duplicate_clip(monkeypatch, tmp_path): - monkeypatch.setattr(enrollment_module, "check_audio", lambda path: (True, "ok")) - # first attempt flagged as a duplicate, second attempt is clean monkeypatch.setattr( - enrollment_module, "DuplicateDetector", - lambda: _FakeDupeDetector(duplicate_paths=["dataset/my_voice/sample001.wav"]) + enrollment_module, + "check_audio", + lambda path: next(results) + ) + + session = EnrollmentSession( + dataset_root=str(tmp_path / "dataset"), + max_attempts_per_sample=3 ) - session = EnrollmentSession(dataset_root=str(tmp_path / "dataset"), max_attempts_per_sample=3) - saved = session.enroll(label="positive", count=1, seconds=5.0) + saved = session.enroll( + label="positive", + count=1, + seconds=5.0 + ) assert len(saved) == 1 def test_gives_up_after_max_attempts(monkeypatch, tmp_path): - monkeypatch.setattr(enrollment_module, "check_audio", lambda path: (False, "too quiet")) - monkeypatch.setattr(enrollment_module, "DuplicateDetector", lambda: _FakeDupeDetector()) - session = EnrollmentSession(dataset_root=str(tmp_path / "dataset"), max_attempts_per_sample=2) - saved = session.enroll(label="positive", count=1, seconds=5.0) + monkeypatch.setattr( + enrollment_module, + "check_audio", + lambda path: (False, "too quiet") + ) + + session = EnrollmentSession( + dataset_root=str(tmp_path / "dataset"), + max_attempts_per_sample=2 + ) + + saved = session.enroll( + label="positive", + count=1, + seconds=5.0 + ) + + assert saved == [] + - assert saved == [] # never accepted, session moved on +def test_returns_path_for_every_sample(monkeypatch, tmp_path): + monkeypatch.setattr( + enrollment_module, + "check_audio", + lambda path: (True, "ok") + ) -def test_returns_a_path_for_every_accepted_sample(monkeypatch, tmp_path): - monkeypatch.setattr(enrollment_module, "check_audio", lambda path: (True, "ok")) - monkeypatch.setattr(enrollment_module, "DuplicateDetector", lambda: _FakeDupeDetector()) + session = EnrollmentSession( + dataset_root=str(tmp_path / "dataset") + ) - session = EnrollmentSession(dataset_root=str(tmp_path / "dataset")) - saved = session.enroll(label="negative", count=3, seconds=5.0) + saved = session.enroll( + label="negative", + count=3, + seconds=5.0 + ) assert len(saved) == 3 - assert len(set(saved)) == 3 # all distinct paths, no overwrites + assert len(set(saved)) == 3 + + +def test_invalid_label_is_rejected(tmp_path): + + session = EnrollmentSession( + dataset_root=str(tmp_path / "dataset") + ) + + with pytest.raises( + ValueError, + match="label must be 'positive' or 'negative'" + ): + + session.enroll( + label="kai", + count=1, + seconds=5.0 + ) \ No newline at end of file From 55448bea57a0e2aa746857024e56c9cfb31940a3 Mon Sep 17 00:00:00 2001 From: tayyaba-code Date: Sat, 18 Jul 2026 21:36:31 +0500 Subject: [PATCH 03/10] Add Python 3.8 type annotation compatibility --- dummy_model.pkl | Bin 2399 -> 2399 bytes src/vocalid/dataset_manager.py | 15 +++++++++++++-- src/vocalid/enrollment.py | 15 +++++++++++++-- src/vocalid/validator.py | 15 +++++++++++++-- test.py | 21 +++++++++++++++++++++ 5 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 test.py diff --git a/dummy_model.pkl b/dummy_model.pkl index feab21ddec8de4a4ed032b2b2a8b4049927403bb..036c2ced9c9b17bf7d2c7f9c6bb4f1c636a77d88 100644 GIT binary patch delta 1578 zcmV~$3n0`77yxiI&sm8GNe{0y7OlLt>zh|o^QLT?^{^$CNKqsGFT`*j-FcsL?oRI9 zad*0#L-8Rpi;B|ABZnT#P(xj%Yro$U{U`e30yR|)+9kF9#C0{N{)OK;C{fxi%zsFL zXJzJhlS~Uie|=wE+4?HDwIZ3%`k@`@pELOs2Px!bPYisadITRC86I3xlR{?fSCQVF z28`*Bc`x%n1sA%D+OAlOK~S+ks1M69!qk(h_EG|hTnqJPMjQtII&{=&F%QGMThfgZ zuM$8t=cgR~`3P2io$bM{)q;GWJL51kF4X+MtzYOD}jvv0ssYA_U=XxRix9NlcuNPSQthp~irki=vSpx#M(qt+Ms6u<&P4){TS8ys47uJ!i^e3Z#&ifA%=S8OrrcE z+&MBgg7YHKp1@U!-c{K9{&45Zh+OD?vU%dWat;V2pOFASt40@ z0$X%*UZfXFp`&7+5+O{09**WmFAot6FB_cN0}ZG{o+H(b{sw0XK4?0)ia;|=CCKKP z1Q&0%GS8~9ao6sufY&1p*g7Z9DDX$KrZKd3odZ)ldGk{Z(B)|ldNp*!X z7Y0HtK-E}))0s*lX3JC)T!7MZSvD*nW!J_oMU|tmmo|u!_!!{-scN^314M7Q*zm9v zwliz)DW3~4u73u@x~oC`v6HS#d@UMs4|{Lu5<^6Y8FRz(^Wb!xq)M}>1g{oX1E24^ zKqro1Y9`i#EsoGwU4@{!*p>Hs zO2O}^wzG!kx!_Vs_bUs`fXR@LEoXhQakwOO%MKGNj7r#s_0@Dpt7b_e^*PWxc`$MO zkO=!2$jCK9R17aQ*}lTl7CNz;ou2R{CT&Za6-2=<+b82gEfsk3jQerNxI>F4cga&_u} zE%Goa{EUTupDJEx1oJTE`k^hGcXE(4y_0uK+q zyEZP)hrLeR6oeYw2D@#h6e6r)%Ybo4x+ptBdk_ng6OP8pG&WAVn$I-9WrMOyTiisWWAJU?0GG5v>=^jB zb1(5+49=aS+;m1g2ABO!I?%!f{R_ECMnB|%vxjKp>6&X0Cuy?m6AD4gNa5c4rWlt* zzn)<6;_-%8pZn?jI$YZvs@^jTfMJU>C3k74T$WfFUR;PNg1F@&S;a_c#UGCqQZJ1!Szmf?jzVr$Rj=loo4vJ(>+W|)GgTKBbaUkbSmz^>3BHmrvAAHkP zgqBjh5JzS%1E>2R8+z-h;j9&{)t)a$FGWzOK~NUnVrd?Io~FQvoL%#ylYC6;J5MM` zXn|#oxt;YK4-ZYzBNZMhBrG(4qA2f%L>s1JpjriY%^Zz&`UPl}Eqi51WTMw>iQky0 z4LW~cuViko#+hK%O2z~eHjc#w4zDt*;AQepQMupzfUyT`sTK#T;DRoVq&Hgve`fsk zBIv3GQlIy++5QJ$Oh_uKXBR^8TvBj|@(Qk7d{u0rQe!#0Y-r0Z1rGIv4#dng;%G_h zo@1Me(X>%!lH=O;URxu=moO|49*y-MW*m=U$Hnl2bK0MoI)y=bem*Y7GW# zjpPOlR)X?fRnXn)tH3KaWOZ*70y(6ewof94IJK_#p-2kH4_Mx3Evi6M>27CSD#Rj( zwL={*CE#9{HDe=d#)gBkn^r*_Y_~3$@`&z(E~6IN&kbs1y(I~^^k%|1A=jh!z6dYh zT)T!5PKW{v>egqLS`kp4V?y`(s&UcbH_F7{a-@psNxz>;#*D;C*T(cF-0XH^@_n=r zc6|AG`qh;tG(R?M%y8~RuB-EcXk9WU8qzjoXqA}rL7a2CQ3grs2Kp^3A2Z2$t^Upe zOk{UEQDhg;#W~xd2vty_)Mw0e(MoVd93M9xCdWZf2hVtEBba%6H%!rsQ1Gl;=VYqJ ziD2tO0;d#sds<)6=Y?>luyylvB@<@|9%!8sILyA9Mr?p~ zRmH)EG&={+ezisQ!^qbC1)8)&)&3)|{*d*W7tp z)Nut|j{fj%q)9t6bj*8dL1H)L-;a#)DrJIOxNo0O2!?dy8RJOhW`&^UH-6|`*n zC1a123bTdm1&|KV)+FwUVSbAU-=TU?2X6YfX)|aDO+=%!wS&p}_ z{nfiZDjv@doi*z5<$%r275*Q4%VG0BV{FYxqWJ4~HzB*<6oKk?F){oN9J*vRGJ00nQ=RzoBkECql^I_LWM*MfU5%-aXFVva~ z;Js|M*(tdY49EI8)MYuU5}F?7=w*XXT&<@vg^M50ZHMPGtr$-1>vxngggE`SVnd8g z9w`5FLin@KMI@$ZCG)Yp7^d-kGP9D6ry~fK*VpDEC3-ON(nmE0v^;Qcw!MZg`piT1 zrsZHc5_Nl{S3SD54>AG?mEiyHu8K}|E10M56$uvn;Y3N|c+IE`L?)&rTdUh3O}bO)?rqk5#*Y str: raise ValueError("label must be 'positive' or 'negative'") return self.positive_dir if label == "positive" else self.negative_dir - def list_samples(self, label: str) -> list[str]: + def list_samples(self, label: str) -> List[str]: target_dir = self._target_dir(label) return [ os.path.join(target_dir, f) diff --git a/src/vocalid/enrollment.py b/src/vocalid/enrollment.py index fd1b65b..d932b45 100644 --- a/src/vocalid/enrollment.py +++ b/src/vocalid/enrollment.py @@ -8,7 +8,18 @@ Retries a rejected clip instead of silently skipping it. """ - +# src/vocalid/typing_compat.py + +from typing import ( + List, + Dict, + Tuple, + Set, + Optional, + Union, + Any, + Callable, +) import os import tempfile @@ -34,7 +45,7 @@ def enroll( label: str, count: int = 10, seconds: float = 5.0 - ) -> list[str]: + ) -> List[str]: """ Records and accepts `count` valid clips for diff --git a/src/vocalid/validator.py b/src/vocalid/validator.py index 05452d8..7ff549b 100644 --- a/src/vocalid/validator.py +++ b/src/vocalid/validator.py @@ -6,7 +6,18 @@ Uses audio_utils.load_audio(path) -> (tensor, sample_rate), which the library already needs for file-based verification. """ - +# src/vocalid/typing_compat.py + +from typing import ( + List, + Dict, + Tuple, + Set, + Optional, + Union, + Any, + Callable, +) import numpy as np from .audio_utils import load_audio @@ -23,7 +34,7 @@ def _to_numpy(audio): return np.asarray(audio).flatten() -def check_audio(path: str) -> tuple[bool, str]: +def check_audio(path: str) -> Tuple[bool, str]: """ Returns (is_valid, reason). reason is 'ok' when valid, otherwise a short human-readable explanation of why it was rejected. diff --git a/test.py b/test.py new file mode 100644 index 0000000..e04ce6b --- /dev/null +++ b/test.py @@ -0,0 +1,21 @@ +from pathlib import Path + +replacements = { + "list[": "List[", + "dict[": "Dict[", + "tuple[": "Tuple[", + "set[": "Set[", +} + +for file in Path("src").rglob("*.py"): + text = file.read_text(encoding="utf-8") + + changed = False + for old, new in replacements.items(): + if old in text: + text = text.replace(old, new) + changed = True + + if changed: + file.write_text(text, encoding="utf-8") + print("Updated", file) \ No newline at end of file From 8a146d78d5fc11bac58b901cb3a422f458785ee7 Mon Sep 17 00:00:00 2001 From: tayyaba-code Date: Wed, 22 Jul 2026 18:00:58 +0500 Subject: [PATCH 04/10] Replace print statements with logging --- dummy_model.pkl | Bin 2399 -> 2399 bytes src/vocalid/audio_utils.py | 12 +++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/dummy_model.pkl b/dummy_model.pkl index 036c2ced9c9b17bf7d2c7f9c6bb4f1c636a77d88..1533bf81d641cc665f6c124fb569d92a73dc23c4 100644 GIT binary patch delta 1578 zcmWO5jXTo`902f0Vd?HBmGaC9-6F)7zUV2kV(q$xhiQBGdLnH6onU~>g zF=8Y8jcE+uMG2kWB}DI?fd@r4l5*IA~A=w;ybPWO->{*$7|yGOi|W3BjF^T!_YIRP3O z^y%(h)&M-yF_rngE5NrhWqWNcf^Fi~nx)Jpd^&hN;npoSM7YbzW7HP#9NhME_Fe_r zyH!2hGcRR=>?!GSDlG%s9<5sErk@YJO)g(=aTVgUV5!vGq!Root2p|qI+)>Y-(tE# zg<&aP_gt`yaNa%>FUr*<^UIY`to#t(J4%Jl8q2(OhRIMN zVYv727QsIa_k9yy^C0+yqbj|ag|hQJV#1{g$+13d&Xmh=W+3-49;<F@*+~rf%1`lPvjaLu{Xj(6*1;jdOjFbx06FQ871ZSdn6sWWoO>jN z*mT2i(Uo=x@_psHIi(&?(LxWlI~Jlrg_0zG$A*IYnQ_;2#5nOw>`QK5G$ay{+xNFf zurzR}CuLU#P@iQq4KS2w`|b;-XO6fUe0;8JRcLeY%>A9)10OcQpN0>Y49cr z^2r|!LNUflyBeHfYFL@uh?8bstlsZ0!N=EiohYM-R-pYoxo|K<2Ca+;atFb~)2qZ@#`ja<$uv}@dRy{3&CpZ20mmVZT0n=o+KAO*f( z_|}Q^TRC3MO1QhPUjoccjB)0WfIynX>SMRN8ej!3`mVGh&@RbjS9Gfg1wZNTP+EV+ zW1(0%H&Y2F85XOJw92sad7w*VUk%p3B^kGhN@)GGdaHN&^}8b1G~HD+>{w9dt&?pZ_<_%XL$z1k%+@Q_Lo+FwR70w^d^^ z2v3;5Po1s7U9X(hMQ9e_8|?s}O9@G6zGPw3oGk&5!OXc#?z&suFb2FrC-LRm*K*`yG%aL*f)oI z{F9DJ!qijaX<6`w>6oZj!bVC%(;t(^3qfa}!S1di9?}e-dNz-qMWUQydPmp{gY~Sn z-{`kvFS|UNSx}9n&ClaD@}yu%s%dBCgu|PAPQ zp9jPEI6DzYoMV30pLd39^zS<{NOR>v|H){uuFC2BKCu=D3H^`Ny#gFK=N;B*>xU15 zisH}zT@R-29Qwd|K4ee5Po812QDr?E7p##13Fk!fig*>SNq*3Gv{ec>{|LU;)}4iJ zU9Pt&;zro%Z)y;9lLOkPijBLfWbi3D>XzP)8w%X6N2AQnUBUd_5>6+~ delta 1578 zcmV~$3n0`77yxiI&sm8GNe{0y7OlLt>zh|o^QLT?^{^$CNKqsGFT`*j-FcsL?oRI9 zad*0#L-8Rpi;B|ABZnT#P(xj%Yro$U{U`e30yR|)+9kF9#C0{N{)OK;C{fxi%zsFL zXJzJhlS~Uie|=wE+4?HDwIZ3%`k@`@pELOs2Px!bPYisadITRC86I3xlR{?fSCQVF z28`*Bc`x%n1sA%D+OAlOK~S+ks1M69!qk(h_EG|hTnqJPMjQtII&{=&F%QGMThfgZ zuM$8t=cgR~`3P2io$bM{)q;GWJL51kF4X+MtzYOD}jvv0ssYA_U=XxRix9NlcuNPSQthp~irki=vSpx#M(qt+Ms6u<&P4){TS8ys47uJ!i^e3Z#&ifA%=S8OrrcE z+&MBgg7YHKp1@U!-c{K9{&45Zh+OD?vU%dWat;V2pOFASt40@ z0$X%*UZfXFp`&7+5+O{09**WmFAot6FB_cN0}ZG{o+H(b{sw0XK4?0)ia;|=CCKKP z1Q&0%GS8~9ao6sufY&1p*g7Z9DDX$KrZKd3odZ)ldGk{Z(B)|ldNp*!X z7Y0HtK-E}))0s*lX3JC)T!7MZSvD*nW!J_oMU|tmmo|u!_!!{-scN^314M7Q*zm9v zwliz)DW3~4u73u@x~oC`v6HS#d@UMs4|{Lu5<^6Y8FRz(^Wb!xq)M}>1g{oX1E24^ zKqro1Y9`i#EsoGwU4@{!*p>Hs zO2O}^wzG!kx!_Vs_bUs`fXR@LEoXhQakwOO%MKGNj7r#s_0@Dpt7b_e^*PWxc`$MO zkO=!2$jCK9R17aQ*}lTl7CNz;ou2R{CT&Za6-2=<+b82gEfsk3jQerNxI>F4cga&_u} zE%Goa{EUTupDJEx1oJTE`k^hGcXE(4y_0uK+q zyEZP)hrLeR6oeYw2D@#h6e6r)%Ybo4x+ptBdk_ng6OP8pG&WAVn$I-9WrMOyTiisWWAJU?0GG5v>=^jB zb1(5+49=aS+;m1g2ABO!I?%!f{R_ECMnB|%vxjKp>6&X0Cuy?m6AD4gNa5c4rWlt* zzn)<6;_-%8pZn?jI$YZvs@^jTfMJU>C3k74T$WfFUR;PNg1F@&S;a_c#UGCq Date: Wed, 22 Jul 2026 18:19:11 +0500 Subject: [PATCH 05/10] Improve embedding error handling and logging --- src/vocalid/embeddings.py | 126 +++++++++----------------------------- 1 file changed, 30 insertions(+), 96 deletions(-) diff --git a/src/vocalid/embeddings.py b/src/vocalid/embeddings.py index 1b02ae2..0e33116 100644 --- a/src/vocalid/embeddings.py +++ b/src/vocalid/embeddings.py @@ -1,90 +1,14 @@ -# import torch -# from .audio_utils import load_audio -# import numpy as np - - -# class EmbeddingExtractor: -# def __init__(self, model_path="speechbrain/spkrec-ecapa-voxceleb"): -# # lazy torchaudio patch -# try: -# import torchaudio -# if not hasattr(torchaudio, "list_audio_backends"): -# torchaudio.list_audio_backends = lambda: ["sox_io"] -# except Exception: -# pass - -# try: -# from speechbrain.inference import EncoderClassifier -# except Exception as e: -# raise ImportError( -# "SpeechBrain could not load. Torchaudio is incompatible.\n" -# f"Original error: {e}" -# ) - -# self.model = EncoderClassifier.from_hparams( -# source=model_path, -# run_opts={"device": "cpu"}, -# savedir="pretrained_models/ecapa", -# ) - -# def _prepare_waveform(self, wav): -# # Convert numpy → tensor -# if not isinstance(wav, torch.Tensor): -# wav = torch.tensor(wav, dtype=torch.float32) - -# # Ensure shape (1, T) -# if wav.ndim == 1: -# wav = wav.unsqueeze(0) - -# # If multichannel, average -# if wav.shape[0] > 1: -# wav = wav.mean(dim=0, keepdim=True) - -# # Pad waves shorter than 1 sec (ECAPA expects enough frames) -# min_len = 16000 # 1 s at 16kHz -# if wav.shape[1] < min_len: -# pad_len = min_len - wav.shape[1] -# wav = torch.nn.functional.pad(wav, (0, pad_len)) - -# return wav - -# def _normalize(self, emb): -# emb = np.asarray(emb).squeeze() -# norm = np.linalg.norm(emb) -# if norm == 0: -# return emb -# return emb / norm - -# def embed_file(self, path): -# try: -# waveform, _ = load_audio(path) -# waveform = self._prepare_waveform(waveform) - -# with torch.no_grad(): -# emb = self.model.encode_batch(waveform)[0].cpu().numpy() - -# return self._normalize(emb) - -# except Exception as e: -# print(f"\nEmbedding failed for: {path}") -# raise -# def emb_waveform(self, waveform): -# waveform = self._prepare_waveform(waveform) - -# with torch.no_grad(): -# emb = self.model.encode_batch(waveform)[0].cpu().numpy() - -# return self._normalize(emb) - -# def extract(self, waveform): -# return self.emb_waveform(waveform) -import torch +import logging + import numpy as np +import torch from .audio_utils import load_audio +logger = logging.getLogger(__name__) + class EmbeddingExtractor: def __init__(self, model_path="speechbrain/spkrec-ecapa-voxceleb"): @@ -96,6 +20,8 @@ def __init__(self, model_path="speechbrain/spkrec-ecapa-voxceleb"): torchaudio.list_audio_backends = lambda: ["sox_io"] except Exception: + # Older/newer torchaudio versions may not expose this API. + # Safe to continue because it is only a compatibility patch. pass try: @@ -105,7 +31,7 @@ def __init__(self, model_path="speechbrain/spkrec-ecapa-voxceleb"): raise ImportError( "SpeechBrain could not load.\n" f"Original error: {e}" - ) + ) from e self.model = EncoderClassifier.from_hparams( source=model_path, @@ -166,7 +92,6 @@ def embed_file(self, path): try: waveform, _ = load_audio(path) - waveform = self._prepare_waveform(waveform) with torch.no_grad(): @@ -180,9 +105,11 @@ def embed_file(self, path): return self._normalize(embedding) - except Exception as e: - print(f"\nEmbedding failed for file: {path}") - print(f"Reason: {e}") + except Exception: + logger.exception( + "Embedding extraction failed for file: %s", + path, + ) raise def emb_waveform(self, waveform): @@ -190,18 +117,25 @@ def emb_waveform(self, waveform): Create an embedding directly from a waveform tensor. """ - waveform = self._prepare_waveform(waveform) + try: + waveform = self._prepare_waveform(waveform) - with torch.no_grad(): - embedding = ( - self.model - .encode_batch(waveform) - .squeeze() - .cpu() - .numpy() - ) + with torch.no_grad(): + embedding = ( + self.model + .encode_batch(waveform) + .squeeze() + .cpu() + .numpy() + ) + + return self._normalize(embedding) - return self._normalize(embedding) + except Exception: + logger.exception( + "Embedding extraction failed from waveform." + ) + raise def extract(self, waveform): """ From 7349abc3c25e1a4d9c03781e6beb40887ff33b7e Mon Sep 17 00:00:00 2001 From: tayyaba-code Date: Wed, 22 Jul 2026 18:29:26 +0500 Subject: [PATCH 06/10] Fix coverage artifact upload path --- .github/workflows/python-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 55514aa..5da41b2 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -43,4 +43,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: coverage-${{ matrix.python-version }} - path: .coverage.xml + path: coverage.xml From bd325dc369b837a8fd5fca01ab46f081bc71354a Mon Sep 17 00:00:00 2001 From: tayyaba-code Date: Wed, 22 Jul 2026 18:31:56 +0500 Subject: [PATCH 07/10] Update CLI logging --- dummy_model.pkl | Bin 2399 -> 2399 bytes src/vocalid/cli.py | 68 ++++++++++++++++++++++++++++++--------------- 2 files changed, 45 insertions(+), 23 deletions(-) diff --git a/dummy_model.pkl b/dummy_model.pkl index 1533bf81d641cc665f6c124fb569d92a73dc23c4..769af0fb9d15c565d303ff2d0a089401a0e3659e 100644 GIT binary patch delta 1577 zcmWO2jX%_f9{}(jd7LuBdbw)RgJalhdaz%#&kMEchfq6HXr#h(PtBC@5_sQIa@GJ$Su#}VYTid}XI4p)j<>Kj(;DfDF5j@-fhu`xXZJRXpTM%Z-CB z7Nh3Jk@4N8wXnrv&lj)w`B1R>)vTUgCU)AtPRQiS!2a0DXzrjC&QY>g+Pt#xe3Mb& za$^~U+J?Ij&JP5bXZ+-aoZ|U@qW9p6|4lI6v{7}R(}2rAmd1LOv_p}5_)3eJ3O8sD z&Fdd1#SN@0LI>hD9_b>jtcnZwtPWt%Ys*?sh_=dzl*wt3POzuN<2SkV_i7X2Kn|iwtrbvLev`B$M%#N zHm&9*ET80JhQ>EXRGkR-{v1TfumnHotljjLfB^>_XYxk9#hBmQPCdLPhay>AQubss zSg-r1VdV`AXO6h{i$|-`Ms11uKdYfHNfs8U6hnw-#Pz7N-=b?Rv6)7$QekjNjbja? z8Y|x{C3=>=uSyQ~boI|b>#~WV;jinl{QhR3OxeKaSqTfsn?|kUg7u$Xw`hj6W*^Bl_k?S(l_ZBeeIuRHbrIx-45B z{eg5bFhYx>e^{)>{oYgMeRo7?6YFCtm|;TU!x-zhbq(<4cXfW`_-xoWO=W$aMcjg? z2Je2MI5)!E-_r_M>)7y{TU%g@l?=I8*Ji(OlR>1TVVG0A91ba;kzSUHFmK-f^X(Eo zI!`6+d4Ubx(`&uIHqY+Dhjl)?hgr*l&~snw9miE_8?r9%b(LPF<+ z3bbaAnvU<5LW;&=#Q$I$gxnd_?|D!SH46jXuao28ahIe*JyZ{SEseOn2X4Sz$C_VQ zxCnP1UZ~BGz7Ia=|JboY2UFV~9;C+RYhyu@u+w4ssr?aa$? zwivOI{l+wg@1h(^Z<1We%X&X?ic6HP|Kal)(Hqf|ax^ux^0=CZnO$z9O6@=?Y^1+* zsLZ|qUYdoZw{>!KOb_x=d?AFi^%iNtE7S2>hgXgze&%>ZQy^d^T_`{^m_KguBVfqts^b7})-6);XE8h3xa)kTQYf~8U~;|lPjtmf#cYGH=AW2?yu z6^15z-kmyCif{WAueQk>P%h1_A7`ZE@`MwwoOl%w*?PEZ=+81(V_R+&=9`7yk6v{x z^(?~D>kG3!p;8#L{+;D_N`M=3q+{GVW)0qrN~CNivT!km-)&kXLdJs1YG^kB^`U_~ z_b0bOeuhSh*P3RW9$I&>)7}MdnfDLd(Jq0+Hsa`NBis>Y~?hLQ;YB;g!Z`D%x>6$g#P^s}|3leo`Q0eh}hlP1UE3H_yX~ z7SRDmmIN}h>3){#uLxe$-}8z8mj^+|9aL#WER>z&5#zfnksRaQ>O{E&r~7k$!K2l%$x>2f!OFqT zwfV>_cdUVA!#}_MOLhW-zw%YQ+G3ARk>3;HHnnhwFx3<_`9pTvV+D1&0OqV_4dxz6 zAtucrOmw*o0)3`jwj|f#Nm|IyZ4L#fU#=vH-?1V8UPkOy9Wjpo7W0~$8wCkO#Et{a z5-bTA>`r!02kP_m#(stpZQgys^vD)hfw%WHt#WM+p1!whN5I2Icwq2w$$-2HhuH?S z%b)yEKLn#KwX47hCWn+c4LD)y$?Dm25k6j%Ffb+`+e9;ilVm9#ByaMwZg0YNikNrT zy%grgZbur1w*c+w#KO&Kipd@U67XGiQXSGR0E<=n>(tW{cyisBfAM}2Z|^&g7`at3Y^wmBwG)j1jbBto8{xL|IT>y*7w2HEH<#VQV`2XRT0=1S;_T z!ncm3Kg#e*X8fJ~eG*`9W{fch1q9N}*BrgoSr034(PxzZDg1_*-9*a#%hc~L+{_fhpfr>Ni$3~s;ow0gEZ11{;X(T5vRhnN%wn?v`FFlji9ToU76_G z>2ixAZh&2WCi;QbIiP*A$f&bY2A`55Z?4$+mjZXJq)}$)F6T|@IBO5m;|HDx{FZ00 GYyLmwf+q|B diff --git a/src/vocalid/cli.py b/src/vocalid/cli.py index a84fa3a..29b00d7 100644 --- a/src/vocalid/cli.py +++ b/src/vocalid/cli.py @@ -1,9 +1,21 @@ + + import argparse +import logging import os from glob import glob + +from .audio_utils import record_audio from .trainer import VoiceTrainer from .verifier import VoiceVerifier -from .audio_utils import record_audio + +logging.basicConfig( + level=logging.INFO, + format="%(message)s", +) + +logger = logging.getLogger(__name__) + def main(): parser = argparse.ArgumentParser(description="Voice Verifier CLI") @@ -11,34 +23,36 @@ def main(): # training command train = sub.add_parser("train", help="Train a voice authentication model") - train.add_argument("--positive", required=True, help = "Folder with your voice samples") - train.add_argument("--negative", required=True, help = "Folder with other voices") - train.add_argument("--output", default="voice_auth.pkl", help="path to save model") + train.add_argument("--positive", required=True, help="Folder with your voice samples") + train.add_argument("--negative", required=True, help="Folder with other voices") + train.add_argument("--output", default="voice_auth.pkl", help="Path to save model") # evaluate the model - evaluate = sub.add_parser("evaluate", help = "Evaluate the trained model") - evaluate.add_argument("--model", required=True, help= "Path to trained model") - evaluate.add_argument("--positive", required=True, help = "Folder with your voice samples") - evaluate.add_argument("--negative", required=True, help= "Folder with negative/random samples") + evaluate = sub.add_parser("evaluate", help="Evaluate the trained model") + evaluate.add_argument("--model", required=True, help="Path to trained model") + evaluate.add_argument("--positive", required=True, help="Folder with your voice samples") + evaluate.add_argument("--negative", required=True, help="Folder with negative/random samples") # verify file command - verify = sub.add_parser("verify", help = "Verify a voice file") - verify.add_argument("file", help="path to .wav voice file to verify") - verify.add_argument("--model", default="voice_auth.pkl", help="trained model path") + verify = sub.add_parser("verify", help="Verify a voice file") + verify.add_argument("file", help="Path to .wav voice file to verify") + verify.add_argument("--model", default="voice_auth.pkl", help="Trained model path") # live verification command - live = sub.add_parser("live", help = "Live microphone verification") - live.add_argument("--model", default="voice_auth.pkl", help= "trained model path") - live.add_argument("--seconds", type=int, default=4, help= "Recording duration in seconds") + live = sub.add_parser("live", help="Live microphone verification") + live.add_argument("--model", default="voice_auth.pkl", help="Trained model path") + live.add_argument("--seconds", type=int, default=4, help="Recording duration in seconds") args = parser.parse_args() if args.commands == "train": pos_files = glob(os.path.join(args.positive, "*.wav")) neg_files = glob(os.path.join(args.negative, "*.wav")) + trainer = VoiceTrainer() trainer.train(pos_files, neg_files, args.output) - print(f"Model saved to {args.output}") + + logger.info("Model saved to %s", args.output) elif args.commands == "evaluate": pos_files = glob(os.path.join(args.positive, "*.wav")) @@ -46,29 +60,37 @@ def main(): trainer = VoiceTrainer() trainer.load(args.model) - # X, y = trainer.prepare_features(pos_files, neg_files) + results = trainer.evaluate(pos_files, neg_files) - print("\n===== Evaluation Results =====") - print("\nAccuracy", round(results["accuracy"], 4)) - print("\nClassification Report:\n") - print(results["report"]) + logger.info("") + logger.info("===== Evaluation Results =====") + logger.info("") + logger.info("Accuracy: %.4f", results["accuracy"]) + logger.info("") + logger.info("Classification Report:") + logger.info("%s", results["report"]) elif args.commands == "verify": verifier = VoiceVerifier(args.model) + ok, score = verifier.verify_file(args.file) - print(f"Verified: {ok}, Score: {score:.2f}") + + logger.info("Verified: %s, Score: %.2f", ok, score) elif args.commands == "live": verifier = VoiceVerifier(args.model) + try: audio_tensor = record_audio(args.seconds) + except RuntimeError as e: - print(str(e)) + logger.error("%s", e) return ok, score = verifier.verify_array(audio_tensor) - print(f"Verified: {ok}, Score: {score:.2f}") + + logger.info("Verified: %s, Score: %.2f", ok, score) else: parser.print_help() \ No newline at end of file From eae30cd500c276d03647de0208c5df0a96e6611e Mon Sep 17 00:00:00 2001 From: tayyaba-code Date: Wed, 22 Jul 2026 18:37:10 +0500 Subject: [PATCH 08/10] Remove generated files from repository --- dummy_model.pkl | Bin 2399 -> 0 bytes test.py | 21 --------------------- 2 files changed, 21 deletions(-) delete mode 100644 dummy_model.pkl delete mode 100644 test.py diff --git a/dummy_model.pkl b/dummy_model.pkl deleted file mode 100644 index 769af0fb9d15c565d303ff2d0a089401a0e3659e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2399 zcmaJ@dpuS77I)%!T_vTaOD}rSV5WBy&HA85&GG0lN}=Q6a1Q68^YChjZr+P9o%o%Hy8=5z1*WAC+od#&~T?(g28&srh$WTjaO z(?%>6d-K>Vk-&t<6;O93U+B)}nJ{@mPp(+Pbt8#+)-&!twx@_K7ITFHGKn+{C4)!< zLdAzIVDTgYB%$uk_7Sn&SQ557Nzg1wD;SwUQ01*-idYh^khFS#qM$%1xl36*GKx@? z2zgYh&gOxk#g@jzY!9x4$rVW0A~&{=gp4G#J_by&8;hdoNit}Jh8vG17Bl_WTu+XK zv?Vk|EP=a_&lF22cq&#F3wge55lPT_t{$FZl2GNd{Fz*;EosxGLa0#BT!msb$xtBF z{aB*uLXm``qby*030=ihi#Yv|LUkIIib-NVmBr(_nh2zPp8%#0 z*NxJUB(!Ybh2|m=E8w7r<>N!aeH2PnEOni(r$|j#k%WrCo%Jr4N+RfD4$FrkG%Jkq zg(7#Vl%qn>-6a9kDQC$CkIJ0I-AYD9Mv(hR1BN0&Gb3$nZNHzQZ2H1bkdUr~I*Z2> z`Z4)@mJg+&?t7NgSWHSoPXV7TkT6{XBy2Ism@9Ri`>y`lnF{|GLy^Kra9@)VT_6I(722&HX~i&ZUb|O5D;TwE_6|gI!lAHWzuo1I z)95>-cE@g0IF7`0v*oO6B=&9jrvG^y2Bh;fOcpgjI_)Jq)~bWvR{Obojq+gfzT!a2 zpVjyar$@u@6$#fL8XFuJ<-n~+8zO(OJc3awmqs^P1cS3EvviPM2o8bm;bl=yP~|cv zqpvN&1ur|T(`fbBzfZ};1WrGWrbq_c%6`}&>C}wSBgLSMFgL~ z#)to+g=qxB77hQO2Nu?&_9G8tgIgl7D6EN(l;*&n%U&F1MOQ*#Y}J#OOPfK>(Z$Qa zp2QB4PSWazK|s$8j`NS^#|giepZ zHpAEA2(k^?XOzb%W znv3SwUQMXRai1)2%gkm-HDgUQYSrSLmYpw^w`bv;*s%Py$Is)gPTkFJPyJ!?$C1G& zkBd?H_O@TT=9747zG=#sZULI^8a_M~qDU^A2$UI72GT`%Z#K97&CNheDuiXpIju=YXd zDd>Mn`&K(M6*BDlXZwejVQy;lTRT-bx@zhLu3cY=HNKe@FR&7!qTmbe>I87AX>;1R zI32@B+l@r-GEfU%d?VVg6o*Uo_IBvm!>(DKLot6V$5jzQXH@T&V1}>5nv_E_9EPfv zg!n*cZD=VBSrmsun=);z-BNIg`sLIh5Kg@(&R9CF6Dyd%3`p&_jb89JqMhp z)(3_3hhn^bO}#uP55^|K+hYr=u??fvyq;%=vyMww#)n=;)5FHzy%jQ0PYud?h3TnvVXBQjLK9$@=K3hAg{Brk9OFdDU|uxBx<&Q@O&iBHYFpI@f(c zhI-!CnmNPakaFEihp$u(-+ETq>55`t^K<*yb1Xih zvDVFMDuWKf%Z5dUIuLJmSv%NUCq4y$eTA)UDZ9fe!TBqz>STbB?HCj%@$nlOIq1lj= zjv@mio8zaX`1=dZv@HoFsZCe=aAuA1& emwxCompQXGF{xc*dL<(0>rB2e`I?lvn*0~|^l9q= diff --git a/test.py b/test.py deleted file mode 100644 index e04ce6b..0000000 --- a/test.py +++ /dev/null @@ -1,21 +0,0 @@ -from pathlib import Path - -replacements = { - "list[": "List[", - "dict[": "Dict[", - "tuple[": "Tuple[", - "set[": "Set[", -} - -for file in Path("src").rglob("*.py"): - text = file.read_text(encoding="utf-8") - - changed = False - for old, new in replacements.items(): - if old in text: - text = text.replace(old, new) - changed = True - - if changed: - file.write_text(text, encoding="utf-8") - print("Updated", file) \ No newline at end of file From 385f178af0b6b5dddf17485fc403e7f31c32d891 Mon Sep 17 00:00:00 2001 From: tayyaba-code Date: Thu, 23 Jul 2026 20:37:22 +0500 Subject: [PATCH 09/10] Improve unit tests and increase test coverage --- .coverage | Bin 0 -> 53248 bytes dummy_model.pkl | Bin 0 -> 2399 bytes test.py | 21 ++ tests/test_audio_utils.py | 204 +++++++++++++++++ tests/test_cli.py | 170 ++++++++++++-- tests/test_embeddings.py | 317 ++++++++++++++++++++++++-- tests/test_enroll_and_authenticate.py | 120 ++++++++++ tests/test_model_store.py | 45 ++++ tests/test_trainer.py | 286 ++++++++++++++++++++--- tests/test_verifier.py | 224 ++++++++++++++++-- 10 files changed, 1292 insertions(+), 95 deletions(-) create mode 100644 .coverage create mode 100644 dummy_model.pkl create mode 100644 test.py create mode 100644 tests/test_audio_utils.py create mode 100644 tests/test_enroll_and_authenticate.py create mode 100644 tests/test_model_store.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..0523a13dfda9f5e7a052c5c9ef95f732420cc816 GIT binary patch literal 53248 zcmeI4U2Ggz702gecV}mJXKrW`ZHyZ{qN0w8?c$QEcAPeGnjj1np^%o)TCB&rv-Tvr zGtSIxY%4@zOQlq(^bL3*Dt#e&f)@ll@b!S85(uFxsH%|otQ1lNsSrgGa;Z3XKD@h* zU3H|WO|AcEckkSfbLX7jJ@?+dvwP>Wk34J#mQi=TrWqI?6}AbYC>$~jK@e1WYxGVI zg-$X_ghH|Fe6v$k82!o)jXfrmawi1#1?~N8q%>dpSMg$LTz#}?E5qt2W#9k-5C8!X z*fIp#kCc>hU!VBoxxk!jSb=9&Eid}i9{SADspChD<5PzpK59g1#-4&f$H<5=Wq9tA zv1oZl-ELTh?bPh58Q9Le5iD3q>6UNR_?$&)G;8sKqoF)MtJpP?3aojmV$rjkrnh38 zvQ~z}0`b)XYdMG#s1nPbcX$b7Pb@xUcvju=ET?MukrsRG+E8KW%jXM9dHZ(pf)(0i zdR2NAd#s^^jw#c1s;KIEHLm@fXFAmd%O5t(H4CdQ>A4(4)_QL9p1?I4wiBA^_;z5s zj$tiZ)mC8DHZ*XULrV^V*KG~eHOmc}jU?l1b@`oZ}`~JA(YuBpmAO zw3>5-{A8=S$Qy?wq6-J^W|Eu925xNZ+isf;+OlJBPje>7d5cC;Eyq6Hvep`Z*yuKt3=EUVHWARD8tz?w)&YfaA>JfZg;_qya5j>^S1=dXjZ(sdI zGgvTg)}1Gl;)B^-d2;7wigOhzWBfDs)HW$p`Dr<%-=smGzvUj4c}hp+Pg*M@XUqnT zCX1%+QJ1ALIGOD1!+iciB)tu*%Y&dIM64I(6H3{t1`sf(_wGpRJr;009sH0T2KI5C8!X z009sH0T9@F1Y}VX6~6wL*q;S z%gchBLbBShN$P+i3+GcvHQk!ksQA=m+~CPvO7QgPnHqo0j!(i9*>nv~D`C5ChqgYL zA<$w9wRuBT=)n$)K`4K}EDNm^;`Dfox;@WvN$GHzt(xstT7lgNEf&*RT&dWO9aJiG zVgCESUb-x>U$Une)9+%(v`hLov?sI==$|jWqP<$WtpDyE>*lx+2!H?xfB*=900@8p z2!H?x2*RX%HqElEd;PEPmsirrcCG)l1M;~vqCM;X(kC+Qa(d~F*8j!BnYNOqs+-sU zg$a2nO%0v(zj~kSrjd-+|M>^x^J%1d*Z)dd@ZIbGT*?OTTK{JcX4+yJwb!lxGvjhA zjriL7Urvducl|F-$SZ1!+YVjpe?0#W!z&1Y00@8p2!H?xfB*=900@8p2yAHrGW}~n z#NSG7tZb|Qrd?#>8rvE;~&clMn&M_vRL? z{+DlL4qScl%Hw}|^Yy~&m7SuNqntYn(jnoSKf7^-<+2pjW&Mr}g?Hzdm%QixHuFR0 zf8Y7RUa=@s+OCXr@$~hVUhR`8vQN79H9*8CBKAaQ{Dh zUSO;2b@nIrPxd_B1^6+$%D&59X1`)Tr~3hyw_p>)BoF`r5C8!X009sH0T2KI5C8!X zxUC5Ag}<29!$1#j1BhxVjA_L%PzqsCQp2E_4}+W%28CQ0$k{N+XTl&Ohk+zz=mtRe z`@gX5wlW#YfB*=900@8p2!H?xfB*=900@8p2;5=<{P%yX|8Fr=R0jee00JNY0w4ea xAOHd&00JNY0=F3fzW*QV|J$rlpbiLt00@8p2!H?xfB*=900@8p2;5=<{|7s05taY| literal 0 HcmV?d00001 diff --git a/dummy_model.pkl b/dummy_model.pkl new file mode 100644 index 0000000000000000000000000000000000000000..a60d712ccff462b2223c3c350e54cc38a3b8a3de GIT binary patch literal 2399 zcmaJ@XH*nf7N)VG5dlHR2r7ao%nBk#KzR%3vf2vDGNx79hAz5$?POPxbVfn4mc(i@ zNQNdzXh9V85=KlgCeRr%q0AZ(WRwxX9oP~voHJ+l{iwS4)xG!o>Z^L^-XIMHQ(bLr zk#q5VB8)4SSc!xZ`pywcd6>wGBa-rkI3e_=$SIq~O?Qkh$8cOIl~4(kMKJXPWlpkW zn1m}L0x43PhsoraHcT$&=EVlzk`ZxI*AIsTZCFCZvaQb*2} z@T6i6PSEhQuZc@VewdshHAG%Me4HY+#M}UmkSslgw(@mTPo+#sR9Tm<5|{{B0#kt*&2ae_h60N}^Ar3fQn2^~Fl!uCgD(FCL&IB&`TQAy-4Om2hR;_j z{9lX-G{#PrP}cNY5HQ>i7+X%v26bidAMKtEaQKZ!>g1Smz?9i{#S^38)X@9Jl{pG< za!J1Y=x1M;%4R(4wXTEx7q3NCwk0Cd`49Cvs#VCmHlo(%?-5}5^x&*mn<_NVjn6EW z%AuBhwT@5~0QIsjwtcf0>14A!1UebuZYus}&rv13p|D$8(Rna(F0HlAL4h6-H+&vh zR6~2~t2XCLr_k^$@e*vG63uYW+x?*CG-3q4^jL3@feIgXzs8zWh>c`vcH6SiQ^6!d zt)4Vw3vJAj<<02O9IKJtll{;My+e1sRYh=n@2*{^cQl};Se=YNItrop;m5!eS*$G4ozgoLe+ZB};X!0ZW63lIGV zgP9lfaD#V3xM~=><8MBvk$&Tn$*SB0z?r>E6VoG*wa{nk0gWF~^T_S6ngu5y&3x6n znCvn*bkTCCIpY`v`#Zi7m7GT}GA$cz29n^yL{H;0vV7QQ>vwR*a1;bY%Bo-cm4kKL zknfRgDm25Ku~;sQK&5&WnBS|D$j$Sb@$P|K=pVG0`=&D$H3YW~8aUQM%e&^LZwzx_ zz$J$fdNC2rFRU9RCgq^&mGgN$|EvU!E9W(z*Vn-O+tubVvKDaf3NgR>z64q)J88u@ z7NORvOzj`S^FU;W49)w?ke<6o(UJ{HSoL%LaO16V7%(y2qdAxh`rNsg1S$F$ig zccudJsfGJh>~i#veS0GgtCfJy#`fC{SSlPeve9;ZR)KU&2kU;hl!ta)c-h|MQVf-A z^dAu}N)#04d-8lvCdA%oxsbArf-A-f3lFDkpk8E3i*gFkkjYTK<+Kzq`oVZtCoc<< zqt_MI)TW`tMBK4^uL|9X>yFB*KaTc>PdcmJT#Rl!t2a{XDuoh%zaACuB!rigIJ)D-Bn{P!IT&71ZbjU{w%^Lr` zX5;(eMykiVRRyHbrMLO zvY2zkRq!hIfmVuU0m_uc>>gO1gErXnGuNG}hIQW;n{^2b(AEe08!o-bMwdJA;r!TB zNG6@_%Y3^XinE_DJ#(K2PbR6tzYEMlGiNm}a`lWy1JgJ1ID7><>h$Z{?koAgU+Z{M zzb}J6FZ)!+mp##w^8Gi-kXk61-Z&&1C;*))URQ?9iqPF zqC2(uz~s%CwBm6ibPWzzy^D!Pc43dF$LPhO(w!6UvQ5)qwTW1i@G=A{GTM))pRGi- zn=5}wZi`253(NW*&viy>l^TD2eXRuAoILx3byI=2X>nEvgFb&w9hVi$Qs5XP?4Zea zbtu{-WQv)y0&!o=`zbf80L8`2NBPsvBIqf#@>q2Q1|-3ToT<4e)~s(rg>xaQyTXCol%9By>&HrI9i5u=WQRkGFS%nOw~_4D@xF^^W6KRcS_NX-S=%@ELc3bOqEbZ2}+De6kY`pV1!9b9o)o4FwsO^@Q+tvhoT-i=Oq z%W;vxdRt!TLbp&PY;aw0^>`&pKoy4zEI806*Xo7<1+soNXH$iG4$_OY)ekz71-yp% zwT)CR#Ij6#H0=|?%wbQ9?PwBOX&*c6oqPg1l&?%x%c`Kb=|SA}xO`vBpTXMAkp(6hwYNJMHZwX(IcrhL7u{tMheWTXH9 literal 0 HcmV?d00001 diff --git a/test.py b/test.py new file mode 100644 index 0000000..e04ce6b --- /dev/null +++ b/test.py @@ -0,0 +1,21 @@ +from pathlib import Path + +replacements = { + "list[": "List[", + "dict[": "Dict[", + "tuple[": "Tuple[", + "set[": "Set[", +} + +for file in Path("src").rglob("*.py"): + text = file.read_text(encoding="utf-8") + + changed = False + for old, new in replacements.items(): + if old in text: + text = text.replace(old, new) + changed = True + + if changed: + file.write_text(text, encoding="utf-8") + print("Updated", file) \ No newline at end of file diff --git a/tests/test_audio_utils.py b/tests/test_audio_utils.py new file mode 100644 index 0000000..7170596 --- /dev/null +++ b/tests/test_audio_utils.py @@ -0,0 +1,204 @@ +import numpy as np +import pytest +import torch + +from unittest.mock import patch + +from vocalid.audio_utils import ( + load_audio, + record_audio, + save_audio, +) + + +# --------------------------------------------------------------------- +# load_audio +# --------------------------------------------------------------------- + +@patch("vocalid.audio_utils.sf.read") +def test_load_audio_mono(mock_read): + + samples = np.random.rand(16000, 1).astype(np.float32) + + mock_read.return_value = ( + samples, + 16000, + ) + + waveform, sr = load_audio("dummy.wav") + + assert sr == 16000 + assert isinstance(waveform, torch.Tensor) + assert waveform.shape == (1, 16000) + + +@patch("vocalid.audio_utils.sf.read") +def test_load_audio_stereo(mock_read): + + samples = np.random.rand(16000, 2).astype(np.float32) + + mock_read.return_value = ( + samples, + 16000, + ) + + waveform, _ = load_audio("dummy.wav") + + assert waveform.shape == (1, 16000) + + +@patch("vocalid.audio_utils.torchaudio.functional.resample") +@patch("vocalid.audio_utils.sf.read") +def test_load_audio_resample( + mock_read, + mock_resample, +): + + samples = np.random.rand(8000, 1).astype(np.float32) + + mock_read.return_value = ( + samples, + 8000, + ) + + mock_resample.return_value = torch.rand(1, 16000) + + waveform, sr = load_audio("dummy.wav") + + assert sr == 16000 + mock_resample.assert_called_once() + + +@patch("vocalid.audio_utils.sf.read") +def test_load_audio_padding(mock_read): + + samples = np.random.rand(4000, 1).astype(np.float32) + + mock_read.return_value = ( + samples, + 16000, + ) + + waveform, _ = load_audio("dummy.wav") + + assert waveform.shape == (1, 16000) + + +@patch("vocalid.audio_utils.sf.read") +def test_load_audio_unsqueeze(mock_read): + """ + Force execution of the waveform.ndim == 1 branch. + """ + + waveform = torch.rand(16000) + + with patch( + "vocalid.audio_utils.torch.from_numpy", + return_value=waveform, + ): + mock_read.return_value = ( + np.random.rand(16000, 1).astype(np.float32), + 16000, + ) + + result, _ = load_audio("dummy.wav") + + assert result.shape == (1, 16000) + + +# --------------------------------------------------------------------- +# record_audio +# --------------------------------------------------------------------- + +@patch("vocalid.audio_utils.sd.wait") +@patch("vocalid.audio_utils.sd.rec") +@patch("vocalid.audio_utils.sd.query_devices") +def test_record_audio_success( + mock_devices, + mock_rec, + mock_wait, +): + + mock_devices.return_value = [ + {"max_input_channels": 1} + ] + + mock_rec.return_value = np.random.rand( + 16000, + 1, + ).astype(np.float32) + + waveform = record_audio(duration=1) + + assert isinstance(waveform, torch.Tensor) + assert waveform.shape == (1, 16000) + + mock_wait.assert_called_once() + + +@patch("vocalid.audio_utils.sd.query_devices") +def test_record_audio_no_microphone( + mock_devices, +): + + mock_devices.return_value = [ + {"max_input_channels": 0} + ] + + with pytest.raises(RuntimeError): + record_audio() + + +@patch("vocalid.audio_utils.sd.rec") +@patch("vocalid.audio_utils.sd.query_devices") +def test_record_audio_recording_failure( + mock_devices, + mock_rec, +): + + mock_devices.return_value = [ + {"max_input_channels": 1} + ] + + mock_rec.side_effect = Exception( + "Recording failed" + ) + + with pytest.raises(RuntimeError): + record_audio() + + +# --------------------------------------------------------------------- +# save_audio +# --------------------------------------------------------------------- + +@patch("vocalid.audio_utils.sf.write") +def test_save_audio(mock_write): + + waveform = torch.rand( + 1, + 16000, + ) + + save_audio( + waveform, + "output.wav", + ) + + mock_write.assert_called_once() + + +@patch("vocalid.audio_utils.sf.write") +def test_save_audio_1d_waveform(mock_write): + """ + Execute the branch where waveform.ndim != 2. + """ + + waveform = torch.rand(16000) + + save_audio( + waveform, + "output.wav", + ) + + mock_write.assert_called_once() \ No newline at end of file diff --git a/tests/test_cli.py b/tests/test_cli.py index 6bca847..149929f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,29 +1,169 @@ + import sys -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch + from vocalid.cli import main + +# --------------------------------------------------------------------- +# train +# --------------------------------------------------------------------- + @patch("vocalid.cli.VoiceTrainer") def test_cli_train(mock_trainer): - trainer_instance = MagicMock() - mock_trainer.return_value = trainer_instance - test_args = ["cli.py", "train", "--positive", "pos_folder", "--negative", "neg_folder"] - with patch.object(sys, 'argv', test_args): + trainer = MagicMock() + mock_trainer.return_value = trainer + + args = [ + "cli.py", + "train", + "--positive", "pos_folder", + "--negative", "neg_folder", + ] + + with patch.object(sys, "argv", args): + main() + + trainer.train.assert_called_once() + + +# --------------------------------------------------------------------- +# evaluate +# --------------------------------------------------------------------- + +@patch("vocalid.cli.VoiceTrainer") +def test_cli_evaluate(mock_trainer): + + trainer = MagicMock() + + trainer.evaluate.return_value = { + "accuracy": 0.95, + "report": "classification report", + } + + mock_trainer.return_value = trainer + + args = [ + "cli.py", + "evaluate", + "--model", "model.pkl", + "--positive", "pos_folder", + "--negative", "neg_folder", + ] + + with patch.object(sys, "argv", args): main() - # Ensure the train method was called - trainer_instance.train.assert_called_once() + trainer.load.assert_called_once_with("model.pkl") + trainer.evaluate.assert_called_once() + +# --------------------------------------------------------------------- +# verify +# --------------------------------------------------------------------- @patch("vocalid.cli.VoiceVerifier") def test_cli_verify(mock_verifier): - verifier_instance = MagicMock() - verifier_instance.verify_file.return_value = (True, 0.95) - mock_verifier.return_value = verifier_instance - test_args = ["cli.py", "verify", "dummy.wav"] - with patch.object(sys, 'argv', test_args): - main() + verifier = MagicMock() + + verifier.verify_file.return_value = ( + True, + 0.95, + ) + + mock_verifier.return_value = verifier + + args = [ + "cli.py", + "verify", + "dummy.wav", + ] + + with patch.object(sys, "argv", args): + main() + + verifier.verify_file.assert_called_once_with( + "dummy.wav" + ) + + +# --------------------------------------------------------------------- +# live success +# --------------------------------------------------------------------- + +@patch("vocalid.cli.record_audio") +@patch("vocalid.cli.VoiceVerifier") +def test_cli_live_success( + mock_verifier, + mock_record, +): + + verifier = MagicMock() + + verifier.verify_array.return_value = ( + True, + 0.91, + ) + + mock_verifier.return_value = verifier + + mock_record.return_value = MagicMock() + + args = [ + "cli.py", + "live", + "--seconds", + "5", + ] + + with patch.object(sys, "argv", args): + main() + + mock_record.assert_called_once_with(5) + + verifier.verify_array.assert_called_once() + + +# --------------------------------------------------------------------- +# live microphone failure +# --------------------------------------------------------------------- + +@patch("vocalid.cli.record_audio") +@patch("vocalid.cli.VoiceVerifier") +def test_cli_live_microphone_failure( + mock_verifier, + mock_record, +): + + mock_verifier.return_value = MagicMock() + + mock_record.side_effect = RuntimeError( + "No microphone" + ) + + args = [ + "cli.py", + "live", + ] + + with patch.object(sys, "argv", args): + main() + + +# --------------------------------------------------------------------- +# help +# --------------------------------------------------------------------- + +@patch("argparse.ArgumentParser.print_help") +def test_cli_print_help(mock_help): + + args = [ + "cli.py", + ] + + with patch.object(sys, "argv", args): + main() - verifier_instance.verify_file.assert_called_once_with("dummy.wav") - \ No newline at end of file + mock_help.assert_called_once() \ No newline at end of file diff --git a/tests/test_embeddings.py b/tests/test_embeddings.py index 49d8f98..31f0e59 100644 --- a/tests/test_embeddings.py +++ b/tests/test_embeddings.py @@ -1,26 +1,309 @@ import numpy as np -from unittest.mock import patch, MagicMock import pytest +import torch -@patch("vocalid.embeddings.EmbeddingExtractor") -def test_embedding_extractor(mock_extractor): +from unittest.mock import MagicMock, patch + +from vocalid.embeddings import EmbeddingExtractor + + +def make_extractor(): """ - Ensure that the EmbeddingExtractor returns a fixed-size embedding - without requiring SpeechBrain to be installed. + Create an EmbeddingExtractor without loading SpeechBrain. """ - # Mock the extractor instance - mock_instance = MagicMock() - mock_instance.extract.return_value = np.random.rand(192) # fixed embedding size - mock_extractor.return_value = mock_instance + extractor = EmbeddingExtractor.__new__(EmbeddingExtractor) + extractor.model = MagicMock() + return extractor + + +# --------------------------------------------------------------------- +# __init__ +# --------------------------------------------------------------------- + +def test_embedding_extractor_init(): + + mock_encoder = MagicMock() + mock_model = MagicMock() + + mock_encoder.from_hparams.return_value = mock_model + + fake_speechbrain = MagicMock() + fake_speechbrain.inference.EncoderClassifier = mock_encoder + + with patch.dict( + "sys.modules", + { + "speechbrain": fake_speechbrain, + "speechbrain.inference": fake_speechbrain.inference, + }, + ): + + extractor = EmbeddingExtractor() + + assert extractor.model == mock_model + + mock_encoder.from_hparams.assert_called_once_with( + source="speechbrain/spkrec-ecapa-voxceleb", + run_opts={"device": "cpu"}, + savedir="pretrained_models/ecapa", + ) + + +def test_embedding_extractor_import_error(): + + with patch.dict( + "sys.modules", + { + "speechbrain": None, + "speechbrain.inference": None, + }, + ): + + with pytest.raises(ImportError) as exc: + + EmbeddingExtractor() + + assert "SpeechBrain could not load" in str(exc.value) + + +# --------------------------------------------------------------------- +# _normalize +# --------------------------------------------------------------------- + +def test_normalize_vector(): + + extractor = make_extractor() + + vec = np.array([3.0, 4.0]) + + result = extractor._normalize(vec) + + assert np.allclose( + result, + np.array([0.6, 0.8]) + ) + + +def test_normalize_zero_vector(): + + extractor = make_extractor() + + vec = np.zeros(5) + + result = extractor._normalize(vec) + + assert np.array_equal( + result, + vec + ) + + +# --------------------------------------------------------------------- +# _prepare_waveform +# --------------------------------------------------------------------- + +def test_prepare_waveform_numpy(): + + extractor = make_extractor() + + waveform = np.random.rand( + 16000 + ).astype(np.float32) + + result = extractor._prepare_waveform( + waveform + ) + + assert isinstance( + result, + torch.Tensor + ) + + assert result.shape == ( + 1, + 16000 + ) + + +def test_prepare_waveform_stereo(): + + extractor = make_extractor() + + waveform = torch.rand( + 2, + 16000 + ) + + result = extractor._prepare_waveform( + waveform + ) + + assert result.shape == ( + 1, + 16000 + ) + + +def test_prepare_waveform_padding(): + + extractor = make_extractor() + + waveform = np.random.rand( + 8000 + ).astype(np.float32) + + result = extractor._prepare_waveform( + waveform + ) + + assert result.shape == ( + 1, + 16000 + ) + + +# --------------------------------------------------------------------- +# embed_file +# --------------------------------------------------------------------- + +@patch("vocalid.embeddings.load_audio") +def test_embed_file_success(mock_load_audio): + + extractor = make_extractor() + + waveform = torch.rand( + 1, + 16000 + ) + + mock_load_audio.return_value = ( + waveform, + 16000 + ) + + extractor.model.encode_batch.return_value = torch.rand( + 1, + 192 + ) + + result = extractor.embed_file( + "dummy.wav" + ) + + assert isinstance( + result, + np.ndarray + ) + + assert result.shape == ( + 192, + ) + + mock_load_audio.assert_called_once_with( + "dummy.wav" + ) + + +@patch("vocalid.embeddings.load_audio") +def test_embed_file_exception(mock_load_audio): + + extractor = make_extractor() + + mock_load_audio.side_effect = RuntimeError( + "Load failed" + ) + + with pytest.raises(RuntimeError): + + extractor.embed_file( + "dummy.wav" + ) + + +# --------------------------------------------------------------------- +# emb_waveform +# --------------------------------------------------------------------- + +def test_emb_waveform_success(): + + extractor = make_extractor() + + waveform = torch.rand( + 1, + 16000 + ) + + extractor.model.encode_batch.return_value = torch.rand( + 1, + 192 + ) + + result = extractor.emb_waveform( + waveform + ) + + assert isinstance( + result, + np.ndarray + ) + + assert result.shape == ( + 192, + ) + + +def test_emb_waveform_exception(): + + extractor = make_extractor() + + waveform = torch.rand( + 1, + 16000 + ) + + extractor.model.encode_batch.side_effect = RuntimeError( + "Encoding failed" + ) + + with pytest.raises(RuntimeError): + + extractor.emb_waveform( + waveform + ) + + +# --------------------------------------------------------------------- +# extract +# --------------------------------------------------------------------- + +def test_extract_calls_emb_waveform(): + + extractor = make_extractor() + + waveform = torch.rand( + 1, + 16000 + ) + + expected = np.random.rand( + 192 + ) - # Import inside the test to ensure mock patching works - from vocalid.embeddings import EmbeddingExtractor + with patch.object( + extractor, + "emb_waveform", + return_value=expected, + ) as mock_method: - extractor = EmbeddingExtractor() - dummy_input = np.random.rand(16000).astype("float32") # 1-second dummy audio at 16kHz + result = extractor.extract( + waveform + ) - emb = extractor.extract(dummy_input) + mock_method.assert_called_once_with( + waveform + ) - # Check the embedding shape - assert emb.shape[0] == 192 - mock_instance.extract.assert_called_once_with(dummy_input) + assert np.array_equal( + result, + expected + ) \ No newline at end of file diff --git a/tests/test_enroll_and_authenticate.py b/tests/test_enroll_and_authenticate.py new file mode 100644 index 0000000..8cc9c25 --- /dev/null +++ b/tests/test_enroll_and_authenticate.py @@ -0,0 +1,120 @@ +from unittest.mock import MagicMock, patch + +from vocalid.enroll_and_authenticate import ( + main, + DATASET_ROOT, + MODEL_PATH, +) + + +@patch("vocalid.enroll_and_authenticate.print") +@patch("vocalid.enroll_and_authenticate.glob.glob") +@patch("vocalid.enroll_and_authenticate.VoiceAuthenticator") +@patch("vocalid.enroll_and_authenticate.VoiceTrainer") +@patch("vocalid.enroll_and_authenticate.EnrollmentSession") +def test_main( + mock_session_cls, + mock_trainer_cls, + mock_auth_cls, + mock_glob, + mock_print, +): + """ + Test the complete enroll -> train -> authenticate workflow. + """ + + # ----------------------------- + # Enrollment session + # ----------------------------- + session = MagicMock() + mock_session_cls.return_value = session + + # ----------------------------- + # Trainer + # ----------------------------- + trainer = MagicMock() + mock_trainer_cls.return_value = trainer + + # ----------------------------- + # Authenticator + # ----------------------------- + authenticator = MagicMock() + authenticator.authenticate_live.return_value = { + "verified": True, + "score": 0.96, + } + mock_auth_cls.return_value = authenticator + + # ----------------------------- + # Fake dataset files + # ----------------------------- + mock_glob.side_effect = [ + [ + f"{DATASET_ROOT}/my_voice/a.wav", + f"{DATASET_ROOT}/my_voice/b.wav", + ], + [ + f"{DATASET_ROOT}/other_voices/x.wav", + f"{DATASET_ROOT}/other_voices/y.wav", + ], + ] + + # ----------------------------- + # Run + # ----------------------------- + main() + + # ----------------------------- + # Enrollment + # ----------------------------- + mock_session_cls.assert_called_once_with( + dataset_root=DATASET_ROOT + ) + + session.enroll.assert_any_call( + label="positive", + count=10, + seconds=5.0, + ) + + session.enroll.assert_any_call( + label="negative", + count=10, + seconds=5.0, + ) + + assert session.enroll.call_count == 2 + + # ----------------------------- + # Dataset lookup + # ----------------------------- + assert mock_glob.call_count == 2 + + # ----------------------------- + # Training + # ----------------------------- + trainer.train.assert_called_once_with( + [ + f"{DATASET_ROOT}/my_voice/a.wav", + f"{DATASET_ROOT}/my_voice/b.wav", + ], + [ + f"{DATASET_ROOT}/other_voices/x.wav", + f"{DATASET_ROOT}/other_voices/y.wav", + ], + save_path=MODEL_PATH, + ) + + # ----------------------------- + # Authentication + # ----------------------------- + mock_auth_cls.assert_called_once_with(MODEL_PATH) + + authenticator.authenticate_live.assert_called_once_with( + seconds=4.0 + ) + + # ----------------------------- + # Output + # ----------------------------- + assert mock_print.call_count >= 6 \ No newline at end of file diff --git a/tests/test_model_store.py b/tests/test_model_store.py new file mode 100644 index 0000000..3cdbf8a --- /dev/null +++ b/tests/test_model_store.py @@ -0,0 +1,45 @@ +from unittest.mock import MagicMock, patch + +from vocalid.model_store import save_model, load_model + + +# --------------------------------------------------------------------- +# save_model +# --------------------------------------------------------------------- + +@patch("vocalid.model_store.joblib.dump") +def test_save_model(mock_dump): + + model = MagicMock() + + save_model( + model, + "dummy.pkl", + ) + + mock_dump.assert_called_once_with( + model, + "dummy.pkl", + ) + + +# --------------------------------------------------------------------- +# load_model +# --------------------------------------------------------------------- + +@patch("vocalid.model_store.joblib.load") +def test_load_model(mock_load): + + fake_model = MagicMock() + + mock_load.return_value = fake_model + + model = load_model( + "dummy.pkl", + ) + + mock_load.assert_called_once_with( + "dummy.pkl", + ) + + assert model == fake_model \ No newline at end of file diff --git a/tests/test_trainer.py b/tests/test_trainer.py index 3ae682f..12f1555 100644 --- a/tests/test_trainer.py +++ b/tests/test_trainer.py @@ -1,58 +1,268 @@ + + import numpy as np -from unittest.mock import patch, MagicMock -from vocalid.trainer import VoiceTrainer +import pytest + +from unittest.mock import MagicMock, patch + +from vocalid.trainer import ( + VoiceTrainer, + normalize, +) + + +# --------------------------------------------------------------------- +# normalize +# --------------------------------------------------------------------- + +def test_normalize_vector(): + + vec = np.array([3.0, 4.0]) + + result = normalize(vec) + + assert np.allclose( + result, + np.array([0.6, 0.8]), + ) + + +def test_normalize_zero_vector(): + + vec = np.zeros(5) + + result = normalize(vec) + + assert np.array_equal(result, vec) + + +# --------------------------------------------------------------------- +# helper +# --------------------------------------------------------------------- + +def make_trainer(): + + trainer = VoiceTrainer.__new__(VoiceTrainer) + + trainer.extractor = MagicMock() + + trainer.model = None + + return trainer + -# ------------------ Test training pipeline ------------------ # +# --------------------------------------------------------------------- +# train +# --------------------------------------------------------------------- + +@patch("vocalid.trainer.save_model") +@patch("vocalid.trainer.LogisticRegression") @patch("vocalid.trainer.EmbeddingExtractor") -def test_training_pipeline(mock_extractor): - mock_instance = MagicMock() - # embed_file returns 192-dim embeddings - mock_instance.embed_file.side_effect = lambda x: np.random.rand(192) - mock_extractor.return_value = mock_instance +def test_training_pipeline( + mock_extractor, + mock_lr, + mock_save, +): + + extractor = MagicMock() + + extractor.embed_file.side_effect = ( + lambda _: np.random.rand(192) + ) - X_positive = ["file1.wav", "file2.wav", "file3.wav", "file4.wav", "file5.wav"] - X_negative = ["file6.wav", "file7.wav", "file8.wav", "file9.wav", "file10.wav"] + mock_extractor.return_value = extractor + + model = MagicMock() + + mock_lr.return_value = model trainer = VoiceTrainer() - trainer.train(X_positive, X_negative, save_path="dummy_model.pkl") - # Predict using embeddings - # X_test = X_positive + X_negative - # X_features = np.vstack([trainer.extractor.embed_file(x) for x in X_test]) - # y_test = np.array([1]*5 + [0]*5) - # preds = trainer.model.predict(X_features) + positive = [ + "p1.wav", + "p2.wav", + ] + + negative = [ + "n1.wav", + "n2.wav", + ] + + path = trainer.train( + positive, + negative, + "dummy.pkl", + ) + + assert path == "dummy.pkl" + + model.fit.assert_called_once() + + mock_save.assert_called_once() + + assert trainer.model == model - # assert (preds == y_test).mean() >= 0.5 - assert trainer.model is not None +@patch("vocalid.trainer.save_model") +@patch("vocalid.trainer.LogisticRegression") +@patch("vocalid.trainer.EmbeddingExtractor") +def test_train_skips_none_embeddings( + mock_extractor, + mock_lr, + mock_save, +): + + extractor = MagicMock() + + extractor.embed_file.side_effect = [ + np.random.rand(192), + None, + np.random.rand(192), + ] + + mock_extractor.return_value = extractor + + mock_lr.return_value = MagicMock() + + trainer = VoiceTrainer() + + trainer.train( + ["a.wav", "b.wav"], + ["c.wav"], + ) + + trainer.model.fit.assert_called_once() -# ------------------ Test save/load ------------------ # @patch("vocalid.trainer.EmbeddingExtractor") -def test_save_and_load(mock_extractor, tmp_path): - mock_instance = MagicMock() - mock_instance.embed_file.side_effect = lambda x: np.random.rand(192) - mock_extractor.return_value = mock_instance +def test_train_no_embeddings( + mock_extractor, +): + + extractor = MagicMock() + + extractor.embed_file.return_value = None - X_positive = ["file1.wav", "file2.wav", "file3.wav", "file4.wav", "file5.wav"] - X_negative = ["file6.wav", "file7.wav", "file8.wav", "file9.wav", "file10.wav"] + mock_extractor.return_value = extractor trainer = VoiceTrainer() - trainer.train(X_positive, X_negative, save_path=str(tmp_path / "dummy_model.pkl")) - # Save/load test - save_path = tmp_path / "clf.pkl" - trainer.save(str(save_path)) - trainer2 = VoiceTrainer() - trainer2.load(str(save_path)) + with pytest.raises(ValueError): + + trainer.train( + ["a.wav"], + ["b.wav"], + ) + + +# --------------------------------------------------------------------- +# evaluate +# --------------------------------------------------------------------- + +def test_evaluate_success(): + + trainer = make_trainer() + + trainer.model = MagicMock() + + trainer.model.predict.return_value = np.array([1, 0]) + + trainer.extractor.embed_file.side_effect = [ + np.random.rand(192), + np.random.rand(192), + ] + + result = trainer.evaluate( + ["p.wav"], + ["n.wav"], + ) + + assert "accuracy" in result + + assert "report" in result + + +def test_evaluate_without_model(): + + trainer = make_trainer() + + with pytest.raises(ValueError): + + trainer.evaluate( + ["p.wav"], + ["n.wav"], + ) + + +def test_evaluate_empty_embeddings(): + + trainer = make_trainer() + + trainer.model = MagicMock() + + trainer.extractor.embed_file.return_value = None + + with pytest.raises(ValueError): + + trainer.evaluate( + ["p.wav"], + ["n.wav"], + ) + + +# --------------------------------------------------------------------- +# save +# --------------------------------------------------------------------- + +@patch("vocalid.trainer.save_model") +def test_save_success(mock_save): + + trainer = make_trainer() + + trainer.model = MagicMock() + + trainer.save("abc.pkl") + + mock_save.assert_called_once_with( + trainer.model, + "abc.pkl", + ) + + +def test_save_without_model(): + + trainer = make_trainer() + + with pytest.raises(ValueError): + + trainer.save("abc.pkl") + + +# --------------------------------------------------------------------- +# load +# --------------------------------------------------------------------- + +@patch("vocalid.trainer.load_model") +def test_load_success(mock_load): + + trainer = make_trainer() + + model = MagicMock() + + mock_load.return_value = model + + trainer.load("abc.pkl") + + assert trainer.model == model + + +@patch("vocalid.trainer.load_model") +def test_load_failure(mock_load): - # X_test = X_positive + X_negative - # X_features = np.vstack([trainer.extractor.embed_file(x) for x in X_test]) + trainer = make_trainer() - # preds_original = trainer.model.predict(X_features) - # preds_loaded = trainer2.model.predict(X_features) + mock_load.return_value = None - # assert np.allclose(preds_original, preds_loaded) + with pytest.raises(ValueError): - assert trainer.model is not None - assert trainer2.model is not None \ No newline at end of file + trainer.load("abc.pkl") \ No newline at end of file diff --git a/tests/test_verifier.py b/tests/test_verifier.py index 0277d6d..285f948 100644 --- a/tests/test_verifier.py +++ b/tests/test_verifier.py @@ -1,28 +1,202 @@ -from unittest.mock import patch, MagicMock + + import numpy as np +import torch + +from unittest.mock import MagicMock, patch + +from vocalid.verifier import VoiceVerifier +from vocalid.config import THRESHOLD + + +def make_verifier(): + """ + Create a VoiceVerifier without calling __init__. + """ + verifier = VoiceVerifier.__new__(VoiceVerifier) + verifier.model = MagicMock() + verifier.extractor = MagicMock() + return verifier + + +# --------------------------------------------------------------------- +# __init__ +# --------------------------------------------------------------------- -@patch("vocalid.verifier.load_model") @patch("vocalid.verifier.EmbeddingExtractor") -def test_verifier(mock_extractor, mock_load_model): - # Mock extractor - mock_instance = MagicMock() - mock_instance.extract.return_value = np.zeros(192) - mock_extractor.return_value = mock_instance - - # Mock model - mock_model = MagicMock() - mock_model.predict_proba.return_value = [[0.1, 0.9]] - mock_load_model.return_value = mock_model - - from vocalid.verifier import VoiceVerifier - verifier = VoiceVerifier("model.bin") - - # Fix: call extractor.extract instead of get_embedding - dummy_waveform = np.random.rand(16000).astype("float32") - emb = verifier.extractor.extract(dummy_waveform) - score = mock_model.predict_proba([emb])[0][1] - verified = score >= 0.5 # use some threshold - - assert isinstance(verified, bool) - assert isinstance(score, float) - mock_instance.extract.assert_called_once_with(dummy_waveform) +@patch("vocalid.verifier.load_model") +def test_verifier_init( + mock_load_model, + mock_extractor, +): + fake_model = MagicMock() + fake_extractor = MagicMock() + + mock_load_model.return_value = fake_model + mock_extractor.return_value = fake_extractor + + verifier = VoiceVerifier("dummy_model.pkl") + + mock_load_model.assert_called_once_with( + "dummy_model.pkl" + ) + + mock_extractor.assert_called_once() + + assert verifier.model is fake_model + assert verifier.extractor is fake_extractor + + +# --------------------------------------------------------------------- +# verify_file +# --------------------------------------------------------------------- + +@patch("vocalid.verifier.load_audio") +def test_verify_file_success(mock_load_audio): + + verifier = make_verifier() + + waveform = torch.rand(1, 16000) + + mock_load_audio.return_value = ( + waveform, + 16000, + ) + + verifier.extractor.emb_waveform.return_value = np.random.rand(192) + + verifier.model.predict_proba.return_value = [ + [0.1, 0.9] + ] + + verified, score = verifier.verify_file( + "dummy.wav" + ) + + assert verified is True + assert score == 0.9 + + verifier.extractor.emb_waveform.assert_called_once_with( + waveform + ) + + +@patch("vocalid.verifier.load_audio") +def test_verify_file_threshold(mock_load_audio): + + verifier = make_verifier() + + waveform = torch.rand(1, 16000) + + mock_load_audio.return_value = ( + waveform, + 16000, + ) + + verifier.extractor.emb_waveform.return_value = np.random.rand(192) + + verifier.model.predict_proba.return_value = [ + [0.8, 0.2] + ] + + verified, score = verifier.verify_file( + "dummy.wav" + ) + + assert verified is False + assert score == 0.2 + + +# --------------------------------------------------------------------- +# verify_array +# --------------------------------------------------------------------- + +def test_verify_array_numpy(): + + verifier = make_verifier() + + verifier.extractor.emb_waveform.return_value = np.random.rand(192) + + verifier.model.predict_proba.return_value = [ + [0.2, 0.8] + ] + + waveform = np.random.rand( + 16000 + ).astype(np.float32) + + verified, score = verifier.verify_array( + waveform + ) + + assert verified is True + assert score == 0.8 + + verifier.extractor.emb_waveform.assert_called_once() + + +def test_verify_array_tensor(): + + verifier = make_verifier() + + verifier.extractor.emb_waveform.return_value = np.random.rand(192) + + verifier.model.predict_proba.return_value = [ + [0.6, 0.4] + ] + + waveform = torch.rand( + 1, + 16000, + ) + + verified, score = verifier.verify_array( + waveform + ) + + assert verified is False + assert score == 0.4 + + verifier.extractor.emb_waveform.assert_called_once_with( + waveform + ) + + +# --------------------------------------------------------------------- +# verify_live +# --------------------------------------------------------------------- + +@patch("vocalid.verifier.record_audio") +def test_verify_live(mock_record): + + verifier = make_verifier() + + waveform = torch.rand( + 1, + 16000, + ) + + mock_record.return_value = waveform + + verifier.verify_array = MagicMock( + return_value=( + True, + 0.95, + ) + ) + + verified, score = verifier.verify_live( + seconds=5 + ) + + mock_record.assert_called_once_with( + 5 + ) + + verifier.verify_array.assert_called_once_with( + waveform, + THRESHOLD, + ) + + assert verified is True + assert score == 0.95 \ No newline at end of file From a5e970cae7a82a16e3cbfbccecb482836b07c1be Mon Sep 17 00:00:00 2001 From: tayyaba-code Date: Thu, 23 Jul 2026 20:48:28 +0500 Subject: [PATCH 10/10] Remove temporary coverage and test artifacts --- .coverage | Bin 53248 -> 0 bytes dummy_model.pkl | Bin 2399 -> 0 bytes test.py | 21 --------------------- 3 files changed, 21 deletions(-) delete mode 100644 .coverage delete mode 100644 dummy_model.pkl delete mode 100644 test.py diff --git a/.coverage b/.coverage deleted file mode 100644 index 0523a13dfda9f5e7a052c5c9ef95f732420cc816..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53248 zcmeI4U2Ggz702gecV}mJXKrW`ZHyZ{qN0w8?c$QEcAPeGnjj1np^%o)TCB&rv-Tvr zGtSIxY%4@zOQlq(^bL3*Dt#e&f)@ll@b!S85(uFxsH%|otQ1lNsSrgGa;Z3XKD@h* zU3H|WO|AcEckkSfbLX7jJ@?+dvwP>Wk34J#mQi=TrWqI?6}AbYC>$~jK@e1WYxGVI zg-$X_ghH|Fe6v$k82!o)jXfrmawi1#1?~N8q%>dpSMg$LTz#}?E5qt2W#9k-5C8!X z*fIp#kCc>hU!VBoxxk!jSb=9&Eid}i9{SADspChD<5PzpK59g1#-4&f$H<5=Wq9tA zv1oZl-ELTh?bPh58Q9Le5iD3q>6UNR_?$&)G;8sKqoF)MtJpP?3aojmV$rjkrnh38 zvQ~z}0`b)XYdMG#s1nPbcX$b7Pb@xUcvju=ET?MukrsRG+E8KW%jXM9dHZ(pf)(0i zdR2NAd#s^^jw#c1s;KIEHLm@fXFAmd%O5t(H4CdQ>A4(4)_QL9p1?I4wiBA^_;z5s zj$tiZ)mC8DHZ*XULrV^V*KG~eHOmc}jU?l1b@`oZ}`~JA(YuBpmAO zw3>5-{A8=S$Qy?wq6-J^W|Eu925xNZ+isf;+OlJBPje>7d5cC;Eyq6Hvep`Z*yuKt3=EUVHWARD8tz?w)&YfaA>JfZg;_qya5j>^S1=dXjZ(sdI zGgvTg)}1Gl;)B^-d2;7wigOhzWBfDs)HW$p`Dr<%-=smGzvUj4c}hp+Pg*M@XUqnT zCX1%+QJ1ALIGOD1!+iciB)tu*%Y&dIM64I(6H3{t1`sf(_wGpRJr;009sH0T2KI5C8!X z009sH0T9@F1Y}VX6~6wL*q;S z%gchBLbBShN$P+i3+GcvHQk!ksQA=m+~CPvO7QgPnHqo0j!(i9*>nv~D`C5ChqgYL zA<$w9wRuBT=)n$)K`4K}EDNm^;`Dfox;@WvN$GHzt(xstT7lgNEf&*RT&dWO9aJiG zVgCESUb-x>U$Une)9+%(v`hLov?sI==$|jWqP<$WtpDyE>*lx+2!H?xfB*=900@8p z2!H?x2*RX%HqElEd;PEPmsirrcCG)l1M;~vqCM;X(kC+Qa(d~F*8j!BnYNOqs+-sU zg$a2nO%0v(zj~kSrjd-+|M>^x^J%1d*Z)dd@ZIbGT*?OTTK{JcX4+yJwb!lxGvjhA zjriL7Urvducl|F-$SZ1!+YVjpe?0#W!z&1Y00@8p2!H?xfB*=900@8p2yAHrGW}~n z#NSG7tZb|Qrd?#>8rvE;~&clMn&M_vRL? z{+DlL4qScl%Hw}|^Yy~&m7SuNqntYn(jnoSKf7^-<+2pjW&Mr}g?Hzdm%QixHuFR0 zf8Y7RUa=@s+OCXr@$~hVUhR`8vQN79H9*8CBKAaQ{Dh zUSO;2b@nIrPxd_B1^6+$%D&59X1`)Tr~3hyw_p>)BoF`r5C8!X009sH0T2KI5C8!X zxUC5Ag}<29!$1#j1BhxVjA_L%PzqsCQp2E_4}+W%28CQ0$k{N+XTl&Ohk+zz=mtRe z`@gX5wlW#YfB*=900@8p2!H?xfB*=900@8p2;5=<{P%yX|8Fr=R0jee00JNY0w4ea xAOHd&00JNY0=F3fzW*QV|J$rlpbiLt00@8p2!H?xfB*=900@8p2;5=<{|7s05taY| diff --git a/dummy_model.pkl b/dummy_model.pkl deleted file mode 100644 index a60d712ccff462b2223c3c350e54cc38a3b8a3de..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2399 zcmaJ@XH*nf7N)VG5dlHR2r7ao%nBk#KzR%3vf2vDGNx79hAz5$?POPxbVfn4mc(i@ zNQNdzXh9V85=KlgCeRr%q0AZ(WRwxX9oP~voHJ+l{iwS4)xG!o>Z^L^-XIMHQ(bLr zk#q5VB8)4SSc!xZ`pywcd6>wGBa-rkI3e_=$SIq~O?Qkh$8cOIl~4(kMKJXPWlpkW zn1m}L0x43PhsoraHcT$&=EVlzk`ZxI*AIsTZCFCZvaQb*2} z@T6i6PSEhQuZc@VewdshHAG%Me4HY+#M}UmkSslgw(@mTPo+#sR9Tm<5|{{B0#kt*&2ae_h60N}^Ar3fQn2^~Fl!uCgD(FCL&IB&`TQAy-4Om2hR;_j z{9lX-G{#PrP}cNY5HQ>i7+X%v26bidAMKtEaQKZ!>g1Smz?9i{#S^38)X@9Jl{pG< za!J1Y=x1M;%4R(4wXTEx7q3NCwk0Cd`49Cvs#VCmHlo(%?-5}5^x&*mn<_NVjn6EW z%AuBhwT@5~0QIsjwtcf0>14A!1UebuZYus}&rv13p|D$8(Rna(F0HlAL4h6-H+&vh zR6~2~t2XCLr_k^$@e*vG63uYW+x?*CG-3q4^jL3@feIgXzs8zWh>c`vcH6SiQ^6!d zt)4Vw3vJAj<<02O9IKJtll{;My+e1sRYh=n@2*{^cQl};Se=YNItrop;m5!eS*$G4ozgoLe+ZB};X!0ZW63lIGV zgP9lfaD#V3xM~=><8MBvk$&Tn$*SB0z?r>E6VoG*wa{nk0gWF~^T_S6ngu5y&3x6n znCvn*bkTCCIpY`v`#Zi7m7GT}GA$cz29n^yL{H;0vV7QQ>vwR*a1;bY%Bo-cm4kKL zknfRgDm25Ku~;sQK&5&WnBS|D$j$Sb@$P|K=pVG0`=&D$H3YW~8aUQM%e&^LZwzx_ zz$J$fdNC2rFRU9RCgq^&mGgN$|EvU!E9W(z*Vn-O+tubVvKDaf3NgR>z64q)J88u@ z7NORvOzj`S^FU;W49)w?ke<6o(UJ{HSoL%LaO16V7%(y2qdAxh`rNsg1S$F$ig zccudJsfGJh>~i#veS0GgtCfJy#`fC{SSlPeve9;ZR)KU&2kU;hl!ta)c-h|MQVf-A z^dAu}N)#04d-8lvCdA%oxsbArf-A-f3lFDkpk8E3i*gFkkjYTK<+Kzq`oVZtCoc<< zqt_MI)TW`tMBK4^uL|9X>yFB*KaTc>PdcmJT#Rl!t2a{XDuoh%zaACuB!rigIJ)D-Bn{P!IT&71ZbjU{w%^Lr` zX5;(eMykiVRRyHbrMLO zvY2zkRq!hIfmVuU0m_uc>>gO1gErXnGuNG}hIQW;n{^2b(AEe08!o-bMwdJA;r!TB zNG6@_%Y3^XinE_DJ#(K2PbR6tzYEMlGiNm}a`lWy1JgJ1ID7><>h$Z{?koAgU+Z{M zzb}J6FZ)!+mp##w^8Gi-kXk61-Z&&1C;*))URQ?9iqPF zqC2(uz~s%CwBm6ibPWzzy^D!Pc43dF$LPhO(w!6UvQ5)qwTW1i@G=A{GTM))pRGi- zn=5}wZi`253(NW*&viy>l^TD2eXRuAoILx3byI=2X>nEvgFb&w9hVi$Qs5XP?4Zea zbtu{-WQv)y0&!o=`zbf80L8`2NBPsvBIqf#@>q2Q1|-3ToT<4e)~s(rg>xaQyTXCol%9By>&HrI9i5u=WQRkGFS%nOw~_4D@xF^^W6KRcS_NX-S=%@ELc3bOqEbZ2}+De6kY`pV1!9b9o)o4FwsO^@Q+tvhoT-i=Oq z%W;vxdRt!TLbp&PY;aw0^>`&pKoy4zEI806*Xo7<1+soNXH$iG4$_OY)ekz71-yp% zwT)CR#Ij6#H0=|?%wbQ9?PwBOX&*c6oqPg1l&?%x%c`Kb=|SA}xO`vBpTXMAkp(6hwYNJMHZwX(IcrhL7u{tMheWTXH9 diff --git a/test.py b/test.py deleted file mode 100644 index e04ce6b..0000000 --- a/test.py +++ /dev/null @@ -1,21 +0,0 @@ -from pathlib import Path - -replacements = { - "list[": "List[", - "dict[": "Dict[", - "tuple[": "Tuple[", - "set[": "Set[", -} - -for file in Path("src").rglob("*.py"): - text = file.read_text(encoding="utf-8") - - changed = False - for old, new in replacements.items(): - if old in text: - text = text.replace(old, new) - changed = True - - if changed: - file.write_text(text, encoding="utf-8") - print("Updated", file) \ No newline at end of file