Диаризация занимала 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>
126 lines
5.3 KiB
Python
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))
|