Files
talkscore-asr/tests/test_gaps.py
T
Vladimir BryzgalovandClaude Opus 5 5d576dd3c4 Режим на детекторе речи вместо диаризации
Диаризация занимала 79 % времени, а её метку мы не используем: на записи
с одним микрофоном голоса не расходятся, роли расставляет LLM по смыслу.
Silero VAD решает единственную нужную задачу и делает это вчетверо быстрее.

Замер на 205 минутах: x16 -> x60, слов на 1,8 % меньше. Семь записей
из восьми в пределах +-4 %, на одной теряется 19 %.

Отдельно найдено: max_speech_duration у Silero по умолчанию 20 с, и на
таких кусках распознавание теряет текст. Снижение до 6 с даёт 6-8 п.п.
полноты бесплатно. Диаризация сохранена настройкой diarize.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 13:36:01 +05:00

126 lines
5.3 KiB
Python

"""Второй проход по промежуткам: без него часть фраз пропадает молча."""
import numpy as np
from app.pipeline import GAP_MIN_SEC, MIN_RECOVERED_CHARS, Pipeline, gap_ranges
def turn(start, end, text="реплика"):
return {"speaker": 1, "start": start, "end": end, "text": text, "acoustics": {}}
class TestGapRanges:
def test_finds_gap_between_turns(self):
assert list(gap_ranges([turn(0, 5), turn(20, 25)], 30)) == [(5, 20), (25, 30)]
def test_ignores_short_pauses(self):
"""Пауза в пару секунд - обычное дело в разговоре."""
assert list(gap_ranges([turn(0, 5), turn(6, 25)], 25)) == []
def test_finds_gap_before_first_turn(self):
assert (0.0, 10) in list(gap_ranges([turn(10, 20)], 20))
def test_finds_gap_after_last_turn(self):
assert (20, 40) in list(gap_ranges([turn(10, 20)], 40))
def test_no_turns_gives_whole_recording(self):
assert list(gap_ranges([], 60)) == [(0.0, 60)]
def test_overlapping_turns_do_not_create_negative_gap(self):
"""Реплики могут перекрываться, и конец предыдущей бывает позже начала следующей."""
assert list(gap_ranges([turn(0, 30), turn(10, 20)], 25)) == []
def test_boundary_is_inclusive(self):
assert len(list(gap_ranges([turn(0, 5), turn(5 + GAP_MIN_SEC, 20)], 20))) == 1
class FakePipeline(Pipeline):
"""Подменяет распознавание: модели в этих проверках не нужны."""
def __init__(self, answers):
self.answers = answers
self._replacements = {}
def _recognize_safely(self, audio, depth=0):
return self.answers.pop(0) if self.answers else ""
class TestRecovery:
@staticmethod
def samples(seconds=60):
return np.zeros(seconds * 16000, dtype=np.float32)
def test_recovered_turn_has_unknown_speaker(self):
"""Диаризация этих участков не видела, поэтому кто говорил - неизвестно."""
pipe = FakePipeline(["Вы приносите справку, я назначаю инструктора."])
out = pipe._recover_gaps(self.samples(), [turn(0, 5), turn(20, 25)], 25)
assert out[0]["speaker"] == 0
assert out[0]["recovered"] is True
def test_short_result_is_dropped_as_noise(self):
"""Шелест бумаг звучит почти как речь и даёт короткие обрывки."""
pipe = FakePipeline(["ага"])
assert pipe._recover_gaps(self.samples(), [turn(0, 5), turn(20, 25)], 25) == []
def test_empty_result_is_dropped(self):
pipe = FakePipeline([""])
assert pipe._recover_gaps(self.samples(), [turn(0, 5), turn(20, 25)], 25) == []
def test_threshold_is_on_characters(self):
text = "x" * MIN_RECOVERED_CHARS
pipe = FakePipeline([text])
assert len(pipe._recover_gaps(self.samples(), [turn(0, 5), turn(20, 25)], 25)) == 1
def test_time_span_matches_the_gap(self):
pipe = FakePipeline(["Вы приносите справку, я назначаю инструктора."])
out = pipe._recover_gaps(self.samples(), [turn(0, 5), turn(20, 25)], 25)
assert (out[0]["start"], out[0]["end"]) == (5, 20)
class TestSwitch:
"""Второй проход подхватывает и радио на фоне - должна быть возможность выключить."""
def test_setting_defaults_to_on(self, tmp_path):
from app.config import load_settings
config = tmp_path / "config.toml"
config.write_text("[processing]\nthreads=1\n", encoding="utf-8")
assert load_settings(config).recover_gaps is True
def test_setting_can_be_turned_off(self, tmp_path):
from app.config import load_settings
config = tmp_path / "config.toml"
config.write_text("[processing]\nrecover_gaps=false\n", encoding="utf-8")
assert load_settings(config).recover_gaps is False
class TestDiarizeSwitch:
"""Диаризация съедает 79 % времени, а её метку мы не используем."""
def test_defaults_to_off(self, tmp_path):
from app.config import load_settings
config = tmp_path / "config.toml"
config.write_text("[processing]\nthreads=1\n", encoding="utf-8")
assert load_settings(config).diarize is False
def test_can_be_turned_on(self, tmp_path):
from app.config import load_settings
config = tmp_path / "config.toml"
config.write_text("[processing]\ndiarize=true\n", encoding="utf-8")
assert load_settings(config).diarize is True
def test_missing_vad_model_is_reported_clearly(self, tmp_path):
"""Отсутствие модели должно называться, а не падать где-то в недрах."""
import numpy as np
import pytest
from app.pipeline import ModelsMissing, Pipeline
pipe = Pipeline.__new__(Pipeline)
pipe.models_dir = tmp_path
pipe.threads = 1
with pytest.raises(ModelsMissing, match="детектора речи"):
pipe._vad_segments(np.zeros(16000, dtype=np.float32))