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

Диаризация занимала 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
+11
View File
@@ -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)),
+4 -2
View File
@@ -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,
+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),
+3 -2
View File
@@ -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)