test_cli_speaker_eval.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. """Tests for the speaker-eval CLI plus the new enroll threshold flags.
  2. Hardware- and network-free: WAVs are synthesized with stdlib ``wave`` (a DC
  3. level encodes the "speaker id"), the embedder is a fake, and the real
  4. sherpa-onnx package is never required.
  5. """
  6. import argparse
  7. import io
  8. import wave
  9. from contextlib import redirect_stderr, redirect_stdout
  10. from pathlib import Path
  11. import numpy as np
  12. import stt_daemon.cli as cli_module
  13. from stt_daemon.enroll import enroll_from_files
  14. from stt_daemon.speakers import SpeakerStore
  15. A = 0.30
  16. B = -0.30
  17. def write_const_wav(path: Path, seconds: float, value: float) -> Path:
  18. """Write a constant-level int16 PCM WAV; the level encodes the speaker."""
  19. count = int(seconds * 16000)
  20. samples = np.full(count, value, dtype=np.float32)
  21. frames = (samples * 32767.0).astype(np.int16)
  22. with wave.open(str(path), "wb") as handle:
  23. handle.setnchannels(1)
  24. handle.setsampwidth(2)
  25. handle.setframerate(16000)
  26. handle.writeframes(frames.tobytes())
  27. return path
  28. class LevelEmbedder:
  29. """Deterministic embedding from the clip DC level (the fake speaker id)."""
  30. def __init__(self, dim: int = 2) -> None:
  31. self.dim = dim
  32. def embed(self, audio: np.ndarray) -> np.ndarray:
  33. mean = float(np.mean(np.asarray(audio, dtype=np.float32)))
  34. if self.dim == 2:
  35. vector = [1.0, 0.0] if mean > 0.15 else (
  36. [0.0, 1.0] if mean < -0.15 else [0.7071, 0.7071])
  37. else:
  38. vector = [1.0, 0.0, 0.0] if mean > 0.15 else [0.0, 1.0, 0.0]
  39. return np.array(vector, dtype=np.float32)
  40. class RotEmbedder:
  41. """Same-speaker clips with distinct-but-similar embeddings (20 deg/call)."""
  42. def embed(self, audio: np.ndarray) -> np.ndarray:
  43. mean = float(np.mean(np.asarray(audio, dtype=np.float32)))
  44. if mean < -0.15:
  45. return np.array([-1.0, 0.0], dtype=np.float32)
  46. RotEmbedder.calls = getattr(RotEmbedder, "calls", 0) + 1
  47. angle = np.deg2rad(20.0 * RotEmbedder.calls)
  48. vector = np.array([np.cos(angle), np.sin(angle)], dtype=np.float32)
  49. return vector / np.linalg.norm(vector)
  50. def write_cfg(tmp_path: Path, *, model_ok: bool = True) -> Path:
  51. """Write a temp config with a temp speaker store; return the cfg path."""
  52. model = tmp_path / "model.onnx"
  53. if model_ok:
  54. model.write_bytes(b"fake-model")
  55. cfg_path = tmp_path / "config.toml"
  56. cfg_path.write_text(
  57. "[speaker]\n"
  58. f'model_path = "{model}"\n'
  59. f'store_dir = "{tmp_path / "speakers"}"\n'
  60. "[events]\n"
  61. f'socket = "{tmp_path / "bus.sock"}"\n',
  62. encoding="utf-8",
  63. )
  64. return cfg_path
  65. def patch_embedder(monkeypatch, embedder) -> None:
  66. """Point cli.SherpaEmbedder at a fake wrapping ``embedder``."""
  67. class FakeSherpa:
  68. def __init__(self, model_path, provider="cpu"):
  69. self._embedder = embedder
  70. def embed(self, audio):
  71. return embedder.embed(audio)
  72. monkeypatch.setattr(cli_module, "SherpaEmbedder", FakeSherpa)
  73. def enroll_name(store_dir: Path, name: str, clip: Path,
  74. embedder, min_enroll_s: float = 2.0) -> None:
  75. """Enroll one clip under ``name`` into the temp store."""
  76. store = SpeakerStore(store_dir)
  77. enroll_from_files(store, embedder, name, [clip], min_enroll_s)
  78. def run_eval(monkeypatch, tmp_path, *, labeled, min_accuracy=None,
  79. model_ok=True, dim=2, pre_enroll_dim=None):
  80. """Drive ``_handle_speaker_eval`` with fakes; return (rc, out, err)."""
  81. cfg_path = write_cfg(tmp_path, model_ok=model_ok)
  82. if pre_enroll_dim is not None:
  83. clip = write_const_wav(tmp_path / "enroll.wav", 2.5, A)
  84. enroll_name(tmp_path / "speakers", "Efren", clip,
  85. LevelEmbedder(dim=pre_enroll_dim))
  86. patch_embedder(monkeypatch, LevelEmbedder(dim=dim))
  87. args = argparse.Namespace(
  88. config=str(cfg_path), labeled=str(labeled), min_accuracy=min_accuracy)
  89. out, err = io.StringIO(), io.StringIO()
  90. with redirect_stdout(out), redirect_stderr(err):
  91. rc = cli_module._handle_speaker_eval(args)
  92. return rc, out.getvalue(), err.getvalue()
  93. def make_labeled(tmp_path: Path, *, value: float = A,
  94. name: str = "Efren") -> Path:
  95. """Build labeled/<name>/clip.wav at the given DC level."""
  96. labeled = tmp_path / "labeled"
  97. clip_dir = labeled / name
  98. clip_dir.mkdir(parents=True, exist_ok=True)
  99. write_const_wav(clip_dir / "clip.wav", 2.5, value)
  100. return labeled
  101. def test_eval_report_success(monkeypatch, tmp_path):
  102. clip = write_const_wav(tmp_path / "enroll.wav", 2.5, A)
  103. enroll_name(tmp_path / "speakers", "Efren", clip, LevelEmbedder(dim=2))
  104. labeled = make_labeled(tmp_path)
  105. rc, out, _ = run_eval(monkeypatch, tmp_path, labeled=labeled)
  106. assert rc == 0
  107. assert "total: 1" in out
  108. assert "accuracy: 1.000" in out
  109. def test_eval_below_min_accuracy(monkeypatch, tmp_path):
  110. clip = write_const_wav(tmp_path / "enroll.wav", 2.5, A)
  111. enroll_name(tmp_path / "speakers", "Efren", clip, LevelEmbedder(dim=2))
  112. labeled = make_labeled(tmp_path, value=B)
  113. rc, _, err = run_eval(monkeypatch, tmp_path, labeled=labeled,
  114. min_accuracy=0.5)
  115. assert rc == 1
  116. assert "below --min-accuracy" in err
  117. def test_eval_missing_model(monkeypatch, tmp_path):
  118. labeled = make_labeled(tmp_path)
  119. rc, _, err = run_eval(monkeypatch, tmp_path, labeled=labeled,
  120. model_ok=False)
  121. assert rc == 1
  122. assert "model" in err.lower()
  123. def test_eval_dim_mismatch(monkeypatch, tmp_path):
  124. labeled = make_labeled(tmp_path)
  125. rc, _, err = run_eval(monkeypatch, tmp_path, labeled=labeled,
  126. dim=2, pre_enroll_dim=3)
  127. assert rc == 1
  128. assert "different model" in err
  129. def test_eval_missing_labeled_path(monkeypatch, tmp_path):
  130. write_cfg(tmp_path)
  131. patch_embedder(monkeypatch, LevelEmbedder(dim=2))
  132. args = argparse.Namespace(
  133. config=str(tmp_path / "config.toml"),
  134. labeled=str(tmp_path / "does-not-exist"), min_accuracy=None)
  135. out, err = io.StringIO(), io.StringIO()
  136. with redirect_stdout(out), redirect_stderr(err):
  137. rc = cli_module._handle_speaker_eval(args)
  138. assert rc == 0
  139. assert "total: 0" in out.getvalue()
  140. def test_seed_handler_forwards_new_thresholds(monkeypatch, tmp_path):
  141. audio_dir = tmp_path / "audio"
  142. audio_dir.mkdir(exist_ok=True)
  143. seed = write_const_wav(audio_dir / "seed.wav", 2.5, A)
  144. write_const_wav(audio_dir / "a1.wav", 2.5, A)
  145. write_const_wav(audio_dir / "b1.wav", 2.5, B)
  146. cfg_path = write_cfg(tmp_path)
  147. RotEmbedder.calls = 0
  148. patch_embedder(monkeypatch, RotEmbedder())
  149. args = argparse.Namespace(
  150. config=str(cfg_path), name="Efren", seed=str(seed), count=1,
  151. min_score=0.55, audio_dir=str(audio_dir), max_scan=0, yes=True,
  152. no_play=True, force=True, dedupe_threshold=0.97,
  153. other_threshold=0.44)
  154. out, err = io.StringIO(), io.StringIO()
  155. with redirect_stdout(out), redirect_stderr(err):
  156. rc = cli_module._handle_speaker_enroll_seed(args)
  157. assert rc == 0
  158. assert "from seed" in out.getvalue()