Зачем: - локальным транскриптам нужна структура реплик для конспектов и протоколов. Что: - добавлены пословные таймкоды для всех ASR-бэкендов и сведение с Sherpa-ONNX. - реализованы CLI-флаги, деградация без потери ASR и speaker Markdown. - добавлены проверяемый кеш моделей, тесты и документация. Проверка: - `pytest` — 283 passed, 1 skipped. - `pyright` — 0 errors. - Ruff и `git diff --check` — без ошибок. - выполнены три контрольных прогона на реальных записях.
454 lines
15 KiB
Python
454 lines
15 KiB
Python
from datetime import datetime
|
||
|
||
from local_transcriber.formatter import (
|
||
LANGUAGE_DETECTED,
|
||
LANGUAGE_FORCED,
|
||
LANGUAGE_UNKNOWN,
|
||
_group_segments,
|
||
format_timestamp,
|
||
format_transcript,
|
||
write_transcript,
|
||
)
|
||
from local_transcriber.transcriber import Segment, TranscribeResult
|
||
from local_transcriber.types import (
|
||
UNKNOWN_LANGUAGE,
|
||
SmallSpeakerCluster,
|
||
SpeakerTranscript,
|
||
SpeakerTurn,
|
||
)
|
||
|
||
|
||
def test_format_timestamp_minutes():
|
||
assert format_timestamp(0.0) == "00:00.00"
|
||
assert format_timestamp(83.45) == "01:23.45"
|
||
assert format_timestamp(9.1) == "00:09.10"
|
||
assert format_timestamp(599.99) == "09:59.99"
|
||
assert format_timestamp(0.995) == "00:01.00" # carry-over: не даёт .100
|
||
|
||
|
||
def test_format_timestamp_hours():
|
||
assert format_timestamp(3723.45, use_hours=True) == "01:02:03.45"
|
||
assert format_timestamp(0.0, use_hours=True) == "00:00:00.00"
|
||
assert format_timestamp(7261.0, use_hours=True) == "02:01:01.00"
|
||
|
||
|
||
def test_format_transcript_basic():
|
||
result = TranscribeResult(
|
||
segments=[
|
||
Segment(start=0.0, end=4.82, text=" Добрый день, коллеги."),
|
||
Segment(start=4.82, end=9.15, text=" Первый вопрос."),
|
||
],
|
||
language="ru",
|
||
language_probability=0.97,
|
||
duration=120.0,
|
||
device_used="cuda",
|
||
)
|
||
content = format_transcript(
|
||
result,
|
||
source_filename="meeting.mp4",
|
||
model_name="large-v3",
|
||
device_info="CUDA (NVIDIA GeForce RTX 3060)",
|
||
language_mode=LANGUAGE_DETECTED,
|
||
transcription_date=datetime(2026, 3, 17, 14, 30, 5),
|
||
)
|
||
|
||
assert "# Транскрипт: meeting.mp4" in content
|
||
assert "**Дата транскрипции**: 2026-03-17 14:30:05" in content
|
||
assert "**Модель**: large-v3" in content
|
||
assert "**Язык**: ru (определён автоматически)" in content
|
||
assert "**Длительность**: 02:00" in content
|
||
assert "**Устройство**: CUDA (NVIDIA GeForce RTX 3060)" in content
|
||
assert "---" in content
|
||
# Соседние сегменты без паузы объединяются в один абзац
|
||
assert "[00:00.00 - 00:09.15] Добрый день, коллеги. Первый вопрос." in content
|
||
|
||
|
||
def test_format_transcript_speaker_turns_use_truncated_start_timestamps():
|
||
result = TranscribeResult(
|
||
segments=[Segment(start=547.96, end=560.0, text=" Обычный текст")],
|
||
language="ru",
|
||
language_probability=0.97,
|
||
duration=700.0,
|
||
device_used="cpu",
|
||
)
|
||
speakers = SpeakerTranscript(
|
||
turns=[
|
||
SpeakerTurn(547.96, 550.0, "Первая реплика.", 1),
|
||
SpeakerTurn(558.4, 560.0, "Ответ.", 2),
|
||
],
|
||
cluster_count=2,
|
||
unassigned_word_count=0,
|
||
small_clusters=[],
|
||
)
|
||
|
||
content = format_transcript(
|
||
result,
|
||
source_filename="meeting.mp4",
|
||
model_name="medium",
|
||
device_info="CPU",
|
||
language_mode=LANGUAGE_DETECTED,
|
||
speaker_transcript=speakers,
|
||
)
|
||
|
||
assert "- **Голосовых кластеров**: 2" in content
|
||
assert "[09:07] Speaker 1: Первая реплика." in content
|
||
assert "[09:18] Speaker 2: Ответ." in content
|
||
assert "[09:07.96 -" not in content
|
||
|
||
|
||
def test_format_transcript_speaker_turns_use_hours_after_one_hour():
|
||
result = TranscribeResult(
|
||
segments=[Segment(start=3661.9, end=3663.0, text=" Длинная встреча")],
|
||
language="ru",
|
||
language_probability=1.0,
|
||
duration=3700.0,
|
||
device_used="cpu",
|
||
)
|
||
speakers = SpeakerTranscript(
|
||
turns=[SpeakerTurn(3661.9, 3663.0, "Длинная встреча", 1)],
|
||
cluster_count=2,
|
||
unassigned_word_count=0,
|
||
small_clusters=[],
|
||
)
|
||
|
||
content = format_transcript(
|
||
result,
|
||
source_filename="meeting.mp4",
|
||
model_name="medium",
|
||
device_info="CPU",
|
||
language_mode=LANGUAGE_FORCED,
|
||
speaker_transcript=speakers,
|
||
)
|
||
|
||
assert "[01:01:01] Speaker 1: Длинная встреча" in content
|
||
|
||
|
||
def test_format_transcript_reports_unknown_words_and_small_clusters():
|
||
result = TranscribeResult(
|
||
segments=[Segment(start=0.0, end=8.0, text=" Текст")],
|
||
language="ru",
|
||
language_probability=1.0,
|
||
duration=20.0,
|
||
device_used="cpu",
|
||
)
|
||
speakers = SpeakerTranscript(
|
||
turns=[SpeakerTurn(1.2, 2.0, "Неясная реплика.", None)],
|
||
cluster_count=2,
|
||
unassigned_word_count=3,
|
||
small_clusters=[SmallSpeakerCluster(speaker=2, duration=4.2)],
|
||
)
|
||
|
||
content = format_transcript(
|
||
result,
|
||
source_filename="meeting.mp4",
|
||
model_name="medium",
|
||
device_info="CPU",
|
||
language_mode=LANGUAGE_FORCED,
|
||
speaker_transcript=speakers,
|
||
)
|
||
|
||
assert "[00:01] Speaker ?: Неясная реплика." in content
|
||
assert "3 слов без назначенного говорящего" in content
|
||
assert "малый кластер Speaker 2: 4.2 с" in content
|
||
|
||
|
||
def test_format_transcript_keeps_plain_body_with_diarization_warning():
|
||
result = TranscribeResult(
|
||
segments=[Segment(start=0.0, end=2.0, text=" Полезный текст.")],
|
||
language="ru",
|
||
language_probability=1.0,
|
||
duration=5.0,
|
||
device_used="cpu",
|
||
)
|
||
|
||
content = format_transcript(
|
||
result,
|
||
source_filename="meeting.mp4",
|
||
model_name="medium",
|
||
device_info="CPU",
|
||
language_mode=LANGUAGE_FORCED,
|
||
diarization_warning="Диаризация завершилась с ошибкой: boom",
|
||
)
|
||
|
||
assert "**Внимание**: Диаризация завершилась с ошибкой: boom" in content
|
||
assert "[00:00.00 - 00:02.00] Полезный текст." in content
|
||
assert "Speaker" not in content
|
||
|
||
|
||
def test_format_transcript_unknown_language_without_placeholder():
|
||
"""Неизвестный язык печатается одной строкой, без служебного значения."""
|
||
result = TranscribeResult(
|
||
segments=[Segment(start=0.0, end=4.0, text=" Добрый день.")],
|
||
language=UNKNOWN_LANGUAGE,
|
||
language_probability=0.0,
|
||
duration=120.0,
|
||
device_used="openvino-cpu",
|
||
)
|
||
content = format_transcript(
|
||
result,
|
||
source_filename="meeting.mp4",
|
||
model_name="medium",
|
||
device_info="OpenVINO (CPU)",
|
||
language_mode=LANGUAGE_UNKNOWN,
|
||
)
|
||
|
||
assert "**Язык**: не определён" in content
|
||
assert UNKNOWN_LANGUAGE not in content
|
||
|
||
|
||
def test_format_transcript_segment_no_leading_space():
|
||
"""Сегменты без ведущего пробела должны форматироваться корректно."""
|
||
result = TranscribeResult(
|
||
segments=[Segment(start=0.0, end=2.0, text="Hello")],
|
||
language="en",
|
||
language_probability=0.99,
|
||
duration=5.0,
|
||
device_used="cpu",
|
||
)
|
||
content = format_transcript(
|
||
result,
|
||
source_filename="f.mp3",
|
||
model_name="tiny",
|
||
device_info="CPU",
|
||
language_mode=LANGUAGE_DETECTED,
|
||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||
)
|
||
assert "[00:00.00 - 00:02.00] Hello" in content
|
||
|
||
|
||
def test_format_transcript_empty():
|
||
result = TranscribeResult(
|
||
segments=[],
|
||
language="ru",
|
||
language_probability=0.5,
|
||
duration=30.0,
|
||
device_used="cpu",
|
||
)
|
||
content = format_transcript(
|
||
result,
|
||
source_filename="silence.wav",
|
||
model_name="tiny",
|
||
device_info="CPU",
|
||
language_mode=LANGUAGE_DETECTED,
|
||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||
)
|
||
|
||
assert "# Транскрипт: silence.wav" in content
|
||
assert "*Речь не обнаружена.*" in content
|
||
assert "**Модель**: tiny" in content
|
||
|
||
|
||
def test_format_transcript_long():
|
||
result = TranscribeResult(
|
||
segments=[
|
||
Segment(start=0.0, end=10.5, text=" Начало."),
|
||
Segment(start=3700.0, end=3710.25, text=" Конец."),
|
||
],
|
||
language="en",
|
||
language_probability=0.99,
|
||
duration=3800.0,
|
||
device_used="cuda",
|
||
)
|
||
content = format_transcript(
|
||
result,
|
||
source_filename="long.mp4",
|
||
model_name="large-v3",
|
||
device_info="CUDA",
|
||
language_mode=LANGUAGE_FORCED,
|
||
transcription_date=datetime(2026, 3, 17, 10, 0, 0),
|
||
)
|
||
|
||
assert "**Длительность**: 01:03:20" in content
|
||
assert "**Язык**: en (задан явно)" in content
|
||
# Timestamps should use hours format
|
||
assert "[00:00:00.00 - 00:00:10.50] Начало." in content
|
||
assert "[01:01:40.00 - 01:01:50.25] Конец." in content
|
||
|
||
|
||
def test_group_segments_merges_adjacent():
|
||
"""Соседние сегменты без паузы объединяются."""
|
||
segments = [
|
||
Segment(start=0.0, end=3.0, text=" Первый."),
|
||
Segment(start=3.0, end=6.0, text=" Второй."),
|
||
Segment(start=6.0, end=9.0, text=" Третий."),
|
||
]
|
||
groups = _group_segments(segments)
|
||
assert len(groups) == 1
|
||
assert groups[0].start == 0.0
|
||
assert groups[0].end == 9.0
|
||
assert groups[0].text == "Первый. Второй. Третий."
|
||
|
||
|
||
def test_group_segments_splits_on_pause():
|
||
"""Пауза > 2с разбивает на отдельные абзацы."""
|
||
segments = [
|
||
Segment(start=0.0, end=3.0, text=" Первый."),
|
||
Segment(start=3.0, end=6.0, text=" Второй."),
|
||
Segment(start=9.0, end=12.0, text=" После паузы."),
|
||
]
|
||
groups = _group_segments(segments)
|
||
assert len(groups) == 2
|
||
assert groups[0].text == "Первый. Второй."
|
||
assert groups[1].text == "После паузы."
|
||
|
||
|
||
def test_group_segments_splits_on_max_duration():
|
||
"""Абзац разбивается при превышении макс. длительности."""
|
||
segments = [
|
||
Segment(start=0.0, end=30.0, text=" Длинный."),
|
||
Segment(start=30.0, end=55.0, text=" Ещё."),
|
||
Segment(start=55.0, end=80.0, text=" Перелив."),
|
||
]
|
||
groups = _group_segments(segments)
|
||
assert len(groups) == 2
|
||
assert groups[0].text == "Длинный. Ещё."
|
||
assert groups[1].text == "Перелив."
|
||
|
||
|
||
def test_group_segments_empty():
|
||
assert _group_segments([]) == []
|
||
|
||
|
||
def test_write_transcript(tmp_path):
|
||
out = tmp_path / "output.md"
|
||
write_transcript("# Test content\n", out)
|
||
assert out.read_text(encoding="utf-8") == "# Test content\n"
|
||
|
||
|
||
def test_format_transcript_tail_gap_warning():
|
||
result = TranscribeResult(
|
||
segments=[Segment(start=0.0, end=60.0, text=" Фраза.")],
|
||
language="ru",
|
||
language_probability=0.95,
|
||
duration=600.0,
|
||
device_used="cpu",
|
||
)
|
||
|
||
content = format_transcript(
|
||
result,
|
||
source_filename="tail.mp3",
|
||
model_name="medium",
|
||
device_info="CPU",
|
||
language_mode=LANGUAGE_FORCED,
|
||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||
)
|
||
|
||
assert "возможна потеря хвоста" in content
|
||
assert "транскрипт покрывает 01:00 из 10:00" in content
|
||
|
||
|
||
def test_format_transcript_no_tail_gap_warning_for_small_gap():
|
||
result = TranscribeResult(
|
||
segments=[Segment(start=0.0, end=60.0, text=" Фраза.")],
|
||
language="ru",
|
||
language_probability=0.95,
|
||
duration=179.99,
|
||
device_used="cpu",
|
||
)
|
||
|
||
content = format_transcript(
|
||
result,
|
||
source_filename="ok.mp3",
|
||
model_name="medium",
|
||
device_info="CPU",
|
||
language_mode=LANGUAGE_FORCED,
|
||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||
)
|
||
|
||
assert "потеря хвоста" not in content
|
||
|
||
|
||
def test_format_transcript_no_tail_gap_warning_for_exact_threshold():
|
||
result = TranscribeResult(
|
||
segments=[Segment(start=0.0, end=60.0, text=" Фраза.")],
|
||
language="ru",
|
||
language_probability=0.95,
|
||
duration=180.0,
|
||
device_used="cpu",
|
||
)
|
||
|
||
content = format_transcript(
|
||
result,
|
||
source_filename="ok.mp3",
|
||
model_name="medium",
|
||
device_info="CPU",
|
||
language_mode=LANGUAGE_FORCED,
|
||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||
)
|
||
|
||
assert "потеря хвоста" not in content
|
||
|
||
|
||
def test_format_transcript_repetition_warning():
|
||
result = TranscribeResult(
|
||
segments=[
|
||
Segment(start=10.0, end=11.0, text=" Повторяемая фраза."),
|
||
Segment(start=11.0, end=12.0, text=" повторяемая фраза"),
|
||
Segment(start=12.0, end=13.0, text=" «Повторяемая фраза»"),
|
||
Segment(start=13.0, end=14.0, text=" повторяемая фраза…"),
|
||
],
|
||
language="ru",
|
||
language_probability=0.95,
|
||
duration=60.0,
|
||
device_used="cpu",
|
||
)
|
||
|
||
content = format_transcript(
|
||
result,
|
||
source_filename="repeat.mp3",
|
||
model_name="medium",
|
||
device_info="CPU",
|
||
language_mode=LANGUAGE_FORCED,
|
||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||
)
|
||
|
||
assert "повторы в [00:10.00 - 00:14.00] (4×)" in content
|
||
assert "возможны галлюцинации" in content
|
||
|
||
|
||
def test_format_transcript_repetition_warning_uses_hours():
|
||
result = TranscribeResult(
|
||
segments=[
|
||
Segment(start=3600.0, end=3601.0, text=" Повтор."),
|
||
Segment(start=3601.0, end=3602.0, text=" повтор"),
|
||
Segment(start=3602.0, end=3603.0, text=" повтор"),
|
||
Segment(start=3603.0, end=3604.0, text=" повтор"),
|
||
],
|
||
language="ru",
|
||
language_probability=0.95,
|
||
duration=3700.0,
|
||
device_used="cpu",
|
||
)
|
||
|
||
content = format_transcript(
|
||
result,
|
||
source_filename="long-repeat.mp3",
|
||
model_name="medium",
|
||
device_info="CPU",
|
||
language_mode=LANGUAGE_FORCED,
|
||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||
)
|
||
|
||
assert "повторы в [01:00:00.00 - 01:00:04.00] (4×)" in content
|
||
|
||
|
||
def test_format_transcript_without_anomalies_has_no_warning_lines():
|
||
result = TranscribeResult(
|
||
segments=[Segment(start=0.0, end=60.0, text=" Обычная запись.")],
|
||
language="ru",
|
||
language_probability=0.95,
|
||
duration=120.0,
|
||
device_used="cpu",
|
||
)
|
||
|
||
content = format_transcript(
|
||
result,
|
||
source_filename="ok.mp3",
|
||
model_name="medium",
|
||
device_info="CPU",
|
||
language_mode=LANGUAGE_FORCED,
|
||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||
)
|
||
|
||
assert "Внимание" not in content
|