Files
talkscore-asr/app/pipeline.py
T
Vladimir BryzgalovandClaude Opus 5 d4a24af6f0 Готовые предложения в ответе для разметки ролей
Разбивка на предложения нужна LLM-разметке и была бы дублирована на стороне
Talkscore. Логика живёт рядом с ASR, который и ставит пунктуацию, а сам
вызов LLM остаётся на Talkscore: промпт меняется чаще, чем расшифровка,
и переразметить сохранённый текст дешевле, чем гонять аудио заново.

Документ интеграции дополнен итогами сравнения моделей, batch-ценами
и проверками формата ответа.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 18:38:34 +05:00

291 lines
15 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, split_sentences)
from app.turns import Segment, chunk_ranges, merge_turns, speech_stats
log = logging.getLogger(__name__)
SAMPLE_RATE = 16000
# GigaAM падает на кусках длиннее ~200 с, держим запас.
# Предел зависит от сборки onnxruntime: на macOS реплика в 190 с проходит,
# на Windows та же падает в свёртке энкодера. Берём с большим запасом, а на
# случай сюрпризов есть деление куска пополам при ошибке.
MAX_CHUNK_SEC = 60.0
# Ниже этого делить бессмысленно: явно не длина виновата.
MIN_SPLIT_SEC = 2.0
# Модель отпечатков рассчитана на короткий фрагмент и на длинном падает.
# Для оценки голоса секунд более чем достаточно.
EMBED_SEC = 8.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
# Выравнивание громкости - решающий шаг для записей с одним микрофоном на столе.
# На проверочных разговорах доля второго участника выросла с 1.8 до 24.3 процента,
# а переключений между репликами - с 21 до 71 процента. Распознанный текст при
# этом не меняется: сравнение фрагментов дало полностью совпадающие расшифровки.
# Параметры подобраны замером: f=400:g=3 даёт лишь 8.6 процента вместо 43.2,
# так что менять их наугад не стоит.
NORMALIZE_FILTER = "dynaudnorm=f=200:g=5"
def to_wav16k(src: Path, dst: Path, ffmpeg: str, normalize: bool = True) -> float:
"""Приводит любой аудиофайл к 16 кГц моно WAV. Возвращает длительность."""
command = [ffmpeg, "-nostdin", "-v", "error", "-y", "-i", str(src), "-vn"]
if normalize:
command += ["-af", NORMALIZE_FILTER]
command += ["-ar", str(SAMPLE_RATE), "-ac", "1", "-c:a", "pcm_s16le", str(dst)]
subprocess.run(command, 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, labels = [], []
for seg in usable:
# Берём кусок из середины реплики: там речь устойчивее, чем на краях,
# а длинный фрагмент модель отпечатков просто не переваривает.
middle = (seg.start + seg.end) / 2
half = min(EMBED_SEC, seg.end - seg.start) / 2
piece = samples[int((middle - half) * SAMPLE_RATE):int((middle + half) * SAMPLE_RATE)]
if len(piece) < SAMPLE_RATE // 2:
continue
stream = self._embedder.create_stream()
stream.accept_waveform(SAMPLE_RATE, piece)
stream.input_finished()
vectors.append(np.array(self._embedder.compute(stream)))
labels.append(seg.speaker)
if len(vectors) < 4:
return 0.0
return separation_quality(np.array(vectors), np.array(labels))
def _recognize_safely(self, audio: np.ndarray, depth: int = 0) -> str:
"""Распознаёт кусок, при ошибке деля его пополам.
Пределы длины у разных сборок onnxruntime разные и заранее неизвестны,
поэтому вместо подбора константы просто уменьшаем кусок и пробуем снова.
"""
try:
return self._asr.recognize(audio, sample_rate=SAMPLE_RATE)
except Exception:
seconds = len(audio) / SAMPLE_RATE
if seconds <= MIN_SPLIT_SEC or depth >= 5:
log.warning("не удалось распознать кусок в %.1f с, пропускаю", seconds)
return ""
log.info("кусок в %.1f с не прошёл, делю пополам", seconds)
middle = len(audio) // 2
left = self._recognize_safely(audio[:middle], depth + 1)
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:
"""Полный проход: диаризация, распознавание реплик, постобработка.
Модели грузятся при первой задаче, а не при создании: воркеров несколько,
и держать копии моделей у тех, кому работа ещё не досталась, незачем.
"""
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]
try:
quality = self._separation_quality(samples, segments)
except Exception as exc: # noqa: BLE001 - оценка вспомогательная
# Метрика не должна ронять задачу: без неё расшифровка всё равно нужна.
log.warning("не удалось оценить разделение говорящих: %s", exc)
quality = 0.0
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._recognize_safely(audio))
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,
# Готовый материал для разметки ролей на стороне Talkscore: делить
# реплики самостоятельно ей незачем, пунктуацию ставит ASR.
"sentences": split_sentences(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),
},
}