mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:27:02 +01:00
The largest untested Python file left in the repo, at 356 lines against the cap. Tests land first so the split that follows is verified against tests that predate the seam. 63 tests: duration probing through both ffmpeg paths, the transcode fallback and temp-file cleanup, audio loading's three outcomes, linear resampling, cosine k-means, the ECAPA classifier loader, per-segment embedding extraction, and diarize_segments end to end. numpy is real here, not a double. It is a declared dependency, and the core of this file is clustering maths - assertions against a mocked numpy would prove nothing about whether two speakers actually get two labels. The _try_import seam passes numpy through while stubbing torch and speechbrain, so the tests still run in CI with neither installed. The clustering tests assert real properties: two opposed groups separate, magnitude is ignored because the metric is cosine, and a fixed seed reproduces. The last three coverage gaps were _load_audio's ffmpeg fallback in both outcomes, the minimum-slice widening, and the zero-iteration k-means branch. Split across six test modules after two intermediate arrangements went over the cap, and one move silently broke a test by separating it from its doubles - caught by the test count holding at 63 through every rearrangement. 338 tests pass in the directory, up from 275 by exactly the 63 added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
197 lines
6.3 KiB
Python
197 lines
6.3 KiB
Python
"""Tests for speaker-embedding extraction and the diarize_segments pipeline.
|
|
|
|
torch and speechbrain are never installed in CI and are reached only through
|
|
``_try_import``, so both are doubled here. The embedding double returns a
|
|
direction per segment, which lets the clustering downstream be real numpy.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
import _transcribe_diarize as diar
|
|
import numpy as np
|
|
|
|
if TYPE_CHECKING:
|
|
from contextlib import AbstractContextManager
|
|
|
|
import numpy.typing as npt
|
|
|
|
|
|
_SR_16K = 16000
|
|
_TWO_SPEAKERS = 2
|
|
|
|
|
|
class _Segment:
|
|
"""A transcription segment, of which only start and end are read."""
|
|
|
|
def __init__(self, start: float | None, end: float | None) -> None:
|
|
self.start = start
|
|
self.end = end
|
|
|
|
|
|
class _Tensor:
|
|
"""A torch tensor double carrying the slice it was built from."""
|
|
|
|
def __init__(self, data: npt.NDArray[np.float32]) -> None:
|
|
self.data = data
|
|
|
|
def unsqueeze(self, _dim: int) -> _Tensor:
|
|
"""torch tensors are reshaped before batching; the data is unchanged."""
|
|
return self
|
|
|
|
|
|
class _Embedding:
|
|
"""The chain classifier.encode_batch(...).squeeze().squeeze().cpu().numpy()."""
|
|
|
|
def __init__(self, vector: npt.NDArray[np.float32]) -> None:
|
|
self._vector = vector
|
|
|
|
def squeeze(self, _dim: int) -> _Embedding:
|
|
return self
|
|
|
|
def cpu(self) -> _Embedding:
|
|
return self
|
|
|
|
def numpy(self) -> npt.NDArray[np.float32]:
|
|
"""Return the embedding as the array the caller will cast."""
|
|
return self._vector
|
|
|
|
|
|
class _Torch:
|
|
"""A torch module double providing tensor() and no_grad()."""
|
|
|
|
def tensor(self, data: npt.NDArray[np.float32]) -> _Tensor:
|
|
"""Wrap a slice of audio the way torch.tensor would."""
|
|
return _Tensor(data)
|
|
|
|
def no_grad(self) -> AbstractContextManager[None]:
|
|
"""Return a context manager, as torch.no_grad() does."""
|
|
|
|
class _Ctx:
|
|
def __enter__(self) -> None:
|
|
return None
|
|
|
|
def __exit__(self, *_a: object) -> None:
|
|
return None
|
|
|
|
return _Ctx()
|
|
|
|
|
|
class _Classifier:
|
|
"""A speaker classifier double: one direction per call, recorded."""
|
|
|
|
def __init__(self, vectors: list[npt.NDArray[np.float32]] | None = None) -> None:
|
|
self.seen: list[int] = []
|
|
self._vectors = vectors
|
|
self._calls = 0
|
|
|
|
def encode_batch(self, tensor: _Tensor) -> _Embedding:
|
|
"""Record the sample count and return the next canned embedding."""
|
|
self.seen.append(len(tensor.data))
|
|
if self._vectors is not None:
|
|
vector = self._vectors[self._calls % len(self._vectors)]
|
|
else:
|
|
vector = np.array([1.0, 0.0], dtype=np.float32)
|
|
self._calls += 1
|
|
return _Embedding(vector)
|
|
|
|
|
|
def _importer(available: dict[str, object]) -> object:
|
|
"""Return a _try_import double resolving the named modules, plus numpy.
|
|
|
|
numpy is a real declared dependency and the resampling and clustering
|
|
code fetches it through the same seam, so it is always passed through -
|
|
stubbing it would make the clustering assertions meaningless.
|
|
"""
|
|
|
|
def _try(name: str) -> object | None:
|
|
if name == "numpy":
|
|
return np
|
|
return available.get(name)
|
|
|
|
return _try
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# _extract_embeddings
|
|
# --------------------------------------------------------------------------- #
|
|
def test_extract_embeddings_returns_one_vector_per_segment() -> None:
|
|
"""Every segment yields exactly one embedding, in order."""
|
|
wav = np.zeros(_SR_16K * 10, dtype=np.float32)
|
|
segments = [_Segment(0.0, 1.0), _Segment(1.0, 2.0), _Segment(2.0, 3.0)]
|
|
classifier = _Classifier()
|
|
|
|
embeddings = diar._extract_embeddings(segments, wav, classifier, _Torch())
|
|
|
|
assert len(embeddings) == 3
|
|
assert all(e.dtype == np.float32 for e in embeddings)
|
|
|
|
|
|
def test_extract_embeddings_pads_around_each_segment() -> None:
|
|
"""A 50 ms pad each side widens the slice beyond the segment itself."""
|
|
wav = np.zeros(_SR_16K * 10, dtype=np.float32)
|
|
classifier = _Classifier()
|
|
|
|
diar._extract_embeddings([_Segment(1.0, 2.0)], wav, classifier, _Torch())
|
|
|
|
# 1 s of audio plus 0.05 s of padding at each end.
|
|
assert classifier.seen == [int(_SR_16K * 1.1)]
|
|
|
|
|
|
def test_extract_embeddings_enforces_a_minimum_slice() -> None:
|
|
"""A very short segment is widened so the encoder has enough samples."""
|
|
wav = np.zeros(_SR_16K * 10, dtype=np.float32)
|
|
classifier = _Classifier()
|
|
|
|
diar._extract_embeddings([_Segment(1.0, 1.001)], wav, classifier, _Torch())
|
|
|
|
assert classifier.seen[0] >= diar._MIN_SAMPLES_DIAR
|
|
|
|
|
|
def test_extract_embeddings_handles_a_segment_with_no_duration() -> None:
|
|
"""An end at or before the start is given a default 0.2 s span."""
|
|
wav = np.zeros(_SR_16K * 10, dtype=np.float32)
|
|
classifier = _Classifier()
|
|
|
|
diar._extract_embeddings([_Segment(5.0, 5.0)], wav, classifier, _Torch())
|
|
|
|
assert classifier.seen[0] >= diar._MIN_SAMPLES_DIAR
|
|
|
|
|
|
def test_extract_embeddings_treats_missing_times_as_zero() -> None:
|
|
"""A segment with None timings starts at the beginning rather than failing."""
|
|
wav = np.zeros(_SR_16K * 10, dtype=np.float32)
|
|
classifier = _Classifier()
|
|
|
|
embeddings = diar._extract_embeddings(
|
|
[_Segment(None, None)], wav, classifier, _Torch()
|
|
)
|
|
|
|
assert len(embeddings) == 1
|
|
|
|
|
|
def test_extract_embeddings_clamps_to_the_end_of_the_audio() -> None:
|
|
"""A segment running past the recording is clipped to what exists."""
|
|
wav = np.zeros(_SR_16K, dtype=np.float32)
|
|
classifier = _Classifier()
|
|
|
|
diar._extract_embeddings([_Segment(0.5, 100.0)], wav, classifier, _Torch())
|
|
|
|
assert classifier.seen[0] <= len(wav)
|
|
|
|
|
|
def test_extract_embeddings_widens_a_slice_below_the_minimum() -> None:
|
|
"""A segment shorter than the padding still reaches the minimum width.
|
|
|
|
The padded slice of a 1 ms segment is 1600 samples exactly at the start of
|
|
the file, so this uses a segment at offset 0 where only one side is padded
|
|
and the widening branch is the one that has to fire.
|
|
"""
|
|
wav = np.zeros(_SR_16K * 10, dtype=np.float32)
|
|
classifier = _Classifier()
|
|
|
|
diar._extract_embeddings([_Segment(0.0, 0.001)], wav, classifier, _Torch())
|
|
|
|
assert classifier.seen[0] == diar._MIN_SAMPLES_DIAR
|