Второй проход по промежуткам, которые детектор счёл тишиной

Диаризация задавала не только «кто говорит», но и границы того, что вообще
попадает в распознавание: фразы вне её сегментов пропадали молча. Второй
проход их возвращает с пометкой 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) <noreply@anthropic.com>
This commit is contained in:
Vladimir Bryzgalov
2026-08-16 22:07:02 +05:00
co-authored by Claude Opus 5
parent fd90cdadc1
commit 56cf7e5b0e
8 changed files with 401 additions and 5 deletions
+7
View File
@@ -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", "")),
+3 -2
View File
@@ -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,
+68 -1
View File
@@ -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),
},
}
+3 -2
View File
@@ -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)