testsAndMisc/linux_configuration/tests/test_transcribe_diarize_pipeline.py
Krzysztof kuhy Rudnicki fb51e991fe _transcribe_diarize: cover 0% -> 100% before splitting
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
2026-08-17 13:54:04 +02:00

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
]