Files
local-transcriber/tests/test_speaker_diarizer.py
T
Dmitriy Dementiev 726718197f feat(diarization): добавлено разделение транскрипта по говорящим
Зачем:
- локальным транскриптам нужна структура реплик для конспектов и протоколов.

Что:
- добавлены пословные таймкоды для всех ASR-бэкендов и сведение с Sherpa-ONNX.
- реализованы CLI-флаги, деградация без потери ASR и speaker Markdown.
- добавлены проверяемый кеш моделей, тесты и документация.

Проверка:
- `pytest` — 283 passed, 1 skipped.
- `pyright` — 0 errors.
- Ruff и `git diff --check` — без ошибок.
- выполнены три контрольных прогона на реальных записях.
2026-08-14 18:55:42 +03:00

232 lines
7.7 KiB
Python

import hashlib
import io
import sys
import tarfile
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from local_transcriber.speaker_diarizer import SpeakerDiarizer, load_speaker_diarizer
from local_transcriber.types import SpeakerInterval
def test_process_returns_sorted_domain_intervals(tmp_path):
audio = tmp_path / "meeting.mp3"
raw_result = MagicMock()
raw_result.sort_by_start_time.return_value = [
SimpleNamespace(start=0.2, end=1.1, speaker=4),
SimpleNamespace(start=1.3, end=2.0, speaker=2),
]
engine = MagicMock()
engine.process.return_value = raw_result
diarizer = SpeakerDiarizer(engine)
samples = np.zeros(16_000, dtype=np.float32)
with patch("faster_whisper.decode_audio", return_value=samples) as decode:
run = diarizer.process(audio)
assert run.intervals == [
SpeakerInterval(start=0.2, end=1.1, cluster=4),
SpeakerInterval(start=1.3, end=2.0, cluster=2),
]
decode.assert_called_once_with(str(audio), sampling_rate=16_000)
engine.process.assert_called_once_with(samples)
def test_process_reports_engine_progress(tmp_path):
raw_result = MagicMock()
raw_result.sort_by_start_time.return_value = []
engine = MagicMock()
def process(samples, callback):
assert callback(2, 4) == 0
return raw_result
engine.process.side_effect = process
statuses = []
with patch(
"faster_whisper.decode_audio",
return_value=np.zeros(16_000, dtype=np.float32),
):
SpeakerDiarizer(engine).process(
tmp_path / "meeting.mp3",
on_status=statuses.append,
)
assert "Определяю говорящих... 2 / 4" in statuses
def test_load_speaker_diarizer_uses_verified_cache_and_calibrated_config(
tmp_path, monkeypatch
):
segmentation = tmp_path / "pyannote-segmentation-3.0.onnx"
embedding = tmp_path / "wespeaker_en_voxceleb_resnet34_LM.onnx"
segmentation.write_bytes(b"segmentation")
embedding.write_bytes(b"embedding")
monkeypatch.setattr(
"local_transcriber.speaker_diarizer._SEGMENTATION_SHA256",
hashlib.sha256(segmentation.read_bytes()).hexdigest(),
)
monkeypatch.setattr(
"local_transcriber.speaker_diarizer._EMBEDDING_SHA256",
hashlib.sha256(embedding.read_bytes()).hexdigest(),
)
captured = {}
def config_factory(**kwargs):
config = SimpleNamespace(**kwargs, validate=lambda: True)
captured["config"] = config
return config
engine = SimpleNamespace(sample_rate=16_000)
sherpa = SimpleNamespace(
OfflineSpeakerSegmentationPyannoteModelConfig=lambda **kwargs: SimpleNamespace(
**kwargs
),
OfflineSpeakerSegmentationModelConfig=lambda **kwargs: SimpleNamespace(
**kwargs
),
SpeakerEmbeddingExtractorConfig=lambda **kwargs: SimpleNamespace(**kwargs),
FastClusteringConfig=lambda **kwargs: SimpleNamespace(**kwargs),
OfflineSpeakerDiarizationConfig=config_factory,
OfflineSpeakerDiarization=lambda config: engine,
)
with (
patch("huggingface_hub.cached_assets_path", return_value=tmp_path),
patch.dict(sys.modules, {"sherpa_onnx": sherpa}),
patch("httpx.stream", side_effect=AssertionError("network is not expected")),
):
diarizer = load_speaker_diarizer(speakers=None, threads=0)
config = captured["config"]
assert config.clustering.num_clusters == -1
assert config.clustering.threshold == 0.89
assert config.min_duration_on == 0.3
assert config.min_duration_off == 0.5
assert not hasattr(config.segmentation, "num_threads")
assert not hasattr(config.embedding, "num_threads")
assert isinstance(diarizer, SpeakerDiarizer)
def test_load_speaker_diarizer_downloads_and_verifies_missing_models(
tmp_path, monkeypatch
):
segmentation_bytes = b"downloaded segmentation"
embedding_bytes = b"downloaded embedding"
archive_buffer = io.BytesIO()
with tarfile.open(fileobj=archive_buffer, mode="w:bz2") as archive:
member = tarfile.TarInfo("sherpa-onnx-pyannote-segmentation-3-0/model.onnx")
member.size = len(segmentation_bytes)
archive.addfile(member, io.BytesIO(segmentation_bytes))
monkeypatch.setattr(
"local_transcriber.speaker_diarizer._SEGMENTATION_SHA256",
hashlib.sha256(segmentation_bytes).hexdigest(),
)
monkeypatch.setattr(
"local_transcriber.speaker_diarizer._EMBEDDING_SHA256",
hashlib.sha256(embedding_bytes).hexdigest(),
)
class FakeResponse:
def __init__(self, content):
self.content = content
def __enter__(self):
return self
def __exit__(self, *args):
return False
def raise_for_status(self):
return None
def iter_bytes(self):
yield self.content
requested_urls = []
def fake_stream(method, url, **kwargs):
requested_urls.append(url)
content = (
archive_buffer.getvalue() if "segmentation" in url else embedding_bytes
)
return FakeResponse(content)
config = SimpleNamespace(validate=lambda: True)
engine = SimpleNamespace(sample_rate=16_000)
sherpa = SimpleNamespace(
OfflineSpeakerSegmentationPyannoteModelConfig=lambda **kwargs: SimpleNamespace(
**kwargs
),
OfflineSpeakerSegmentationModelConfig=lambda **kwargs: SimpleNamespace(
**kwargs
),
SpeakerEmbeddingExtractorConfig=lambda **kwargs: SimpleNamespace(**kwargs),
FastClusteringConfig=lambda **kwargs: SimpleNamespace(**kwargs),
OfflineSpeakerDiarizationConfig=lambda **kwargs: config,
OfflineSpeakerDiarization=lambda actual_config: engine,
)
with (
patch("huggingface_hub.cached_assets_path", return_value=tmp_path),
patch.dict(sys.modules, {"sherpa_onnx": sherpa}),
patch("httpx.stream", side_effect=fake_stream),
):
load_speaker_diarizer(speakers=2, threads=4)
assert (
tmp_path / "pyannote-segmentation-3.0.onnx"
).read_bytes() == segmentation_bytes
assert (
tmp_path / "wespeaker_en_voxceleb_resnet34_LM.onnx"
).read_bytes() == embedding_bytes
assert len(requested_urls) == 2
assert list(tmp_path.glob("*.tmp")) == []
def test_load_speaker_diarizer_keeps_corrupt_cache_when_download_is_invalid(
tmp_path, monkeypatch
):
segmentation = tmp_path / "pyannote-segmentation-3.0.onnx"
segmentation.write_bytes(b"existing corrupt model")
monkeypatch.setattr(
"local_transcriber.speaker_diarizer._SEGMENTATION_SHA256",
hashlib.sha256(b"expected model").hexdigest(),
)
archive_buffer = io.BytesIO()
with tarfile.open(fileobj=archive_buffer, mode="w:bz2") as archive:
payload = b"wrong downloaded model"
member = tarfile.TarInfo("sherpa-onnx-pyannote-segmentation-3-0/model.onnx")
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def raise_for_status(self):
return None
def iter_bytes(self):
yield archive_buffer.getvalue()
with (
patch("huggingface_hub.cached_assets_path", return_value=tmp_path),
patch("httpx.stream", return_value=FakeResponse()),
pytest.raises(RuntimeError, match="Контрольная сумма"),
):
load_speaker_diarizer(speakers=None)
assert segmentation.read_bytes() == b"existing corrupt model"
assert list(tmp_path.glob("*.tmp")) == []