From 5d576dd3c49965dd230ec2e1c58786e6a91a6d80 Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov Date: Mon, 17 Aug 2026 13:36:01 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B6=D0=B8=D0=BC=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B4=D0=B5=D1=82=D0=B5=D0=BA=D1=82=D0=BE=D1=80=D0=B5=20?= =?UTF-8?q?=D1=80=D0=B5=D1=87=D0=B8=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=20=D0=B4=D0=B8=D0=B0=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Диаризация занимала 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) --- app/config.py | 11 ++++++ app/main.py | 6 ++- app/pipeline.py | 83 ++++++++++++++++++++++++++++++++++----- app/worker.py | 5 ++- build/download_models.py | 6 +++ tests/test_concurrency.py | 4 +- tests/test_gaps.py | 31 +++++++++++++++ 7 files changed, 132 insertions(+), 14 deletions(-) diff --git a/app/config.py b/app/config.py index 09b220f..788a2c0 100644 --- a/app/config.py +++ b/app/config.py @@ -52,6 +52,15 @@ threads = 0 # в очередь вместо параллельной работы. Каждый процесс держит свою копию # моделей, это около 1 ГБ памяти на процесс. workers = 1 +# Разделять ли запись по голосам (pyannote). На записи с одним микрофоном +# на столе голоса не расходятся: метка говорящего оказывается случайной +# в семи случаях из восьми. Роли всё равно расставляет LLM по смыслу текста, +# поэтому по умолчанию работает только детектор речи. +# Замер на 205 минутах реальных записей: без диаризации в 4,3 раза быстрее +# (x16 -> x60), слов на 1,8 % меньше. Семь записей из восьми в пределах +# +-4 %, на одной теряется 19 % - там голоса очень тихие. true - если такие +# записи для вас типичны или нужны метки говорящих. +diarize = false # Ожидаемое число говорящих в записи. 0 = определять автоматически # (на реальных звонках работает плохо, для диалога ставьте 2). speakers = 2 @@ -106,6 +115,7 @@ class Settings: workers: int = 0 speakers: int = 2 normalize: bool = True + diarize: bool = False recover_gaps: bool = True max_upload_mb: int = 500 keep_results_hours: float = 72.0 @@ -193,6 +203,7 @@ def load_settings(config_path: Path | None = None) -> Settings: workers=int(proc.get("workers", 1)), speakers=int(proc.get("speakers", 2)), normalize=bool(proc.get("normalize", True)), + diarize=bool(proc.get("diarize", False)), recover_gaps=bool(proc.get("recover_gaps", True)), max_upload_mb=int(proc.get("max_upload_mb", 500)), keep_results_hours=float(proc.get("keep_results_hours", 72)), diff --git a/app/main.py b/app/main.py index 33d41ce..f78bd0f 100644 --- a/app/main.py +++ b/app/main.py @@ -75,13 +75,15 @@ def _process(job_id: str, worker: Pipeline, pool) -> None: from app.worker import run_job result = pool.submit(run_job, str(upload), speakers, worker.ffmpeg, - settings.normalize, settings.recover_gaps).result() + settings.normalize, settings.recover_gaps, + settings.diarize).result() else: with tempfile.TemporaryDirectory() as tmp: wav = Path(tmp) / "audio.wav" to_wav16k(upload, wav, worker.ffmpeg, settings.normalize) result = worker.transcribe(wav, num_speakers=speakers, - recover_gaps=settings.recover_gaps) + recover_gaps=settings.recover_gaps, + diarize=settings.diarize) result["filename"] = job["filename"] store.mark_done(job_id, result) log.info("задача %s готова: %.1f с аудио, x%s", job_id, diff --git a/app/pipeline.py b/app/pipeline.py index 7512a74..dac1aee 100644 --- a/app/pipeline.py +++ b/app/pipeline.py @@ -43,6 +43,17 @@ ASR_MODEL_NAME = "gigaam-v3-e2e-rnnt" ASR_MODEL_REL = Path("gigaam") SEG_MODEL_REL = Path("sherpa-onnx-pyannote-segmentation-3-0") / "model.onnx" EMB_MODEL_REL = Path("nemo_en_titanet_small.onnx") +VAD_MODEL_REL = Path("silero_vad.onnx") +# Порог детектора речи. Ниже - подхватывает фон, выше - теряет тихие реплики. +VAD_THRESHOLD = 0.5 +# Паузу короче этой внутри речи не считаем разрывом: иначе фраза рвётся +# на каждом вдохе. +VAD_MIN_SILENCE_SEC = 0.5 +VAD_MIN_SPEECH_SEC = 0.25 +# Предельная длина отрезка речи. Решающая настройка: распознавание теряет +# текст на длинных кусках, и умолчание Silero в 20 с даёт заметно меньше +# слов, чем более мелкие отрезки диаризации. +VAD_MAX_SPEECH_SEC = 6.0 class ModelsMissing(RuntimeError): @@ -236,6 +247,54 @@ class Pipeline: right = self._recognize_safely(audio[middle:], depth + 1) return " ".join(part for part in (left, right) if part) + def _vad_segments(self, samples: np.ndarray) -> list[Segment]: + """Границы речи без разделения по говорящим. + + Диаризация занимает около 79 % времени обработки, а её метку мы всё + равно не используем: на записи с одним микрофоном голоса не расходятся, + и роли расставляет LLM по смыслу. Детектор речи решает единственную + нужную здесь задачу - где речь есть - и делает это на порядок быстрее. + + Говорящий у всех отрезков один и тот же (0): это честнее, чем выдавать + номер, за которым ничего не стоит. + """ + import sherpa_onnx + + model = self.models_dir / VAD_MODEL_REL + if not model.is_file(): + raise ModelsMissing(f"нет модели детектора речи: {model}") + + config = sherpa_onnx.VadModelConfig() + config.silero_vad.model = str(model) + config.silero_vad.threshold = VAD_THRESHOLD + config.silero_vad.min_silence_duration = VAD_MIN_SILENCE_SEC + config.silero_vad.min_speech_duration = VAD_MIN_SPEECH_SEC + config.silero_vad.max_speech_duration = VAD_MAX_SPEECH_SEC + config.sample_rate = SAMPLE_RATE + config.num_threads = self.threads + + window = 512 + detector = sherpa_onnx.VoiceActivityDetector(config, buffer_size_in_seconds=60) + out: list[Segment] = [] + for offset in range(0, len(samples), window): + detector.accept_waveform(samples[offset:offset + window]) + while not detector.empty(): + segment = detector.front + start = segment.start / SAMPLE_RATE + out.append(Segment(start=start, + end=start + len(segment.samples) / SAMPLE_RATE, + speaker=0)) + detector.pop() + detector.flush() + while not detector.empty(): + segment = detector.front + start = segment.start / SAMPLE_RATE + out.append(Segment(start=start, + end=start + len(segment.samples) / SAMPLE_RATE, + speaker=0)) + detector.pop() + return out + def _recover_gaps(self, samples: np.ndarray, turns: list[dict], duration: float) -> list[dict]: """Распознаёт то, что детектор речи не отметил как речь. @@ -275,7 +334,7 @@ class Pipeline: return out def transcribe(self, wav_path: Path, num_speakers: int = 2, - recover_gaps: bool = True) -> dict: + recover_gaps: bool = True, diarize: bool = False) -> dict: """Полный проход: диаризация, распознавание реплик, постобработка. Модели грузятся при первой задаче, а не при создании: воркеров несколько, @@ -289,13 +348,18 @@ class Pipeline: duration = len(samples) / SAMPLE_RATE t0 = time.time() - raw = self._diarizer(num_speakers).process(samples).sort_by_start_time() - segments = [Segment(start=s.start, end=s.end, speaker=s.speaker) for s in raw] - try: - quality = self._separation_quality(samples, segments) - except Exception as exc: # noqa: BLE001 - оценка вспомогательная - # Метрика не должна ронять задачу: без неё расшифровка всё равно нужна. - log.warning("не удалось оценить разделение говорящих: %s", exc) + if diarize: + raw = self._diarizer(num_speakers).process(samples).sort_by_start_time() + segments = [Segment(start=s.start, end=s.end, speaker=s.speaker) for s in raw] + try: + quality = self._separation_quality(samples, segments) + except Exception as exc: # noqa: BLE001 - оценка вспомогательная + # Метрика не должна ронять задачу: расшифровка нужна и без неё. + log.warning("не удалось оценить разделение говорящих: %s", exc) + quality = 0.0 + else: + segments = self._vad_segments(samples) + # Разделения не было, и притворяться, что оно удалось, нельзя. quality = 0.0 t_diar = time.time() - t0 @@ -341,8 +405,9 @@ class Pipeline: "sentences": split_sentences(turns_out), "stats": { **speech_stats(segments), + "diarized": diarize, "separation_quality": quality, - "speakers_reliable": quality >= RELIABLE_SEPARATION, + "speakers_reliable": diarize and quality >= RELIABLE_SEPARATION, "silence_sec": round(max(0.0, duration - sum(s.end - s.start for s in segments)), 1), "turns_count": len(turns_out), "recovered_turns": len(recovered), diff --git a/app/worker.py b/app/worker.py index 1a116d1..965ac6c 100644 --- a/app/worker.py +++ b/app/worker.py @@ -31,7 +31,8 @@ def init_worker(models_dir: str, threads: int, replacements_path: str, base_dir: def run_job(audio_path: str, num_speakers: int, ffmpeg: str, - normalize: bool = True, recover_gaps: bool = True) -> dict: + normalize: bool = True, recover_gaps: bool = True, + diarize: bool = False) -> dict: """Переводит файл в WAV и распознаёт. Выполняется в процессе-воркере.""" import tempfile @@ -44,4 +45,4 @@ def run_job(audio_path: str, num_speakers: int, ffmpeg: str, wav = Path(tmp) / "audio.wav" to_wav16k(Path(audio_path), wav, ffmpeg, normalize) return _pipeline.transcribe(wav, num_speakers=num_speakers, - recover_gaps=recover_gaps) + recover_gaps=recover_gaps, diarize=diarize) diff --git a/build/download_models.py b/build/download_models.py index e09b3b6..30fd54e 100644 --- a/build/download_models.py +++ b/build/download_models.py @@ -21,6 +21,11 @@ EMB_URL = ("https://github.com/k2-fsa/sherpa-onnx/releases/download/" "speaker-recongition-models/nemo_en_titanet_small.onnx") +# Детектор речи: основной режим работы, диаризация - опция. +VAD_URL = ("https://github.com/k2-fsa/sherpa-onnx/releases/download/" + "asr-models/silero_vad.onnx") + + def setup_console() -> None: """Windows-консоль по умолчанию не в UTF-8, иначе русский текст рассыпается.""" for stream in (sys.stdout, sys.stderr): @@ -80,6 +85,7 @@ def main() -> int: MODELS.mkdir(parents=True, exist_ok=True) print("Диаризация:") + download(VAD_URL, MODELS / "silero_vad.onnx", "детектор речи") download(EMB_URL, MODELS / "nemo_en_titanet_small.onnx", "голосовые отпечатки") seg_dir = MODELS / "sherpa-onnx-pyannote-segmentation-3-0" diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py index 08fac37..984c1df 100644 --- a/tests/test_concurrency.py +++ b/tests/test_concurrency.py @@ -314,7 +314,9 @@ class TestSeparationNeverBreaksJob: lambda *a: (_ for _ in ()).throw(RuntimeError("модель упала"))) monkeypatch.setattr("app.pipeline.read_wav", lambda _: np.zeros(16000 * 3, dtype=np.float32)) - result = p.transcribe(tmp_path / "any.wav") + # Оценка разделения считается только при диаризации: в обычном режиме + # запись режет детектор речи, и разделять по голосам нечего. + result = p.transcribe(tmp_path / "any.wav", diarize=True) assert result["stats"]["separation_quality"] == 0.0 assert result["turns"] # расшифровка на месте diff --git a/tests/test_gaps.py b/tests/test_gaps.py index c840854..a0e1d3f 100644 --- a/tests/test_gaps.py +++ b/tests/test_gaps.py @@ -92,3 +92,34 @@ class TestSwitch: 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))