| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241 |
- """Tests for offline speaker pre-enrollment (WAV reading + CLI handler).
- Hardware- and network-free: WAVs are synthesized with stdlib ``wave`` and the
- embedder is a fake; the real sherpa-onnx package is never required.
- """
- import argparse
- import wave
- from pathlib import Path
- import numpy as np
- import pytest
- import stt_daemon.cli as cli_module
- from stt_daemon.enroll import enroll_from_files, read_wav
- from stt_daemon.speakers import SpeakerStore
- def write_mono_wav(path: Path, samples: np.ndarray, sample_rate: int = 16000,
- nchannels: int = 1) -> None:
- """Write int16 PCM WAV frames from float32 samples in [-1, 1]."""
- frames = (np.asarray(samples, dtype=np.float32) * 32767.0).astype(np.int16)
- if nchannels == 2:
- frames = np.stack([frames, frames], axis=1).reshape(-1)
- with wave.open(str(path), "wb") as wav:
- wav.setnchannels(nchannels)
- wav.setsampwidth(2)
- wav.setframerate(sample_rate)
- wav.writeframes(frames.tobytes())
- def make_wav(path: Path, seconds: float, sample_rate: int = 16000,
- nchannels: int = 1) -> Path:
- """Write a sine clip of ``seconds`` and return the path."""
- count = int(seconds * sample_rate)
- t = np.arange(count, dtype=np.float32) / sample_rate
- samples = 0.5 * np.sin(2.0 * np.pi * 440.0 * t).astype(np.float32)
- write_mono_wav(path, samples, sample_rate, nchannels)
- return path
- class FakeEmbedder:
- """Deterministic unit-norm embedding from audio energy."""
- def __init__(self) -> None:
- self.calls = 0
- def embed(self, audio: np.ndarray) -> np.ndarray:
- self.calls += 1
- energy = float(np.mean(np.asarray(audio, dtype=np.float32) ** 2))
- vector = np.array([energy + self.calls, 1.0], dtype=np.float32)
- return vector / np.linalg.norm(vector)
- # -- read_wav -----------------------------------------------------------------
- def test_read_wav_happy_path(tmp_path):
- wav = make_wav(tmp_path / "clip.wav", seconds=1.0)
- samples, rate = read_wav(wav)
- assert rate == 16000
- assert samples.dtype == np.float32
- assert len(samples) == 16000
- assert float(np.abs(samples).max()) <= 1.0
- def test_read_wav_rejects_stereo(tmp_path):
- wav = make_wav(tmp_path / "stereo.wav", seconds=1.0, nchannels=2)
- with pytest.raises(ValueError, match="mono"):
- read_wav(wav)
- def test_read_wav_rejects_non_16k(tmp_path):
- wav = make_wav(tmp_path / "clip8k.wav", seconds=1.0, sample_rate=8000)
- with pytest.raises(ValueError, match="16 kHz"):
- read_wav(wav)
- def test_read_wav_rejects_non_wav(tmp_path):
- bogus = tmp_path / "notes.txt"
- bogus.write_text("not audio", encoding="utf-8")
- with pytest.raises(ValueError, match="not a WAV"):
- read_wav(bogus)
- def test_read_wav_rejects_unsupported_width(tmp_path):
- wav = tmp_path / "clip8bit.wav"
- with wave.open(str(wav), "wb") as handle:
- handle.setnchannels(1)
- handle.setsampwidth(1)
- handle.setframerate(16000)
- handle.writeframes(bytes([128] * 16000))
- with pytest.raises(ValueError, match="sample width"):
- read_wav(wav)
- # -- enroll_from_files ----------------------------------------------------------
- def test_enroll_from_files_enrolls_and_saves(tmp_path):
- wav = make_wav(tmp_path / "clip.wav", seconds=2.5)
- store = SpeakerStore(tmp_path / "store")
- embedder = FakeEmbedder()
- speaker_id = enroll_from_files(store, embedder, "Efren", [wav], 2.0)
- assert speaker_id == "spk_0001"
- assert embedder.calls == 1
- assert (tmp_path / "store" / "speakers.json").exists()
- fresh = SpeakerStore(tmp_path / "store")
- fresh.load()
- assert fresh.speakers[speaker_id]["name"] == "Efren"
- assert fresh.speakers[speaker_id]["status"] == "confirmed"
- def test_enroll_from_files_rejects_short_clip(tmp_path):
- wav = make_wav(tmp_path / "short.wav", seconds=0.5)
- store = SpeakerStore(tmp_path / "store")
- with pytest.raises(ValueError, match="too short"):
- enroll_from_files(store, FakeEmbedder(), "Efren", [wav], 2.0)
- # -- CLI handler ------------------------------------------------------------------
- def run_handler(monkeypatch, tmp_path, *, name="Efren", wavs=None,
- model_ok=True, min_enroll_s=2.0, force=False):
- """Drive ``_handle_speaker_enroll`` with fakes; return (rc, out, err)."""
- if wavs is None:
- wavs = [make_wav(tmp_path / "clip.wav", seconds=2.5)]
- model = tmp_path / "model.onnx"
- if model_ok:
- model.write_bytes(b"fake-model")
- cfg_path = tmp_path / "config.toml"
- cfg_path.write_text(
- "[speaker]\n"
- f'model_path = "{model}"\n'
- f"min_enroll_s = {min_enroll_s}\n"
- f'store_dir = "{tmp_path / "speakers"}"\n'
- "[events]\n"
- f'socket = "{tmp_path / "bus.sock"}"\n',
- encoding="utf-8",
- )
- fake = FakeEmbedder()
- class FakeSherpa:
- def __init__(self, model_path, provider="cpu"):
- self.embedder = fake
- def embed(self, audio):
- return fake.embed(audio)
- monkeypatch.setattr(cli_module, "SherpaEmbedder", FakeSherpa)
- args = argparse.Namespace(
- config=str(cfg_path), name=name, wav=[str(w) for w in wavs], force=force
- )
- import io
- from contextlib import redirect_stderr, redirect_stdout
- out, err = io.StringIO(), io.StringIO()
- with redirect_stdout(out), redirect_stderr(err):
- rc = cli_module._handle_speaker_enroll(args)
- return rc, out.getvalue(), err.getvalue()
- def test_cli_handler_success(monkeypatch, tmp_path):
- rc, out, _ = run_handler(monkeypatch, tmp_path)
- assert rc == 0
- assert "enrolled 'Efren' as spk_0001" in out
- assert "1 clip(s)" in out
- def test_cli_handler_missing_model(monkeypatch, tmp_path):
- rc, _, err = run_handler(monkeypatch, tmp_path, model_ok=False)
- assert rc == 1
- assert "model" in err.lower()
- def test_cli_handler_short_wav(monkeypatch, tmp_path):
- short = make_wav(tmp_path / "short.wav", seconds=0.5)
- rc, _, err = run_handler(monkeypatch, tmp_path, wavs=[short])
- assert rc == 1
- assert "too short" in err
- def test_cli_handler_dedupes_and_saves(monkeypatch, tmp_path):
- """Two identical clips collapse to one prototype; the line says so."""
- wavs = [make_wav(tmp_path / f"clip{i}.wav", seconds=2.5) for i in range(2)]
- model = tmp_path / "model.onnx"
- model.write_bytes(b"fake-model")
- cfg_path = tmp_path / "config.toml"
- cfg_path.write_text(
- "[speaker]\n"
- f'model_path = "{model}"\n'
- "min_enroll_s = 2.0\n"
- f'store_dir = "{tmp_path / "speakers"}"\n'
- "[events]\n"
- f'socket = "{tmp_path / "bus.sock"}"\n',
- encoding="utf-8",
- )
- class ConstEmbedder:
- def embed(self, audio):
- return np.array([1.0, 0.0], dtype=np.float32)
- class FakeSherpa:
- def __init__(self, model_path, provider="cpu"):
- self._embedder = ConstEmbedder()
- def embed(self, audio):
- return self._embedder.embed(audio)
- monkeypatch.setattr(cli_module, "SherpaEmbedder", FakeSherpa)
- args = argparse.Namespace(
- config=str(cfg_path), name="Efren",
- wav=[str(w) for w in wavs], force=True, dedupe_threshold=0.98,
- )
- import io
- from contextlib import redirect_stderr, redirect_stdout
- out, err = io.StringIO(), io.StringIO()
- with redirect_stdout(out), redirect_stderr(err):
- rc = cli_module._handle_speaker_enroll(args)
- assert rc == 0
- assert "removed 1 duplicate prototype(s)" in out.getvalue()
- fresh = SpeakerStore(tmp_path / "speakers")
- fresh.load()
- assert fresh.speakers["spk_0001"]["prototypes"] == 1
|