Режим на детекторе речи вместо диаризации

Диаризация занимала 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>
This commit is contained in:
Vladimir Bryzgalov
2026-08-17 13:36:01 +05:00
co-authored by Claude Opus 5
parent b2bad9d8e6
commit 5d576dd3c4
7 changed files with 132 additions and 14 deletions
+74 -9
View File
@@ -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),