Files
talkscore-asr/app/pipeline.py
T
Vladimir BryzgalovandClaude Opus 5 e2db164f0b Признаки для расстановки ролей и оценка надёжности разделения
На записях с микрофоном на столе голоса участников для модели почти
неразличимы: перебор четырёх моделей отпечатков и смена алгоритма
кластеризации баланс улучшают, но роли всё равно скачут.

Поэтому сервис теперь отдаёт то, на что можно опереться: акустику каждой
реплики (громкость, доля высоких, центроид - они связаны с расстоянием
до микрофона) и метрику separation_quality с флагом speakers_reliable.
Ниже 0.35 разметка по говорящим случайна, и роли должна определять LLM
по смыслу реплик.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 23:21:56 +05:00

238 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Распознавание речи с разделением по говорящим.
Модели загружаются один раз при старте сервиса и живут в памяти: холодная
загрузка GigaAM занимает секунды, и делать её на каждый запрос недопустимо.
"""
import logging
import shutil
import subprocess
import time
import wave
from pathlib import Path
import numpy as np
from app.acoustics import segment_acoustics, separation_quality
from app.text import apply_replacements, load_replacements, normalize_typography
from app.turns import Segment, chunk_ranges, merge_turns, speech_stats
log = logging.getLogger(__name__)
SAMPLE_RATE = 16000
# GigaAM падает на кусках длиннее ~200 с, держим запас.
MAX_CHUNK_SEC = 150.0
# Ниже этого значения голоса практически неразличимы и разметка по говорящим
# случайна. На записях с одним микрофоном в комнате так бывает часто.
RELIABLE_SEPARATION = 0.35
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")
class ModelsMissing(RuntimeError):
"""Модели не скачаны - сервис не может работать."""
def find_ffmpeg(base_dir: Path) -> str:
"""Ищет ffmpeg сначала в папке пакета, потом в системе."""
for candidate in (base_dir / "bin" / "ffmpeg.exe", base_dir / "bin" / "ffmpeg"):
if candidate.is_file():
return str(candidate)
found = shutil.which("ffmpeg")
if not found:
raise ModelsMissing(
"не найден ffmpeg - положите ffmpeg.exe в папку bin рядом с сервисом")
return found
def to_wav16k(src: Path, dst: Path, ffmpeg: str) -> float:
"""Приводит любой аудиофайл к 16 кГц моно WAV. Возвращает длительность."""
subprocess.run(
[ffmpeg, "-nostdin", "-v", "error", "-y", "-i", str(src),
"-vn", "-ar", str(SAMPLE_RATE), "-ac", "1", "-c:a", "pcm_s16le", str(dst)],
check=True, capture_output=True,
)
with wave.open(str(dst)) as w:
return w.getnframes() / w.getframerate()
def read_wav(path: Path) -> np.ndarray:
with wave.open(str(path)) as w:
if w.getframerate() != SAMPLE_RATE or w.getnchannels() != 1:
raise ValueError(f"ожидался {SAMPLE_RATE} Гц моно, получено "
f"{w.getframerate()} Гц / {w.getnchannels()} кан.")
raw = w.readframes(w.getnframes())
return np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
class Pipeline:
"""Диаризация плюс распознавание. Потокобезопасен на уровне одного воркера."""
def __init__(self, models_dir: Path, threads: int, replacements_path: Path,
base_dir: Path):
self.models_dir = Path(models_dir)
self.threads = threads
self.replacements_path = Path(replacements_path)
self.base_dir = Path(base_dir)
self._ffmpeg: str | None = None
self._asr = None
self._diar_cache: dict[int, object] = {}
self._embedder = None
self._replacements: dict[str, str] = {}
@property
def ffmpeg(self) -> str:
"""Ищется при первом обращении, а не в конструкторе.
Иначе отсутствие ffmpeg роняло бы сам импорт модуля - и вместо внятного
сообщения пользователь получал бы стек при старте, а обновление кода
не могло бы проверить работоспособность новой версии.
"""
if self._ffmpeg is None:
self._ffmpeg = find_ffmpeg(self.base_dir)
return self._ffmpeg
def warmup(self) -> None:
"""Грузит модели заранее, чтобы первый запрос не ждал."""
import onnx_asr
missing = [str(p) for p in (self.models_dir / SEG_MODEL_REL,
self.models_dir / EMB_MODEL_REL,
self.models_dir / ASR_MODEL_REL / "config.json")
if not p.is_file()]
if missing:
raise ModelsMissing(
f"не найдены модели: {', '.join(missing)}. Запустите download_models.bat")
t0 = time.time()
# CoreML и DirectML на GigaAM падают на отдельных узлах графа, поэтому явно CPU:
# на 16-ядерном процессоре этого более чем достаточно.
self._asr = onnx_asr.load_model(
ASR_MODEL_NAME, str(self.models_dir / ASR_MODEL_REL),
providers=["CPUExecutionProvider"],
sess_options=self._session_options(),
)
self._reload_replacements()
log.info("модели загружены за %.1f с, потоков: %d", time.time() - t0, self.threads)
def _session_options(self):
import onnxruntime as ort
opts = ort.SessionOptions()
opts.intra_op_num_threads = self.threads
opts.inter_op_num_threads = 1
return opts
def _reload_replacements(self) -> None:
self._replacements = load_replacements(self.replacements_path)
def _diarizer(self, num_speakers: int):
"""Диаризатор кэшируется по числу говорящих - его создание не бесплатно."""
import sherpa_onnx
if num_speakers in self._diar_cache:
return self._diar_cache[num_speakers]
clustering = (sherpa_onnx.FastClusteringConfig(num_clusters=num_speakers)
if num_speakers > 0 else sherpa_onnx.FastClusteringConfig(threshold=0.5))
cfg = sherpa_onnx.OfflineSpeakerDiarizationConfig(
segmentation=sherpa_onnx.OfflineSpeakerSegmentationModelConfig(
pyannote=sherpa_onnx.OfflineSpeakerSegmentationPyannoteModelConfig(
model=str(self.models_dir / SEG_MODEL_REL)),
num_threads=self.threads),
embedding=sherpa_onnx.SpeakerEmbeddingExtractorConfig(
model=str(self.models_dir / EMB_MODEL_REL), num_threads=self.threads),
clustering=clustering,
# Подобрано на реальных звонках: отсекает обрывки на перекрывающейся
# речи, из-за которых в расшифровку лезли огрызки чужих слов.
min_duration_on=1.0,
min_duration_off=0.7,
)
diar = sherpa_onnx.OfflineSpeakerDiarization(cfg)
self._diar_cache[num_speakers] = diar
return diar
def _separation_quality(self, samples: np.ndarray, segments: list[Segment]) -> float:
"""Оценивает, действительно ли голоса разошлись, или разметка случайна."""
import sherpa_onnx
usable = [s for s in segments if s.end - s.start >= 1.0][:120]
if len(usable) < 4:
return 0.0
if self._embedder is None:
self._embedder = sherpa_onnx.SpeakerEmbeddingExtractor(
sherpa_onnx.SpeakerEmbeddingExtractorConfig(
model=str(self.models_dir / EMB_MODEL_REL), num_threads=self.threads))
vectors = []
for seg in usable:
stream = self._embedder.create_stream()
stream.accept_waveform(SAMPLE_RATE,
samples[int(seg.start * SAMPLE_RATE):int(seg.end * SAMPLE_RATE)])
stream.input_finished()
vectors.append(np.array(self._embedder.compute(stream)))
return separation_quality(np.array(vectors),
np.array([s.speaker for s in usable]))
def transcribe(self, wav_path: Path, num_speakers: int = 2) -> dict:
"""Полный проход: диаризация, распознавание реплик, постобработка.
Модели грузятся при первой задаче, а не при создании: воркеров несколько,
и держать копии моделей у тех, кому работа ещё не досталась, незачем.
"""
if self._asr is None:
self.warmup()
self._reload_replacements()
samples = read_wav(wav_path)
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]
quality = self._separation_quality(samples, segments)
t_diar = time.time() - t0
t0 = time.time()
turns_out = []
for turn in merge_turns(segments, gap=0.8):
pieces = []
for start, stop in chunk_ranges(turn.start, turn.end, MAX_CHUNK_SEC):
audio = samples[int(start * SAMPLE_RATE):int(stop * SAMPLE_RATE)]
if len(audio) < SAMPLE_RATE * 0.2:
continue
pieces.append(self._asr.recognize(audio, sample_rate=SAMPLE_RATE))
text = " ".join(p.strip() for p in pieces if p and p.strip())
if not text:
continue
text = normalize_typography(apply_replacements(text, self._replacements))
audio = samples[int(turn.start * SAMPLE_RATE):int(turn.end * SAMPLE_RATE)]
turns_out.append({
"speaker": turn.speaker + 1,
"start": round(turn.start, 2),
"end": round(turn.end, 2),
"text": text,
# Подсказка для LLM: у говорящего ближе к микрофону громкость
# и доля высоких выше. Когда разделение по голосу ненадёжно,
# это единственный объективный признак, кто есть кто.
"acoustics": segment_acoustics(audio),
})
t_asr = time.time() - t0
return {
"duration_sec": round(duration, 1),
"turns": turns_out,
"stats": {
**speech_stats(segments),
"separation_quality": quality,
"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),
},
"timing": {
"diarization_sec": round(t_diar, 1),
"asr_sec": round(t_asr, 1),
"realtime_factor": round(duration / max(t_diar + t_asr, 0.001), 1),
},
}