test_speaker_enroll.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. """Tests for offline speaker pre-enrollment (WAV reading + CLI handler).
  2. Hardware- and network-free: WAVs are synthesized with stdlib ``wave`` and the
  3. embedder is a fake; the real sherpa-onnx package is never required.
  4. """
  5. import argparse
  6. import wave
  7. from pathlib import Path
  8. import numpy as np
  9. import pytest
  10. import stt_daemon.cli as cli_module
  11. from stt_daemon.enroll import enroll_from_files, read_wav
  12. from stt_daemon.speakers import SpeakerStore
  13. def write_mono_wav(path: Path, samples: np.ndarray, sample_rate: int = 16000,
  14. nchannels: int = 1) -> None:
  15. """Write int16 PCM WAV frames from float32 samples in [-1, 1]."""
  16. frames = (np.asarray(samples, dtype=np.float32) * 32767.0).astype(np.int16)
  17. if nchannels == 2:
  18. frames = np.stack([frames, frames], axis=1).reshape(-1)
  19. with wave.open(str(path), "wb") as wav:
  20. wav.setnchannels(nchannels)
  21. wav.setsampwidth(2)
  22. wav.setframerate(sample_rate)
  23. wav.writeframes(frames.tobytes())
  24. def make_wav(path: Path, seconds: float, sample_rate: int = 16000,
  25. nchannels: int = 1) -> Path:
  26. """Write a sine clip of ``seconds`` and return the path."""
  27. count = int(seconds * sample_rate)
  28. t = np.arange(count, dtype=np.float32) / sample_rate
  29. samples = 0.5 * np.sin(2.0 * np.pi * 440.0 * t).astype(np.float32)
  30. write_mono_wav(path, samples, sample_rate, nchannels)
  31. return path
  32. class FakeEmbedder:
  33. """Deterministic unit-norm embedding from audio energy."""
  34. def __init__(self) -> None:
  35. self.calls = 0
  36. def embed(self, audio: np.ndarray) -> np.ndarray:
  37. self.calls += 1
  38. energy = float(np.mean(np.asarray(audio, dtype=np.float32) ** 2))
  39. vector = np.array([energy + self.calls, 1.0], dtype=np.float32)
  40. return vector / np.linalg.norm(vector)
  41. # -- read_wav -----------------------------------------------------------------
  42. def test_read_wav_happy_path(tmp_path):
  43. wav = make_wav(tmp_path / "clip.wav", seconds=1.0)
  44. samples, rate = read_wav(wav)
  45. assert rate == 16000
  46. assert samples.dtype == np.float32
  47. assert len(samples) == 16000
  48. assert float(np.abs(samples).max()) <= 1.0
  49. def test_read_wav_rejects_stereo(tmp_path):
  50. wav = make_wav(tmp_path / "stereo.wav", seconds=1.0, nchannels=2)
  51. with pytest.raises(ValueError, match="mono"):
  52. read_wav(wav)
  53. def test_read_wav_rejects_non_16k(tmp_path):
  54. wav = make_wav(tmp_path / "clip8k.wav", seconds=1.0, sample_rate=8000)
  55. with pytest.raises(ValueError, match="16 kHz"):
  56. read_wav(wav)
  57. def test_read_wav_rejects_non_wav(tmp_path):
  58. bogus = tmp_path / "notes.txt"
  59. bogus.write_text("not audio", encoding="utf-8")
  60. with pytest.raises(ValueError, match="not a WAV"):
  61. read_wav(bogus)
  62. def test_read_wav_rejects_unsupported_width(tmp_path):
  63. wav = tmp_path / "clip8bit.wav"
  64. with wave.open(str(wav), "wb") as handle:
  65. handle.setnchannels(1)
  66. handle.setsampwidth(1)
  67. handle.setframerate(16000)
  68. handle.writeframes(bytes([128] * 16000))
  69. with pytest.raises(ValueError, match="sample width"):
  70. read_wav(wav)
  71. # -- enroll_from_files ----------------------------------------------------------
  72. def test_enroll_from_files_enrolls_and_saves(tmp_path):
  73. wav = make_wav(tmp_path / "clip.wav", seconds=2.5)
  74. store = SpeakerStore(tmp_path / "store")
  75. embedder = FakeEmbedder()
  76. speaker_id = enroll_from_files(store, embedder, "Efren", [wav], 2.0)
  77. assert speaker_id == "spk_0001"
  78. assert embedder.calls == 1
  79. assert (tmp_path / "store" / "speakers.json").exists()
  80. fresh = SpeakerStore(tmp_path / "store")
  81. fresh.load()
  82. assert fresh.speakers[speaker_id]["name"] == "Efren"
  83. assert fresh.speakers[speaker_id]["status"] == "confirmed"
  84. def test_enroll_from_files_rejects_short_clip(tmp_path):
  85. wav = make_wav(tmp_path / "short.wav", seconds=0.5)
  86. store = SpeakerStore(tmp_path / "store")
  87. with pytest.raises(ValueError, match="too short"):
  88. enroll_from_files(store, FakeEmbedder(), "Efren", [wav], 2.0)
  89. # -- CLI handler ------------------------------------------------------------------
  90. def run_handler(monkeypatch, tmp_path, *, name="Efren", wavs=None,
  91. model_ok=True, min_enroll_s=2.0, force=False):
  92. """Drive ``_handle_speaker_enroll`` with fakes; return (rc, out, err)."""
  93. if wavs is None:
  94. wavs = [make_wav(tmp_path / "clip.wav", seconds=2.5)]
  95. model = tmp_path / "model.onnx"
  96. if model_ok:
  97. model.write_bytes(b"fake-model")
  98. cfg_path = tmp_path / "config.toml"
  99. cfg_path.write_text(
  100. "[speaker]\n"
  101. f'model_path = "{model}"\n'
  102. f"min_enroll_s = {min_enroll_s}\n"
  103. f'store_dir = "{tmp_path / "speakers"}"\n'
  104. "[events]\n"
  105. f'socket = "{tmp_path / "bus.sock"}"\n',
  106. encoding="utf-8",
  107. )
  108. fake = FakeEmbedder()
  109. class FakeSherpa:
  110. def __init__(self, model_path, provider="cpu"):
  111. self.embedder = fake
  112. def embed(self, audio):
  113. return fake.embed(audio)
  114. monkeypatch.setattr(cli_module, "SherpaEmbedder", FakeSherpa)
  115. args = argparse.Namespace(
  116. config=str(cfg_path), name=name, wav=[str(w) for w in wavs], force=force
  117. )
  118. import io
  119. from contextlib import redirect_stderr, redirect_stdout
  120. out, err = io.StringIO(), io.StringIO()
  121. with redirect_stdout(out), redirect_stderr(err):
  122. rc = cli_module._handle_speaker_enroll(args)
  123. return rc, out.getvalue(), err.getvalue()
  124. def test_cli_handler_success(monkeypatch, tmp_path):
  125. rc, out, _ = run_handler(monkeypatch, tmp_path)
  126. assert rc == 0
  127. assert "enrolled 'Efren' as spk_0001" in out
  128. assert "1 clip(s)" in out
  129. def test_cli_handler_missing_model(monkeypatch, tmp_path):
  130. rc, _, err = run_handler(monkeypatch, tmp_path, model_ok=False)
  131. assert rc == 1
  132. assert "model" in err.lower()
  133. def test_cli_handler_short_wav(monkeypatch, tmp_path):
  134. short = make_wav(tmp_path / "short.wav", seconds=0.5)
  135. rc, _, err = run_handler(monkeypatch, tmp_path, wavs=[short])
  136. assert rc == 1
  137. assert "too short" in err
  138. def test_cli_handler_dedupes_and_saves(monkeypatch, tmp_path):
  139. """Two identical clips collapse to one prototype; the line says so."""
  140. wavs = [make_wav(tmp_path / f"clip{i}.wav", seconds=2.5) for i in range(2)]
  141. model = tmp_path / "model.onnx"
  142. model.write_bytes(b"fake-model")
  143. cfg_path = tmp_path / "config.toml"
  144. cfg_path.write_text(
  145. "[speaker]\n"
  146. f'model_path = "{model}"\n'
  147. "min_enroll_s = 2.0\n"
  148. f'store_dir = "{tmp_path / "speakers"}"\n'
  149. "[events]\n"
  150. f'socket = "{tmp_path / "bus.sock"}"\n',
  151. encoding="utf-8",
  152. )
  153. class ConstEmbedder:
  154. def embed(self, audio):
  155. return np.array([1.0, 0.0], dtype=np.float32)
  156. class FakeSherpa:
  157. def __init__(self, model_path, provider="cpu"):
  158. self._embedder = ConstEmbedder()
  159. def embed(self, audio):
  160. return self._embedder.embed(audio)
  161. monkeypatch.setattr(cli_module, "SherpaEmbedder", FakeSherpa)
  162. args = argparse.Namespace(
  163. config=str(cfg_path), name="Efren",
  164. wav=[str(w) for w in wavs], force=True, dedupe_threshold=0.98,
  165. )
  166. import io
  167. from contextlib import redirect_stderr, redirect_stdout
  168. out, err = io.StringIO(), io.StringIO()
  169. with redirect_stdout(out), redirect_stderr(err):
  170. rc = cli_module._handle_speaker_enroll(args)
  171. assert rc == 0
  172. assert "removed 1 duplicate prototype(s)" in out.getvalue()
  173. fresh = SpeakerStore(tmp_path / "speakers")
  174. fresh.load()
  175. assert fresh.speakers["spk_0001"]["prototypes"] == 1