From 56cf7e5b0e11472872f5cfbc1390631c80f6a908 Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov Date: Sun, 16 Aug 2026 22:07:02 +0500 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=82=D0=BE=D1=80=D0=BE=D0=B9=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=85=D0=BE=D0=B4=20=D0=BF=D0=BE=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=BC=D0=B5=D0=B6=D1=83=D1=82=D0=BA=D0=B0=D0=BC,=20?= =?UTF-8?q?=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=8B=D0=B5=20=D0=B4=D0=B5=D1=82?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=BE=D1=80=20=D1=81=D1=87=D1=91=D0=BB=20?= =?UTF-8?q?=D1=82=D0=B8=D1=88=D0=B8=D0=BD=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Диаризация задавала не только «кто говорит», но и границы того, что вообще попадает в распознавание: фразы вне её сегментов пропадали молча. Второй проход их возвращает с пометкой recovered=true и speaker=0. Замер на восьми записях: 131 слово из 17 004 (0,8 %), 3-19 с на запись. Возвращается и настоящий диалог (вопрос о цене, даты, документы), и радио на фоне, поэтому проход отключается настройкой recover_gaps. Скрипты render_transcript.py и check_gaps.py - для сверки расшифровки с записью и поиска потерянных фраз. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 + app/config.py | 7 +++ app/main.py | 5 +- app/pipeline.py | 69 +++++++++++++++++++++- app/worker.py | 5 +- build/check_gaps.py | 115 +++++++++++++++++++++++++++++++++++++ build/render_transcript.py | 110 +++++++++++++++++++++++++++++++++++ tests/test_gaps.py | 94 ++++++++++++++++++++++++++++++ 8 files changed, 401 insertions(+), 5 deletions(-) create mode 100644 build/check_gaps.py create mode 100644 build/render_transcript.py create mode 100644 tests/test_gaps.py diff --git a/.gitignore b/.gitignore index 92ae756..baba696 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ models/ data/ config.toml +transcripts/ diff --git a/app/config.py b/app/config.py index edbb19e..09b220f 100644 --- a/app/config.py +++ b/app/config.py @@ -59,6 +59,11 @@ speakers = 2 # это решающая настройка: без неё тихий участник сливается с громким и # разделение по говорящим разваливается. На распознавание не влияет. normalize = true +# Распознавать промежутки, которые детектор речи не отметил как речь. +# Возвращает потерянные фразы (вопрос о цене, даты, документы), но вместе +# с ними может подхватить радио или телевизор на фоне. Возвращённые реплики +# помечены recovered=true и speaker=0 - их видно и можно отфильтровать. +recover_gaps = true # Максимальный размер загружаемого файла, МБ max_upload_mb = 500 # Сколько часов хранить результаты завершённых задач @@ -101,6 +106,7 @@ class Settings: workers: int = 0 speakers: int = 2 normalize: bool = True + recover_gaps: bool = True max_upload_mb: int = 500 keep_results_hours: float = 72.0 webhook_url: str = "" @@ -187,6 +193,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)), + 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)), webhook_url=str(hook.get("url", "")), diff --git a/app/main.py b/app/main.py index cd239d8..33d41ce 100644 --- a/app/main.py +++ b/app/main.py @@ -75,12 +75,13 @@ 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).result() + settings.normalize, settings.recover_gaps).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) + result = worker.transcribe(wav, num_speakers=speakers, + recover_gaps=settings.recover_gaps) 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 f6384ba..7512a74 100644 --- a/app/pipeline.py +++ b/app/pipeline.py @@ -33,6 +33,12 @@ EMBED_SEC = 8.0 # Ниже этого значения голоса практически неразличимы и разметка по говорящим # случайна. На записях с одним микрофоном в комнате так бывает часто. RELIABLE_SEPARATION = 0.35 +# Промежутки короче этого - обычные паузы в разговоре, туда лезть незачем. +GAP_MIN_SEC = 3.0 +# Короткий обрывок из промежутка почти всегда шум, принятый за слово: +# шелест бумаг и стук клавиш на записи со стола дают почти ту же громкость, +# что и речь. Порог отсекает их, не трогая настоящие фразы. +MIN_RECOVERED_CHARS = 15 ASR_MODEL_NAME = "gigaam-v3-e2e-rnnt" ASR_MODEL_REL = Path("gigaam") SEG_MODEL_REL = Path("sherpa-onnx-pyannote-segmentation-3-0") / "model.onnx" @@ -84,6 +90,17 @@ def read_wav(path: Path) -> np.ndarray: return np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0 +def gap_ranges(turns: list[dict], duration: float): + """Промежутки между репликами, куда распознавание не заглядывало.""" + previous = 0.0 + for turn in turns: + if turn["start"] - previous >= GAP_MIN_SEC: + yield previous, turn["start"] + previous = max(previous, turn["end"]) + if duration - previous >= GAP_MIN_SEC: + yield previous, duration + + class Pipeline: """Диаризация плюс распознавание. Потокобезопасен на уровне одного воркера.""" @@ -219,7 +236,46 @@ class Pipeline: right = self._recognize_safely(audio[middle:], depth + 1) return " ".join(part for part in (left, right) if part) - def transcribe(self, wav_path: Path, num_speakers: int = 2) -> dict: + def _recover_gaps(self, samples: np.ndarray, turns: list[dict], + duration: float) -> list[dict]: + """Распознаёт то, что детектор речи не отметил как речь. + + Диаризация задаёт не только «кто говорит», но и границы того, что + вообще попадает в распознавание. Модель pyannote обучена разделять + говорящих, а не искать речь, и на тихой записи со стола часть фраз + в эти границы не попадает - они пропадают молча. Замер на реальной + записи: 4 промежутка из 35 содержали речь, около 2 % слов. + + Кто произнёс найденное, неизвестно: диаризация этих участков не + видела. Поэтому speaker = 0, а роль потом расставит LLM по смыслу. + """ + out: list[dict] = [] + for start, end in gap_ranges(turns, duration): + pieces = [] + for chunk_start, chunk_stop in chunk_ranges(start, end, MAX_CHUNK_SEC): + audio = samples[int(chunk_start * SAMPLE_RATE):int(chunk_stop * SAMPLE_RATE)] + if len(audio) < SAMPLE_RATE * 0.2: + continue + pieces.append(self._recognize_safely(audio)) + text = " ".join(p.strip() for p in pieces if p and p.strip()) + if len(text) < MIN_RECOVERED_CHARS: + continue + text = normalize_typography(apply_replacements(text, self._replacements)) + audio = samples[int(start * SAMPLE_RATE):int(end * SAMPLE_RATE)] + out.append({ + "speaker": 0, + "start": round(start, 2), + "end": round(end, 2), + "text": text, + "acoustics": segment_acoustics(audio), + # Признак второго прохода: говорящий неизвестен, и потребителю + # стоит знать, что это не результат диаризации. + "recovered": True, + }) + return out + + def transcribe(self, wav_path: Path, num_speakers: int = 2, + recover_gaps: bool = True) -> dict: """Полный проход: диаризация, распознавание реплик, постобработка. Модели грузятся при первой задаче, а не при создании: воркеров несколько, @@ -269,6 +325,14 @@ class Pipeline: }) t_asr = time.time() - t0 + t0 = time.time() + recovered = (self._recover_gaps(samples, turns_out, duration) + if recover_gaps else []) + t_recovery = time.time() - t0 + if recovered: + turns_out = sorted(turns_out + recovered, key=lambda t: t["start"]) + log.info("во втором проходе найдено реплик: %d", len(recovered)) + return { "duration_sec": round(duration, 1), "turns": turns_out, @@ -281,10 +345,13 @@ class Pipeline: "speakers_reliable": 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), + "recovered_sec": round(sum(t["end"] - t["start"] for t in recovered), 1), }, "timing": { "diarization_sec": round(t_diar, 1), "asr_sec": round(t_asr, 1), + "recovery_sec": round(t_recovery, 1), "realtime_factor": round(duration / max(t_diar + t_asr, 0.001), 1), }, } diff --git a/app/worker.py b/app/worker.py index 1eec376..1a116d1 100644 --- a/app/worker.py +++ b/app/worker.py @@ -31,7 +31,7 @@ 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) -> dict: + normalize: bool = True, recover_gaps: bool = True) -> dict: """Переводит файл в WAV и распознаёт. Выполняется в процессе-воркере.""" import tempfile @@ -43,4 +43,5 @@ def run_job(audio_path: str, num_speakers: int, ffmpeg: str, with tempfile.TemporaryDirectory() as tmp: wav = Path(tmp) / "audio.wav" to_wav16k(Path(audio_path), wav, ffmpeg, normalize) - return _pipeline.transcribe(wav, num_speakers=num_speakers) + return _pipeline.transcribe(wav, num_speakers=num_speakers, + recover_gaps=recover_gaps) diff --git a/build/check_gaps.py b/build/check_gaps.py new file mode 100644 index 0000000..6c833de --- /dev/null +++ b/build/check_gaps.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Проверяет, была ли речь в пропусках расшифровки. + +Распознаётся только то, что детектор речи отметил как речь. Пропуск в +расшифровке может означать и настоящую тишину, и потерянную фразу - на глаз +это не различить. Здесь громкость внутри пропуска сравнивается с громкостью +распознанных реплик той же записи: если она сопоставима, речь была, и +детектор её не увидел. + + uv run python build/check_gaps.py результат.json запись.mp3 +""" +import argparse +import json +import subprocess +import sys +import tempfile +import wave +from pathlib import Path + +import numpy as np + +SAMPLE_RATE = 16000 +GAP_MIN_SEC = 3.0 +# Насколько тише распознанной речи должен быть фрагмент, чтобы считать его +# тишиной. 12 дБ - это примерно вчетверо тише по амплитуде. +SILENCE_MARGIN_DB = 12.0 + + +def to_wav(src: Path, dst: Path) -> None: + subprocess.run( + ["ffmpeg", "-nostdin", "-y", "-i", str(src), "-ac", "1", + "-ar", str(SAMPLE_RATE), "-vn", str(dst)], + check=True, capture_output=True) + + +def read_wav(path: Path) -> np.ndarray: + with wave.open(str(path), "rb") as handle: + raw = handle.readframes(handle.getnframes()) + return np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0 + + +def level_db(samples: np.ndarray) -> float: + if len(samples) == 0: + return -120.0 + return 20 * float(np.log10(float(np.sqrt((samples ** 2).mean())) + 1e-9)) + + +def slice_at(samples: np.ndarray, start: float, end: float) -> np.ndarray: + return samples[int(start * SAMPLE_RATE):int(end * SAMPLE_RATE)] + + +def timecode(seconds: float) -> str: + return f"{int(seconds) // 60:02d}:{int(seconds) % 60:02d}" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("result", type=Path) + ap.add_argument("audio", type=Path) + args = ap.parse_args() + + data = json.loads(args.result.read_text(encoding="utf-8")) + turns = data.get("turns", []) + if not turns: + print("в результате нет реплик") + return 1 + + with tempfile.TemporaryDirectory() as tmp: + wav = Path(tmp) / "audio.wav" + to_wav(args.audio, wav) + samples = read_wav(wav) + duration = len(samples) / SAMPLE_RATE + + speech = float(np.median([level_db(slice_at(samples, t["start"], t["end"])) + for t in turns])) + threshold = speech - SILENCE_MARGIN_DB + print(f"Запись: {args.audio.name}, {timecode(duration)}") + print(f"Медианная громкость распознанной речи: {speech:.1f} дБ") + print(f"Порог: тише {threshold:.1f} дБ считаем тишиной\n") + + gaps = [] + previous_end = 0.0 + for turn in turns: + if turn["start"] - previous_end >= GAP_MIN_SEC: + gaps.append((previous_end, turn["start"])) + previous_end = turn["end"] + if duration - previous_end >= GAP_MIN_SEC: + gaps.append((previous_end, duration)) + + suspicious = 0.0 + quiet = 0.0 + print(f"{'пропуск':<18} {'длина':>7} {'громкость':>10} вердикт") + for start, end in gaps: + loud = level_db(slice_at(samples, start, end)) + span = end - start + if loud >= threshold: + suspicious += span + verdict = "ПОХОЖЕ НА РЕЧЬ" + else: + quiet += span + verdict = "тишина" + print(f"{timecode(start)}-{timecode(end):<11} {span:>6.0f}с " + f"{loud:>9.1f}дБ {verdict}") + + total = suspicious + quiet + print(f"\nВсего в пропусках: {total:.0f} с") + print(f" тишина: {quiet:.0f} с ({quiet / total * 100 if total else 0:.0f}%)") + print(f" похоже на речь: {suspicious:.0f} с " + f"({suspicious / total * 100 if total else 0:.0f}%) " + f"- {suspicious / duration * 100:.0f}% всей записи") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/build/render_transcript.py b/build/render_transcript.py new file mode 100644 index 0000000..132eaf2 --- /dev/null +++ b/build/render_transcript.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Читаемая расшифровка из результата сервиса, с отметками пропусков. + +Распознаётся только то, что детектор речи отметил как речь. Если фраза +показалась ему тишиной, до распознавания она не доходит и в расшифровке её +не будет - молча. Поэтому здесь помечаются разрывы между репликами: по ним +видно, куда перемотать запись, чтобы проверить, была ли там речь. + + uv run python build/render_transcript.py результат.json > расшифровка.txt + uv run python build/render_transcript.py папка/ --out папка-текстов/ +""" +import argparse +import json +from pathlib import Path + +# Пауза в разговоре - дело обычное. Помечаем только заметные провалы, +# где успела бы поместиться целая фраза. +GAP_NOTICE_SEC = 3.0 +GAP_ALARM_SEC = 15.0 + + +def timecode(seconds: float) -> str: + return f"{int(seconds) // 60:02d}:{int(seconds) % 60:02d}" + + +def render(data: dict) -> str: + turns = data.get("turns", []) + stats = data.get("stats", {}) + timing = data.get("timing", {}) + duration = data.get("duration_sec", 0.0) + + lines = [ + f"Запись: {data.get('source') or data.get('filename', '?')}", + f"Длительность: {timecode(duration)} ({duration:.0f} с)", + f"Реплик: {len(turns)}", + ] + if stats: + speech = stats.get("speech_sec", 0.0) + share = speech / duration * 100 if duration else 0 + lines.append( + f"Распознано речи: {speech:.0f} с из {duration:.0f} с ({share:.0f}%), " + f"остальное детектор счёл тишиной") + lines.append( + f"Разделение по голосам: {stats.get('separation_quality', 0):.2f} " + f"({'надёжно' if stats.get('speakers_reliable') else 'НЕНАДЁЖНО, роли условны'})") + if timing: + lines.append(f"Обработка: {timing.get('diarization_sec', 0):.0f} с диаризация + " + f"{timing.get('asr_sec', 0):.0f} с распознавание") + lines.append("") + lines.append("Разрывы длиннее 3 с помечены. Это места, где детектор речи не нашёл") + lines.append("речи - именно там стоит проверить запись, если фраза потерялась.") + lines.append("=" * 78) + lines.append("") + + previous_end = 0.0 + lost = 0.0 + for turn in turns: + gap = turn["start"] - previous_end + if gap >= GAP_NOTICE_SEC: + lost += gap + mark = "!!!" if gap >= GAP_ALARM_SEC else "..." + lines.append(f" {mark} пропуск {gap:.0f} с " + f"[{timecode(previous_end)} - {timecode(turn['start'])}]") + loudness = (turn.get("acoustics") or {}).get("loudness_db") + volume = f" {loudness:.0f}дБ" if loudness is not None else "" + lines.append(f"[{timecode(turn['start'])}] Г{turn['speaker']}{volume}: {turn['text']}") + previous_end = turn["end"] + + tail = duration - previous_end + if tail >= GAP_NOTICE_SEC: + lost += tail + lines.append(f" ... пропуск {tail:.0f} с " + f"[{timecode(previous_end)} - конец записи]") + + lines.append("") + lines.append("=" * 78) + lines.append(f"Суммарно в пропусках длиннее {GAP_NOTICE_SEC:.0f} с: {lost:.0f} с " + f"({lost / duration * 100 if duration else 0:.0f}% записи)") + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("source", type=Path, help="файл результата или папка с ними") + ap.add_argument("--out", type=Path, help="куда складывать тексты") + args = ap.parse_args() + + files = sorted(args.source.glob("*.json")) if args.source.is_dir() else [args.source] + files = [f for f in files if f.name != "summary.json"] + if not files: + print("не нашёл файлов результата") + return 1 + + for path in files: + data = json.loads(path.read_text(encoding="utf-8")) + if "turns" not in data: + continue + text = render(data) + if args.out: + args.out.mkdir(parents=True, exist_ok=True) + target = args.out / f"{path.stem}.txt" + target.write_text(text, encoding="utf-8") + print(f"{target}") + else: + print(text) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_gaps.py b/tests/test_gaps.py new file mode 100644 index 0000000..c840854 --- /dev/null +++ b/tests/test_gaps.py @@ -0,0 +1,94 @@ +"""Второй проход по промежуткам: без него часть фраз пропадает молча.""" +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