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
236 lines
7.9 KiB
Python
236 lines
7.9 KiB
Python
"""Tests for the diarize_segments pipeline end to end.
|
|
|
|
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
|
|
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
import _transcribe_diarize as diar
|
|
import numpy as np
|
|
|
|
if TYPE_CHECKING:
|
|
from contextlib import AbstractContextManager
|
|
from pathlib import Path
|
|
|
|
import numpy.typing as npt
|
|
import pytest
|
|
|
|
|
|
_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
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# diarize_segments
|
|
# --------------------------------------------------------------------------- #
|
|
def test_diarize_segments_labels_two_speakers(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The whole pipeline turns four segments into two speaker labels."""
|
|
wav = np.zeros(_SR_16K * 10, dtype=np.float32)
|
|
monkeypatch.setattr(diar, "_try_import", _importer({"torch": _Torch()}))
|
|
monkeypatch.setattr(diar, "_load_audio", lambda _p: (wav, _SR_16K, None))
|
|
monkeypatch.setattr(
|
|
diar,
|
|
"_load_speaker_classifier",
|
|
lambda _t: _Classifier(
|
|
[
|
|
np.array([1.0, 0.0], dtype=np.float32),
|
|
np.array([0.0, 1.0], dtype=np.float32),
|
|
]
|
|
),
|
|
)
|
|
segments = [_Segment(float(i), float(i) + 1.0) for i in range(4)]
|
|
|
|
labels = diar.diarize_segments("clip.wav", segments, num_speakers=_TWO_SPEAKERS)
|
|
|
|
assert labels is not None
|
|
assert len(labels) == len(segments)
|
|
assert len(set(labels)) == _TWO_SPEAKERS
|
|
|
|
|
|
def test_diarize_segments_warns_without_torch(
|
|
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""No torch means speaker labels are skipped, not an error."""
|
|
monkeypatch.setattr(diar, "_try_import", lambda _n: None)
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
assert diar.diarize_segments("clip.wav", [_Segment(0.0, 1.0)]) is None
|
|
|
|
assert "skipping speaker labels" in caplog.text
|
|
|
|
|
|
def test_diarize_segments_gives_up_when_the_audio_cannot_be_read(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Unreadable audio ends diarization before any model is loaded."""
|
|
monkeypatch.setattr(diar, "_try_import", _importer({"torch": _Torch()}))
|
|
monkeypatch.setattr(diar, "_load_audio", lambda _p: None)
|
|
|
|
assert diar.diarize_segments("clip.wav", [_Segment(0.0, 1.0)]) is None
|
|
|
|
|
|
def test_diarize_segments_gives_up_without_a_classifier(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A missing embedding model ends diarization."""
|
|
wav = np.zeros(_SR_16K, dtype=np.float32)
|
|
monkeypatch.setattr(diar, "_try_import", _importer({"torch": _Torch()}))
|
|
monkeypatch.setattr(diar, "_load_audio", lambda _p: (wav, _SR_16K, None))
|
|
monkeypatch.setattr(diar, "_load_speaker_classifier", lambda _t: None)
|
|
|
|
assert diar.diarize_segments("clip.wav", [_Segment(0.0, 1.0)]) is None
|
|
|
|
|
|
def test_diarize_segments_returns_none_for_no_segments(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""No segments means no embeddings and so no labels."""
|
|
wav = np.zeros(_SR_16K, dtype=np.float32)
|
|
monkeypatch.setattr(diar, "_try_import", _importer({"torch": _Torch()}))
|
|
monkeypatch.setattr(diar, "_load_audio", lambda _p: (wav, _SR_16K, None))
|
|
monkeypatch.setattr(diar, "_load_speaker_classifier", lambda _t: _Classifier())
|
|
|
|
assert diar.diarize_segments("clip.wav", []) is None
|
|
|
|
|
|
def test_diarize_segments_downmixes_stereo(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A 2-D stereo array is averaged to mono before resampling."""
|
|
stereo = np.zeros((_SR_16K * 2, 2), dtype=np.float32)
|
|
monkeypatch.setattr(diar, "_try_import", _importer({"torch": _Torch()}))
|
|
monkeypatch.setattr(diar, "_load_audio", lambda _p: (stereo, _SR_16K, None))
|
|
monkeypatch.setattr(diar, "_load_speaker_classifier", lambda _t: _Classifier())
|
|
|
|
labels = diar.diarize_segments("clip.wav", [_Segment(0.0, 1.0)], num_speakers=1)
|
|
|
|
assert labels == [0]
|
|
|
|
|
|
def test_diarize_segments_removes_the_temp_file(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""A transcoded temp WAV is deleted once labels are computed."""
|
|
temp = tmp_path / "scratch.wav"
|
|
temp.write_bytes(b"RIFF")
|
|
wav = np.zeros(_SR_16K * 2, dtype=np.float32)
|
|
monkeypatch.setattr(diar, "_try_import", _importer({"torch": _Torch()}))
|
|
monkeypatch.setattr(diar, "_load_audio", lambda _p: (wav, _SR_16K, str(temp)))
|
|
monkeypatch.setattr(diar, "_load_speaker_classifier", lambda _t: _Classifier())
|
|
|
|
diar.diarize_segments("clip.wav", [_Segment(0.0, 1.0)], num_speakers=1)
|
|
|
|
assert not temp.exists()
|
|
|
|
|
|
def test_diarize_segments_treats_zero_speakers_as_one(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""num_speakers=0 would break k-means, so it is floored at one."""
|
|
wav = np.zeros(_SR_16K * 2, dtype=np.float32)
|
|
monkeypatch.setattr(diar, "_try_import", _importer({"torch": _Torch()}))
|
|
monkeypatch.setattr(diar, "_load_audio", lambda _p: (wav, _SR_16K, None))
|
|
monkeypatch.setattr(diar, "_load_speaker_classifier", lambda _t: _Classifier())
|
|
|
|
assert diar.diarize_segments("clip.wav", [_Segment(0.0, 1.0)], num_speakers=0) == [
|
|
0
|
|
]
|