- Зачем: - транскрипция может тихо терять содержание: блоки галлюцинированных повторов и обрыв распознавания до конца файла выглядят как законченный транскрипт (класс ошибок из ADR-006), и читатель об этом не узнаёт. - Что: - новый модуль quality.py: детектор разрыва в хвосте (строго >120 с) и детектор блоков повторяющихся сегментов (серия >=4 для длинного текста, >=10 для короткого вроде «ага»). - предупреждения жёлтым в консоли (single и batch) и строками «Внимание» в шапке markdown-транскрипта; _format_duration переименована в публичную format_duration. - 27 новых тестов: границы порогов, нормализация текста, шапка formatter, точные строки CLI-сообщений с обрезкой «(+ ещё N)». - Проверка: - uv run pytest — 218 passed, 1 skipped. - ручной прогон записи 23:01 (gigaam-v3/onnx) — транскрипт полный, предупреждений нет. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
132 lines
4.9 KiB
Python
132 lines
4.9 KiB
Python
"""Формирование markdown-транскрипта из результатов распознавания."""
|
||
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
from .quality import TAIL_GAP_WARN_S, find_repetition_blocks, tail_gap
|
||
from .types import Segment, TranscribeResult
|
||
|
||
_PAUSE_THRESHOLD_S = 2.0 # пауза между сегментами для разбиения на абзацы
|
||
_MAX_PARAGRAPH_S = 60.0 # максимальная длительность абзаца
|
||
|
||
|
||
@dataclass
|
||
class _Paragraph:
|
||
start: float
|
||
end: float
|
||
text: str
|
||
|
||
|
||
def _group_segments(segments: list[Segment]) -> list[_Paragraph]:
|
||
"""Объединяет мелкие сегменты в абзацы по паузам и макс. длительности."""
|
||
if not segments:
|
||
return []
|
||
|
||
paragraphs: list[_Paragraph] = []
|
||
cur_start = segments[0].start
|
||
cur_end = segments[0].end
|
||
cur_texts: list[str] = [segments[0].text.strip()]
|
||
|
||
for seg in segments[1:]:
|
||
gap = seg.start - cur_end
|
||
duration = seg.end - cur_start
|
||
if gap >= _PAUSE_THRESHOLD_S or duration > _MAX_PARAGRAPH_S:
|
||
paragraphs.append(_Paragraph(cur_start, cur_end, " ".join(cur_texts)))
|
||
cur_start = seg.start
|
||
cur_end = seg.end
|
||
cur_texts = [seg.text.strip()]
|
||
else:
|
||
cur_end = seg.end
|
||
cur_texts.append(seg.text.strip())
|
||
|
||
paragraphs.append(_Paragraph(cur_start, cur_end, " ".join(cur_texts)))
|
||
return paragraphs
|
||
|
||
|
||
def format_timestamp(seconds: float, use_hours: bool = False) -> str:
|
||
"""Форматирует время в ``MM:SS.cc`` или ``HH:MM:SS.cc``.
|
||
|
||
Сотые доли (centiseconds) — максимальная точность, которую даёт Whisper.
|
||
"""
|
||
total_cs = round(seconds * 100)
|
||
centiseconds = total_cs % 100
|
||
total_seconds = total_cs // 100
|
||
|
||
if use_hours:
|
||
hours = total_seconds // 3600
|
||
minutes = (total_seconds % 3600) // 60
|
||
secs = total_seconds % 60
|
||
return f"{hours:02d}:{minutes:02d}:{secs:02d}.{centiseconds:02d}"
|
||
|
||
minutes = total_seconds // 60
|
||
secs = total_seconds % 60
|
||
return f"{minutes:02d}:{secs:02d}.{centiseconds:02d}"
|
||
|
||
|
||
def format_duration(seconds: float) -> str:
|
||
"""Человекочитаемая длительность для метаданных в шапке транскрипта."""
|
||
total = int(seconds)
|
||
h = total // 3600
|
||
m = (total % 3600) // 60
|
||
s = total % 60
|
||
if h > 0:
|
||
return f"{h:02d}:{m:02d}:{s:02d}"
|
||
return f"{m:02d}:{s:02d}"
|
||
|
||
|
||
def format_transcript(
|
||
result: TranscribeResult,
|
||
source_filename: str,
|
||
model_name: str,
|
||
device_info: str,
|
||
language_mode: str, # "detected" | "forced"
|
||
transcription_date: datetime | None = None, # None -> datetime.now()
|
||
) -> str:
|
||
"""Собирает markdown-транскрипт: шапка с метаданными + абзацы с таймкодами."""
|
||
date = transcription_date or datetime.now()
|
||
use_hours = result.duration > 3600
|
||
|
||
lines: list[str] = []
|
||
lines.append(f"# Транскрипт: {source_filename}")
|
||
lines.append("")
|
||
lines.append(f"- **Дата транскрипции**: {date.strftime('%Y-%m-%d %H:%M:%S')}")
|
||
lines.append(f"- **Модель**: {model_name}")
|
||
lines.append(f"- **Язык**: {result.language} ({language_mode})")
|
||
lines.append(f"- **Длительность**: {format_duration(result.duration)}")
|
||
if tail_gap(result) > TAIL_GAP_WARN_S:
|
||
last_end = result.segments[-1].end
|
||
lines.append(
|
||
f"- **Внимание**: транскрипт покрывает {format_duration(last_end)} "
|
||
f"из {format_duration(result.duration)} — возможна потеря хвоста записи"
|
||
)
|
||
for block in find_repetition_blocks(result.segments):
|
||
start = format_timestamp(block.start, use_hours=use_hours)
|
||
end = format_timestamp(block.end, use_hours=use_hours)
|
||
lines.append(
|
||
f"- **Внимание**: повторы в [{start} - {end}] ({block.count}×) "
|
||
"— возможны галлюцинации модели"
|
||
)
|
||
lines.append(f"- **Устройство**: {device_info}")
|
||
lines.append("")
|
||
lines.append("---")
|
||
|
||
if not result.segments:
|
||
lines.append("")
|
||
lines.append("*Речь не обнаружена.*")
|
||
else:
|
||
for para in _group_segments(result.segments):
|
||
start = format_timestamp(para.start, use_hours=use_hours)
|
||
end = format_timestamp(para.end, use_hours=use_hours)
|
||
lines.append("")
|
||
lines.append(f"[{start} - {end}] {para.text}")
|
||
|
||
lines.append("")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def write_transcript(content: str, output_path: Path) -> None:
|
||
"""Записывает готовый транскрипт в файл."""
|
||
with open(output_path, "w", encoding="utf-8") as f:
|
||
f.write(content)
|