Files
local-transcriber/src/local_transcriber/formatter.py
T
Dmitriy Dementiev 9f11c32979 fix(cli)!: источник языка в шапке транскрипта на русском
- Зачем:
  - в одной строке шапки смешивались английские и русские значения, а при неизвестном языке в неё попадало служебное «unknown (не определён)».
- Что:
  - формулировки источника языка вынесены константами в formatter и переведены на русский.
  - оба бэкенда используют общий признак UNKNOWN_LANGUAGE вместо «auto» и «unknown».
  - неизвестный язык печатается одной строкой, без служебного значения.
- Проверка:
  - uv run pytest -q: 242 passed, 1 skipped.
  - ruff check .: 48 замечаний, столько же, сколько до правки.

BREAKING CHANGE: в шапке транскрипта значения detected и forced заменены на «определён автоматически» и «задан явно»; при неизвестном языке строка выглядит как «- **Язык**: не определён».
2026-08-12 11:52:50 +03:00

148 lines
5.4 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.
"""Формирование 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 # максимальная длительность абзаца
# Источник языка в шапке транскрипта
LANGUAGE_FORCED = "задан явно"
LANGUAGE_DETECTED = "определён автоматически"
LANGUAGE_FROM_MODEL = "из профиля модели"
LANGUAGE_UNKNOWN = "не определён"
LANGUAGE_MODES = (
LANGUAGE_FORCED,
LANGUAGE_DETECTED,
LANGUAGE_FROM_MODEL,
LANGUAGE_UNKNOWN,
)
@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, # см. LANGUAGE_MODES
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}")
if language_mode == LANGUAGE_UNKNOWN:
lines.append(f"- **Язык**: {LANGUAGE_UNKNOWN}")
else:
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)