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

Диаризация задавала не только «кто говорит», но и границы того, что вообще
попадает в распознавание: фразы вне её сегментов пропадали молча. Второй
проход их возвращает с пометкой 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
+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),
},
}