Второй проход по промежуткам, которые детектор счёл тишиной
Диаризация задавала не только «кто говорит», но и границы того, что вообще попадает в распознавание: фразы вне её сегментов пропадали молча. Второй проход их возвращает с пометкой 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:
co-authored by
Claude Opus 5
parent
fd90cdadc1
commit
56cf7e5b0e
@@ -59,6 +59,11 @@ speakers = 2
|
||||
# это решающая настройка: без неё тихий участник сливается с громким и
|
||||
# разделение по говорящим разваливается. На распознавание не влияет.
|
||||
normalize = true
|
||||
# Распознавать промежутки, которые детектор речи не отметил как речь.
|
||||
# Возвращает потерянные фразы (вопрос о цене, даты, документы), но вместе
|
||||
# с ними может подхватить радио или телевизор на фоне. Возвращённые реплики
|
||||
# помечены recovered=true и speaker=0 - их видно и можно отфильтровать.
|
||||
recover_gaps = true
|
||||
# Максимальный размер загружаемого файла, МБ
|
||||
max_upload_mb = 500
|
||||
# Сколько часов хранить результаты завершённых задач
|
||||
@@ -101,6 +106,7 @@ class Settings:
|
||||
workers: int = 0
|
||||
speakers: int = 2
|
||||
normalize: bool = True
|
||||
recover_gaps: bool = True
|
||||
max_upload_mb: int = 500
|
||||
keep_results_hours: float = 72.0
|
||||
webhook_url: str = ""
|
||||
@@ -187,6 +193,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)),
|
||||
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)),
|
||||
webhook_url=str(hook.get("url", "")),
|
||||
|
||||
+3
-2
@@ -75,12 +75,13 @@ 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).result()
|
||||
settings.normalize, settings.recover_gaps).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)
|
||||
result = worker.transcribe(wav, num_speakers=speakers,
|
||||
recover_gaps=settings.recover_gaps)
|
||||
result["filename"] = job["filename"]
|
||||
store.mark_done(job_id, result)
|
||||
log.info("задача %s готова: %.1f с аудио, x%s", job_id,
|
||||
|
||||
+68
-1
@@ -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),
|
||||
},
|
||||
}
|
||||
|
||||
+3
-2
@@ -31,7 +31,7 @@ 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) -> dict:
|
||||
normalize: bool = True, recover_gaps: bool = True) -> dict:
|
||||
"""Переводит файл в WAV и распознаёт. Выполняется в процессе-воркере."""
|
||||
import tempfile
|
||||
|
||||
@@ -43,4 +43,5 @@ def run_job(audio_path: str, num_speakers: int, ffmpeg: str,
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
wav = Path(tmp) / "audio.wav"
|
||||
to_wav16k(Path(audio_path), wav, ffmpeg, normalize)
|
||||
return _pipeline.transcribe(wav, num_speakers=num_speakers)
|
||||
return _pipeline.transcribe(wav, num_speakers=num_speakers,
|
||||
recover_gaps=recover_gaps)
|
||||
|
||||
Reference in New Issue
Block a user