Files
local-transcriber/tests/test_experimental_diarization.py
T
Dmitry Dementiev bd0d7e6736 feat(diarization): подготовлен стенд Intel-эксперимента
Зачем:
- нужен воспроизводимый Intel-этап перед проверкой кандидатов на Ryzen
- sherpa-onnx 1.13.6 открывает настройку шага окна

Что:
- добавлены строгий manifest v3, планировщик A/B и проверяемая приватная капсула
- реализованы экспериментальные NME, модели эмбеддингов и режимы worker/session
- версия sherpa-onnx поднята до 1.13.6, зависимости и тесты закреплены

Проверка:
- uv lock --check
- uv run pytest -q
- uv run ruff check для изменённых Python-файлов
- uv build с проверкой состава wheel
2026-08-31 15:12:33 +03:00

678 lines
21 KiB
Python

import io
import sys
from contextlib import contextmanager
from types import SimpleNamespace
import numpy as np
import pytest
from local_transcriber import experimental_diarization as runner
def _run_shared_cell(monkeypatch, tmp_path, *, counters=None, drop_output_text=False):
events = []
active_interval = {"name": None}
embeddings = np.asarray([[1.0, 0.0], [0.0, 1.0]], dtype=np.float32)
work_counters = (
{
"embedding_jobs": 2,
"embedding_run_calls": 2,
"embedding_rows_dropped": 0,
}
if counters is None
else counters
)
class Diarization:
def sort_by_start_time(self):
return [
SimpleNamespace(speaker=0, start=0.0, end=0.5),
SimpleNamespace(speaker=1, start=0.5, end=1.0),
]
class Engine:
sample_rate = runner.SAMPLE_RATE
def __init__(self, config):
self.config = config
def prepare(self, samples, workers):
events.append((active_interval["name"], f"prepare:{workers}"))
return SimpleNamespace(
is_terminal=False,
embeddings=embeddings.copy(),
row_mapping=((0, 0, [(0, 8_000)]), (1, 0, [(8_000, 16_000)])),
counters=dict(work_counters),
)
def finalize(self, prepared, labels):
events.append((active_interval["name"], "finalize"))
return Diarization()
class Clustering:
def __init__(self, config):
self.config = config
def __call__(self, values):
events.append((active_interval["name"], "cluster"))
return np.asarray([0, 1], dtype=np.int32)
fake_sherpa = SimpleNamespace(
OfflineSpeakerDiarization=Engine,
FastClusteringConfig=lambda **values: SimpleNamespace(**values),
FastClustering=Clustering,
)
monkeypatch.setitem(sys.modules, "sherpa_onnx", fake_sherpa)
monkeypatch.setattr(
runner,
"make_config",
lambda cell, artifacts, provider_config_path: SimpleNamespace(
validate=lambda: True
),
)
monkeypatch.setattr(
runner,
"_decode_clip",
lambda recording: np.zeros(runner.SAMPLE_RATE, dtype=np.float32),
)
def estimate(values):
events.append((active_interval["name"], "counter"))
return {"status": "complete", "num_clusters": 2, "p": 3}
monkeypatch.setattr(runner, "estimate_nme", estimate)
measurement_names = iter(("published", "validation"))
def measure(operation, process, interval_seconds):
name = next(measurement_names)
active_interval["name"] = name
try:
result = operation()
finally:
active_interval["name"] = None
suffix = 0 if name == "published" else 1
return result, {
"wall_seconds": 10.0 + suffix,
"user_cpu_seconds": 7.0 + suffix,
"system_cpu_seconds": 1.0,
"total_cpu_seconds": 8.0 + suffix,
"average_cpu_cores": 0.8,
"peak_rss_bytes": 100 + suffix,
}
monkeypatch.setattr(runner, "measure_call", measure)
@contextmanager
def native_log():
yield io.BytesIO()
monkeypatch.setattr(runner, "capture_native_stderr", native_log)
monkeypatch.setattr(
runner,
"parse_prepare_timings",
lambda log: {"segmentation_seconds": 0.2, "embedding_seconds": 0.1},
)
if drop_output_text:
original_builder = runner.build_speaker_transcript
def build_without_last_word(words, intervals, duration):
transcript = original_builder(words, intervals, duration)
transcript.turns = transcript.turns[:-1]
return transcript
monkeypatch.setattr(
runner, "build_speaker_transcript", build_without_last_word
)
words_path = tmp_path / "words.json"
words_path.write_text(
'[{"start": 0.1, "end": 0.4, "text": "Привет"}, '
'{"start": 0.6, "end": 0.9, "text": "мир"}]',
encoding="utf-8",
)
cell = {
"name": "E1",
"cell_id": "shared-cell",
"counter": "nme",
"window_shift_ratio": 0.1,
"segmentation_artifact_id": "segmentation",
"embedding_artifact_id": "embedding",
"recording_ids": ["recording"],
"clustering": {"threshold": 0.89, "num_clusters": None},
"inference": {
"mode": "shared-session",
"outer_workers": 2,
"session_count": 1,
"intra_op_threads": 2,
"inter_op_threads": 1,
"batch_size": 1,
},
}
request = {
"manifest": {
"rss_sample_interval_ms": 10,
"artifacts": [],
"recordings": [
{
"id": "recording",
"path": str(tmp_path / "recording.wav"),
"start": 0.0,
"duration": 1.0,
"expected_speakers": 2,
"asr_words": {"path": str(words_path)},
"reference": None,
}
],
},
"cell": cell,
"work_dir": str(tmp_path),
}
return runner.run_cell(request), events
def test_run_cell_shared_session_separates_measurement_from_validation(
monkeypatch, tmp_path
):
result, events = _run_shared_cell(monkeypatch, tmp_path)
recording = result["recordings"][0]
assert result["aggregate"]["quality_passed"] is True
assert recording["text_equal"] is True
assert recording["prepared"]["inference"] == {
"mode": "shared-session",
"rows_equal_to_sequential": True,
"finite_embeddings": True,
"labels_equal_to_sequential": True,
"final_equal_to_sequential": True,
"max_abs_drift": 0.0,
"min_cosine": 1.0,
}
assert recording["metrics"]["wall_seconds"] == 10.0
assert recording["metrics"]["peak_rss_bytes"] == 100
assert recording["metrics"]["validation_wall_seconds"] == 11.0
assert recording["metrics"]["validation_peak_rss_bytes"] == 101
assert events == [
("published", "prepare:2"),
("published", "counter"),
("published", "cluster"),
("published", "finalize"),
("validation", "counter"),
("validation", "cluster"),
("validation", "prepare:1"),
("validation", "counter"),
("validation", "cluster"),
("validation", "finalize"),
]
def test_run_cell_rejects_changed_output_text(monkeypatch, tmp_path):
result, _ = _run_shared_cell(
monkeypatch, tmp_path, drop_output_text=True
)
assert result["recordings"][0]["text_equal"] is False
assert result["aggregate"]["quality_passed"] is False
def test_run_cell_rejects_missing_prepared_work_counters(monkeypatch, tmp_path):
result, _ = _run_shared_cell(monkeypatch, tmp_path, counters={})
assert result["recordings"][0]["prepared"]["validation_passed"] is False
assert result["aggregate"]["quality_passed"] is False
def test_segmentation_window_count_includes_last_padded_window():
assert runner.segmentation_window_count(160_000, 16_000) == 1
assert runner.segmentation_window_count(160_001, 16_000) == 2
assert runner.segmentation_window_count(176_000, 16_000) == 2
def test_prepare_timing_parser_accepts_exactly_two_stages():
log = (
"OfflineSpeakerDiarization: segmentation 1.250 s\n"
"OfflineSpeakerDiarization: embedding 2.500 s"
)
assert runner.parse_prepare_timings(log) == {
"segmentation_seconds": 1.25,
"embedding_seconds": 2.5,
}
with pytest.raises(ValueError, match="обе стадии"):
runner.parse_prepare_timings("OfflineSpeakerDiarization: embedding 2.5 s")
def test_known_counter_clusters_independent_embedding_copies():
original = np.asarray([[1.0, 0.0], [0.9, 0.1], [0.0, 1.0]], dtype=np.float32)
prepared = SimpleNamespace(embeddings=original, is_terminal=False)
class FakeClustering:
calls = 0
def __init__(self, config):
self.config = config
def __call__(self, features):
FakeClustering.calls += 1
features[:] = -100
return list(range(self.config.num_clusters)) + [0]
fake_sherpa = SimpleNamespace(
FastClusteringConfig=lambda **values: SimpleNamespace(**values),
FastClustering=FakeClustering,
)
cell = {
"counter": "known",
"clustering": {"threshold": 0.89},
}
labels, counter, _ = runner._cluster_prepared(
fake_sherpa, prepared, cell, expected_speakers=2
)
assert labels.tolist() == [0, 1, 0]
assert counter["num_clusters"] == 2
assert FakeClustering.calls == 1
validated = runner._validate_cluster_repeatability(
fake_sherpa,
prepared,
cell,
expected_speakers=2,
labels=labels,
counter_result=counter,
)
assert validated["repeatable_labels"] is True
assert FakeClustering.calls == 2
assert np.array_equal(prepared.embeddings, original)
def _special_cell(mode, *, batch_size=1, sessions=1, outer=1, intra=4):
return {
"counter": "nme" if mode == "titanet-batch" else "known",
"clustering": {"threshold": 0.89},
"embedding_artifact_id": "embedding",
"inference": {
"mode": mode,
"outer_workers": outer,
"session_count": sessions,
"intra_op_threads": intra,
"inter_op_threads": 1,
"batch_size": batch_size,
},
}
def test_special_inference_enforces_exact_thread_budgets():
runner._validate_special_inference(
_special_cell("separate-session", sessions=4, outer=4, intra=1), 4
)
runner._validate_special_inference(
_special_cell("titanet-batch", batch_size=4), 4
)
with pytest.raises(ValueError, match="P worker"):
runner._validate_special_inference(
_special_cell("separate-session", sessions=2, outer=4, intra=1), 4
)
with pytest.raises(ValueError, match="бюджет физических ядер"):
runner._validate_special_inference(
_special_cell("separate-session", sessions=4, outer=4, intra=2), 4
)
with pytest.raises(ValueError, match="batch 1 или 4"):
runner._validate_special_inference(
_special_cell("titanet-batch", batch_size=2), 4
)
def test_separate_sessions_restore_row_order_without_loss():
class Stream:
def __init__(self):
self.samples = []
def accept_waveform(self, sample_rate, samples):
assert sample_rate == runner.SAMPLE_RATE
self.samples.extend(samples)
def input_finished(self):
pass
class Extractor:
def create_stream(self):
return Stream()
def is_ready(self, stream):
return bool(stream.samples)
def compute(self, stream):
return [stream.samples[0], len(stream.samples)]
samples = np.arange(30, dtype=np.float32)
mapping = [
(0, 0, [(9, 12)]),
(0, 1, [(1, 3)]),
(1, 0, [(20, 24)]),
(1, 1, [(5, 6)]),
(2, 0, [(14, 19)]),
]
embeddings = runner._compute_separate_embeddings(
[Extractor(), Extractor()], mapping, samples, outer_workers=2
)
assert embeddings.tolist() == [
[9.0, 3.0],
[1.0, 2.0],
[20.0, 4.0],
[5.0, 1.0],
[14.0, 5.0],
]
assert np.isfinite(embeddings).all()
def test_separate_runner_measures_validation_and_embedding_rss_separately(
monkeypatch,
):
embeddings = np.asarray([[1.0, 0.0], [0.0, 1.0]], dtype=np.float32)
prepared = SimpleNamespace(
is_terminal=False,
embeddings=embeddings.copy(),
row_mapping=((0, 0, [(0, 5)]), (1, 0, [(5, 10)])),
counters={
"embedding_jobs": 2,
"embedding_run_calls": 2,
"embedding_rows_dropped": 0,
},
)
events = []
active_interval = {"name": None}
class Clustering:
def __init__(self, config):
self.config = config
def __call__(self, values):
events.append((active_interval["name"], "cluster"))
return np.asarray([0, 1], dtype=np.int32)
fake_sherpa = SimpleNamespace(
FastClusteringConfig=lambda **values: SimpleNamespace(**values),
FastClustering=Clustering,
)
monkeypatch.setitem(sys.modules, "sherpa_onnx", fake_sherpa)
def estimate(values):
events.append((active_interval["name"], "counter"))
return {"status": "complete", "num_clusters": 2, "p": 3}
monkeypatch.setattr(runner, "estimate_nme", estimate)
monkeypatch.setattr(
runner,
"_compute_separate_embeddings",
lambda extractors, mapping, samples, outer_workers: embeddings.copy(),
)
measurement_names = iter(("published", "embedding", "validation"))
def measure(operation, process, interval_seconds):
name = next(measurement_names)
previous = active_interval["name"]
active_interval["name"] = name
try:
result = operation()
finally:
active_interval["name"] = previous
values = {
"published": (10.0, 100),
"embedding": (3.0, 50),
"validation": (4.0, 200),
}
wall, peak = values[name]
return result, {
"wall_seconds": wall,
"user_cpu_seconds": wall / 2,
"system_cpu_seconds": 1.0,
"total_cpu_seconds": wall / 2 + 1.0,
"average_cpu_cores": 0.6,
"peak_rss_bytes": peak,
}
monkeypatch.setattr(runner, "measure_call", measure)
@contextmanager
def native_log():
yield io.BytesIO()
monkeypatch.setattr(runner, "capture_native_stderr", native_log)
monkeypatch.setattr(
runner,
"parse_prepare_timings",
lambda log: {"segmentation_seconds": 0.2, "embedding_seconds": 0.1},
)
class Diarization:
def sort_by_start_time(self):
return [
SimpleNamespace(speaker=0, start=0.0, end=0.5),
SimpleNamespace(speaker=1, start=0.5, end=1.0),
]
class Engine:
def prepare(self, samples, workers):
events.append((active_interval["name"], "prepare"))
return prepared
def finalize(self, prepared_value, labels):
events.append((active_interval["name"], "finalize"))
return Diarization()
cell = _special_cell("separate-session", sessions=2, outer=2, intra=1)
cell["counter"] = "nme"
_, metrics, _, data = runner._run_separate_session_recording(
Engine(),
[object(), object()],
np.zeros(10, dtype=np.float32),
cell,
{"expected_speakers": 2},
0.01,
)
assert metrics["peak_rss_bytes"] == 100
assert metrics["embedding_peak_rss_bytes"] == 50
assert metrics["validation_peak_rss_bytes"] == 200
assert metrics["validation_wall_seconds"] == 4.0
assert metrics["instrumented_pipeline_wall_seconds"] == 10.0
assert "measured_validation_wall_seconds" not in metrics
assert data["validation_passed"] is True
assert events == [
("published", "prepare"),
("published", "counter"),
("published", "cluster"),
("published", "finalize"),
("validation", "counter"),
("validation", "cluster"),
("validation", "counter"),
("validation", "cluster"),
("validation", "finalize"),
]
def test_titanet_batches_keep_variable_length_row_order():
features = [
np.full((3, 80), 10.0, dtype=np.float32),
np.full((5, 80), 20.0, dtype=np.float32),
np.full((2, 80), 30.0, dtype=np.float32),
np.full((4, 80), 40.0, dtype=np.float32),
np.full((6, 80), 50.0, dtype=np.float32),
]
class Session:
def __init__(self):
self.length_batches = []
def run(self, output_names, inputs):
assert output_names == ["embs"]
values = inputs["audio_signal"]
lengths = inputs["length"]
self.length_batches.append(lengths.tolist())
assert values.shape == (len(lengths), 80, max(lengths))
return [
np.asarray(
[[values[index, 0, 0], length] for index, length in enumerate(lengths)],
dtype=np.float32,
)
]
session = Session()
batch4 = runner._infer_titanet_batches(session, features, batch_size=4)
batch1 = runner._infer_titanet_batches(Session(), features, batch_size=1)
assert session.length_batches == [[3, 5, 2, 4], [6]]
assert batch4.tolist() == batch1.tolist() == [
[10.0, 3.0],
[20.0, 5.0],
[30.0, 2.0],
[40.0, 4.0],
[50.0, 6.0],
]
def test_titanet_runner_compares_batch_1_and_4_before_finalize(monkeypatch):
embeddings = np.asarray(
[[1.0, 0.0], [0.9, 0.1], [0.0, 1.0]], dtype=np.float32
)
prepared = SimpleNamespace(
is_terminal=False,
embeddings=embeddings.copy(),
row_mapping=((0, 0, [(0, 4)]), (0, 1, [(4, 8)]), (1, 0, [(8, 12)])),
counters={"embedding_jobs": 3, "embedding_run_calls": 3, "embedding_rows_dropped": 0},
)
events = []
active_interval = {"name": None}
class Clustering:
def __init__(self, config):
self.config = config
def __call__(self, values):
events.append((active_interval["name"], "cluster"))
return np.asarray([0, 0, 1], dtype=np.int32)
fake_sherpa = SimpleNamespace(
FastClusteringConfig=lambda **values: SimpleNamespace(**values),
FastClustering=Clustering,
)
monkeypatch.setitem(sys.modules, "sherpa_onnx", fake_sherpa)
monkeypatch.setattr(
runner,
"estimate_nme",
lambda values: (
events.append((active_interval["name"], "counter"))
or {"status": "complete", "num_clusters": 2, "p": 2}
),
)
monkeypatch.setattr(
runner,
"_prepare_titanet_features",
lambda mapping, samples: [
np.ones((3, 80), dtype=np.float32),
np.ones((4, 80), dtype=np.float32),
np.ones((5, 80), dtype=np.float32),
],
)
seen_batches = []
def infer(session, features, batch_size):
seen_batches.append((active_interval["name"], batch_size))
return embeddings.copy()
monkeypatch.setattr(runner, "_infer_titanet_batches", infer)
measurement_names = iter(
("published", "published-embedding", "validation", "validation-embedding")
)
def measure(operation, process, interval_seconds):
name = next(measurement_names)
previous = active_interval["name"]
active_interval["name"] = name
try:
result = operation()
finally:
active_interval["name"] = previous
return result, {
"wall_seconds": 1.0,
"user_cpu_seconds": 0.7,
"system_cpu_seconds": 0.1,
"total_cpu_seconds": 0.8,
"average_cpu_cores": 0.8,
"peak_rss_bytes": 100,
}
monkeypatch.setattr(runner, "measure_call", measure)
@contextmanager
def native_log():
yield io.BytesIO()
monkeypatch.setattr(runner, "capture_native_stderr", native_log)
monkeypatch.setattr(
runner,
"parse_prepare_timings",
lambda log: {"segmentation_seconds": 0.2, "embedding_seconds": 0.4},
)
class Diarization:
def __init__(self, labels):
self.labels = labels
def sort_by_start_time(self):
return [
SimpleNamespace(speaker=int(label), start=index, end=index + 1)
for index, label in enumerate(self.labels)
]
class Engine:
def prepare(self, samples, workers):
assert workers == 1
events.append((active_interval["name"], "prepare"))
return prepared
def finalize(self, prepared_value, labels):
assert prepared_value is prepared
events.append((active_interval["name"], "finalize"))
return Diarization(labels)
cell = _special_cell("titanet-batch", batch_size=4)
result, metrics, _, data = runner._run_titanet_batch_recording(
Engine(),
object(),
np.arange(20, dtype=np.float32),
cell,
{"expected_speakers": 2},
0.01,
)
assert seen_batches == [("published-embedding", 4), ("validation-embedding", 1)]
assert data["validation_passed"] is True
assert data["inference"]["n_equal"] is True
assert data["inference"]["labels_equal"] is True
assert data["inference"]["final_equal"] is True
assert data["counters"]["embedding_run_calls"] == 1
assert metrics["measurement_kind"] == "composed-estimate"
assert metrics["wall_seconds_is_composed"] is True
assert metrics["instrumented_pipeline_wall_seconds"] == 1.0
assert metrics["embedding_peak_rss_bytes"] == 100
assert metrics["validation_wall_seconds"] == 1.0
assert metrics["validation_peak_rss_bytes"] == 100
assert events == [
("published", "prepare"),
("published", "counter"),
("published", "cluster"),
("published", "finalize"),
("validation", "counter"),
("validation", "cluster"),
("validation", "counter"),
("validation", "cluster"),
("validation", "finalize"),
]
assert [item.speaker for item in result.sort_by_start_time()] == [0, 0, 1]