Обновление OpenVINO до 2026.3, large-v3-turbo и ONNX по умолчанию #6
@@ -8,7 +8,7 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from local_transcriber.types import Segment, TranscribeResult
|
from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, TranscribeResult
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -216,7 +216,9 @@ class OnnxAsrBackend:
|
|||||||
|
|
||||||
_notify(on_status, "Транскрибирую (onnx-asr)...")
|
_notify(on_status, "Транскрибирую (onnx-asr)...")
|
||||||
segments: list[Segment] = []
|
segments: list[Segment] = []
|
||||||
result_language = language or _model_language(self._model_spec) or "unknown"
|
result_language = (
|
||||||
|
language or _model_language(self._model_spec) or UNKNOWN_LANGUAGE
|
||||||
|
)
|
||||||
|
|
||||||
for vad_seg in model.recognize(
|
for vad_seg in model.recognize(
|
||||||
audio_array, sample_rate=16000, language=language
|
audio_array, sample_rate=16000, language=language
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from typing import Any
|
|||||||
from huggingface_hub import snapshot_download
|
from huggingface_hub import snapshot_download
|
||||||
from huggingface_hub.errors import LocalEntryNotFoundError
|
from huggingface_hub.errors import LocalEntryNotFoundError
|
||||||
|
|
||||||
from local_transcriber.types import Segment, TranscribeResult
|
from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, TranscribeResult
|
||||||
|
|
||||||
# (model_alias, compute_type) → HF repo
|
# (model_alias, compute_type) → HF repo
|
||||||
MODEL_REPOS: dict[tuple[str, str], str] = {
|
MODEL_REPOS: dict[tuple[str, str], str] = {
|
||||||
@@ -159,7 +159,7 @@ class OpenVINOBackend:
|
|||||||
f"Транскрибирую (OpenVINO)... [{len(segments)} сегм.]",
|
f"Транскрибирую (OpenVINO)... [{len(segments)} сегм.]",
|
||||||
)
|
)
|
||||||
|
|
||||||
detected_language = language or "auto"
|
detected_language = language or UNKNOWN_LANGUAGE
|
||||||
language_probability = 1.0 if language else 0.0
|
language_probability = 1.0 if language else 0.0
|
||||||
|
|
||||||
return TranscribeResult(
|
return TranscribeResult(
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ from .config import apply_device_defaults, load_config, resolve_defaults
|
|||||||
from .context_menu import install_menu as install_context_menu
|
from .context_menu import install_menu as install_context_menu
|
||||||
from .context_menu import uninstall_menu as uninstall_context_menu
|
from .context_menu import uninstall_menu as uninstall_context_menu
|
||||||
from .formatter import (
|
from .formatter import (
|
||||||
|
LANGUAGE_DETECTED,
|
||||||
|
LANGUAGE_FORCED,
|
||||||
|
LANGUAGE_FROM_MODEL,
|
||||||
|
LANGUAGE_UNKNOWN,
|
||||||
format_duration,
|
format_duration,
|
||||||
format_timestamp,
|
format_timestamp,
|
||||||
format_transcript,
|
format_transcript,
|
||||||
@@ -30,6 +34,7 @@ from .transcriber import (
|
|||||||
_transcribe_file,
|
_transcribe_file,
|
||||||
load_model,
|
load_model,
|
||||||
)
|
)
|
||||||
|
from .types import UNKNOWN_LANGUAGE
|
||||||
from .utils import (
|
from .utils import (
|
||||||
build_output_path,
|
build_output_path,
|
||||||
detect_device,
|
detect_device,
|
||||||
@@ -62,12 +67,12 @@ def _format_language_mode(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Описывает источник языка, не выдавая профиль модели за детектор."""
|
"""Описывает источник языка, не выдавая профиль модели за детектор."""
|
||||||
if requested_language != "auto":
|
if requested_language != "auto":
|
||||||
return "forced"
|
return LANGUAGE_FORCED
|
||||||
if result.language_probability > 0:
|
if result.language_probability > 0:
|
||||||
return "detected"
|
return LANGUAGE_DETECTED
|
||||||
if result.language not in {"", "auto", "unknown"}:
|
if result.language not in {"", UNKNOWN_LANGUAGE}:
|
||||||
return "из профиля модели"
|
return LANGUAGE_FROM_MODEL
|
||||||
return "не определён"
|
return LANGUAGE_UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
def _format_repetition_blocks(
|
def _format_repetition_blocks(
|
||||||
|
|||||||
@@ -10,6 +10,19 @@ from .types import Segment, TranscribeResult
|
|||||||
_PAUSE_THRESHOLD_S = 2.0 # пауза между сегментами для разбиения на абзацы
|
_PAUSE_THRESHOLD_S = 2.0 # пауза между сегментами для разбиения на абзацы
|
||||||
_MAX_PARAGRAPH_S = 60.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
|
@dataclass
|
||||||
class _Paragraph:
|
class _Paragraph:
|
||||||
@@ -80,7 +93,7 @@ def format_transcript(
|
|||||||
source_filename: str,
|
source_filename: str,
|
||||||
model_name: str,
|
model_name: str,
|
||||||
device_info: str,
|
device_info: str,
|
||||||
language_mode: str, # detected | forced | из профиля модели | не определён
|
language_mode: str, # см. LANGUAGE_MODES
|
||||||
transcription_date: datetime | None = None, # None -> datetime.now()
|
transcription_date: datetime | None = None, # None -> datetime.now()
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Собирает markdown-транскрипт: шапка с метаданными + абзацы с таймкодами."""
|
"""Собирает markdown-транскрипт: шапка с метаданными + абзацы с таймкодами."""
|
||||||
@@ -92,7 +105,10 @@ def format_transcript(
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(f"- **Дата транскрипции**: {date.strftime('%Y-%m-%d %H:%M:%S')}")
|
lines.append(f"- **Дата транскрипции**: {date.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
lines.append(f"- **Модель**: {model_name}")
|
lines.append(f"- **Модель**: {model_name}")
|
||||||
lines.append(f"- **Язык**: {result.language} ({language_mode})")
|
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)}")
|
lines.append(f"- **Длительность**: {format_duration(result.duration)}")
|
||||||
if tail_gap(result) > TAIL_GAP_WARN_S:
|
if tail_gap(result) > TAIL_GAP_WARN_S:
|
||||||
last_end = result.segments[-1].end
|
last_end = result.segments[-1].end
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ from collections.abc import Callable
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
# Единый признак «язык неизвестен» для всех бэкендов
|
||||||
|
UNKNOWN_LANGUAGE = "unknown"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Segment:
|
class Segment:
|
||||||
@@ -15,7 +18,7 @@ class Segment:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class TranscribeResult:
|
class TranscribeResult:
|
||||||
segments: list[Segment]
|
segments: list[Segment]
|
||||||
language: str
|
language: str # код языка или UNKNOWN_LANGUAGE, если он неизвестен
|
||||||
language_probability: float
|
language_probability: float
|
||||||
duration: float # seconds
|
duration: float # seconds
|
||||||
device_used: str # "cpu" / "cuda" / "onnx" / "openvino-gpu" / "openvino-cpu"
|
device_used: str # "cpu" / "cuda" / "onnx" / "openvino-gpu" / "openvino-cpu"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from local_transcriber.backends.openvino import (
|
|||||||
OpenVINOBackend,
|
OpenVINOBackend,
|
||||||
_validate_model_dir,
|
_validate_model_dir,
|
||||||
)
|
)
|
||||||
from local_transcriber.types import Segment
|
from local_transcriber.types import UNKNOWN_LANGUAGE, Segment
|
||||||
|
|
||||||
|
|
||||||
# === _resolve_repo ===
|
# === _resolve_repo ===
|
||||||
@@ -301,7 +301,7 @@ def test_transcribe_no_language_auto():
|
|||||||
|
|
||||||
call_kwargs = mock_model.generate.call_args.kwargs
|
call_kwargs = mock_model.generate.call_args.kwargs
|
||||||
assert "language" not in call_kwargs
|
assert "language" not in call_kwargs
|
||||||
assert result.language == "auto"
|
assert result.language == UNKNOWN_LANGUAGE
|
||||||
assert result.language_probability == 0.0
|
assert result.language_probability == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+11
-4
@@ -6,7 +6,14 @@ from rich.console import Console
|
|||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from local_transcriber.cli import _format_device_info, _format_language_mode, app
|
from local_transcriber.cli import _format_device_info, _format_language_mode, app
|
||||||
|
from local_transcriber.formatter import (
|
||||||
|
LANGUAGE_DETECTED,
|
||||||
|
LANGUAGE_FORCED,
|
||||||
|
LANGUAGE_FROM_MODEL,
|
||||||
|
LANGUAGE_UNKNOWN,
|
||||||
|
)
|
||||||
from local_transcriber.transcriber import Segment, TranscribeFileResult, TranscribeResult
|
from local_transcriber.transcriber import Segment, TranscribeFileResult, TranscribeResult
|
||||||
|
from local_transcriber.types import UNKNOWN_LANGUAGE
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|
||||||
@@ -45,10 +52,10 @@ def _make_tfr(result=None, model=None, actual_device="cpu", backend=None, model_
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("requested_language", "language", "probability", "expected"),
|
("requested_language", "language", "probability", "expected"),
|
||||||
[
|
[
|
||||||
("ru", "ru", 1.0, "forced"),
|
("ru", "ru", 1.0, LANGUAGE_FORCED),
|
||||||
("auto", "ru", 0.95, "detected"),
|
("auto", "ru", 0.95, LANGUAGE_DETECTED),
|
||||||
("auto", "ru", 0.0, "из профиля модели"),
|
("auto", "ru", 0.0, LANGUAGE_FROM_MODEL),
|
||||||
("auto", "unknown", 0.0, "не определён"),
|
("auto", UNKNOWN_LANGUAGE, 0.0, LANGUAGE_UNKNOWN),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_format_language_mode(
|
def test_format_language_mode(
|
||||||
|
|||||||
+37
-12
@@ -2,12 +2,16 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from local_transcriber.formatter import (
|
from local_transcriber.formatter import (
|
||||||
|
LANGUAGE_DETECTED,
|
||||||
|
LANGUAGE_FORCED,
|
||||||
|
LANGUAGE_UNKNOWN,
|
||||||
_group_segments,
|
_group_segments,
|
||||||
format_timestamp,
|
format_timestamp,
|
||||||
format_transcript,
|
format_transcript,
|
||||||
write_transcript,
|
write_transcript,
|
||||||
)
|
)
|
||||||
from local_transcriber.transcriber import Segment, TranscribeResult
|
from local_transcriber.transcriber import Segment, TranscribeResult
|
||||||
|
from local_transcriber.types import UNKNOWN_LANGUAGE
|
||||||
|
|
||||||
|
|
||||||
def test_format_timestamp_minutes():
|
def test_format_timestamp_minutes():
|
||||||
@@ -40,14 +44,14 @@ def test_format_transcript_basic():
|
|||||||
source_filename="meeting.mp4",
|
source_filename="meeting.mp4",
|
||||||
model_name="large-v3",
|
model_name="large-v3",
|
||||||
device_info="CUDA (NVIDIA GeForce RTX 3060)",
|
device_info="CUDA (NVIDIA GeForce RTX 3060)",
|
||||||
language_mode="detected",
|
language_mode=LANGUAGE_DETECTED,
|
||||||
transcription_date=datetime(2026, 3, 17, 14, 30, 5),
|
transcription_date=datetime(2026, 3, 17, 14, 30, 5),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert "# Транскрипт: meeting.mp4" in content
|
assert "# Транскрипт: meeting.mp4" in content
|
||||||
assert "**Дата транскрипции**: 2026-03-17 14:30:05" in content
|
assert "**Дата транскрипции**: 2026-03-17 14:30:05" in content
|
||||||
assert "**Модель**: large-v3" in content
|
assert "**Модель**: large-v3" in content
|
||||||
assert "**Язык**: ru (detected)" in content
|
assert "**Язык**: ru (определён автоматически)" in content
|
||||||
assert "**Длительность**: 02:00" in content
|
assert "**Длительность**: 02:00" in content
|
||||||
assert "**Устройство**: CUDA (NVIDIA GeForce RTX 3060)" in content
|
assert "**Устройство**: CUDA (NVIDIA GeForce RTX 3060)" in content
|
||||||
assert "---" in content
|
assert "---" in content
|
||||||
@@ -55,6 +59,27 @@ def test_format_transcript_basic():
|
|||||||
assert "[00:00.00 - 00:09.15] Добрый день, коллеги. Первый вопрос." in content
|
assert "[00:00.00 - 00:09.15] Добрый день, коллеги. Первый вопрос." 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():
|
def test_format_transcript_segment_no_leading_space():
|
||||||
"""Сегменты без ведущего пробела должны форматироваться корректно."""
|
"""Сегменты без ведущего пробела должны форматироваться корректно."""
|
||||||
result = TranscribeResult(
|
result = TranscribeResult(
|
||||||
@@ -69,7 +94,7 @@ def test_format_transcript_segment_no_leading_space():
|
|||||||
source_filename="f.mp3",
|
source_filename="f.mp3",
|
||||||
model_name="tiny",
|
model_name="tiny",
|
||||||
device_info="CPU",
|
device_info="CPU",
|
||||||
language_mode="detected",
|
language_mode=LANGUAGE_DETECTED,
|
||||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||||
)
|
)
|
||||||
assert "[00:00.00 - 00:02.00] Hello" in content
|
assert "[00:00.00 - 00:02.00] Hello" in content
|
||||||
@@ -88,7 +113,7 @@ def test_format_transcript_empty():
|
|||||||
source_filename="silence.wav",
|
source_filename="silence.wav",
|
||||||
model_name="tiny",
|
model_name="tiny",
|
||||||
device_info="CPU",
|
device_info="CPU",
|
||||||
language_mode="detected",
|
language_mode=LANGUAGE_DETECTED,
|
||||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -113,12 +138,12 @@ def test_format_transcript_long():
|
|||||||
source_filename="long.mp4",
|
source_filename="long.mp4",
|
||||||
model_name="large-v3",
|
model_name="large-v3",
|
||||||
device_info="CUDA",
|
device_info="CUDA",
|
||||||
language_mode="forced",
|
language_mode=LANGUAGE_FORCED,
|
||||||
transcription_date=datetime(2026, 3, 17, 10, 0, 0),
|
transcription_date=datetime(2026, 3, 17, 10, 0, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert "**Длительность**: 01:03:20" in content
|
assert "**Длительность**: 01:03:20" in content
|
||||||
assert "**Язык**: en (forced)" in content
|
assert "**Язык**: en (задан явно)" in content
|
||||||
# Timestamps should use hours format
|
# Timestamps should use hours format
|
||||||
assert "[00:00:00.00 - 00:00:10.50] Начало." in content
|
assert "[00:00:00.00 - 00:00:10.50] Начало." in content
|
||||||
assert "[01:01:40.00 - 01:01:50.25] Конец." in content
|
assert "[01:01:40.00 - 01:01:50.25] Конец." in content
|
||||||
@@ -188,7 +213,7 @@ def test_format_transcript_tail_gap_warning():
|
|||||||
source_filename="tail.mp3",
|
source_filename="tail.mp3",
|
||||||
model_name="medium",
|
model_name="medium",
|
||||||
device_info="CPU",
|
device_info="CPU",
|
||||||
language_mode="forced",
|
language_mode=LANGUAGE_FORCED,
|
||||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -210,7 +235,7 @@ def test_format_transcript_no_tail_gap_warning_for_small_gap():
|
|||||||
source_filename="ok.mp3",
|
source_filename="ok.mp3",
|
||||||
model_name="medium",
|
model_name="medium",
|
||||||
device_info="CPU",
|
device_info="CPU",
|
||||||
language_mode="forced",
|
language_mode=LANGUAGE_FORCED,
|
||||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -231,7 +256,7 @@ def test_format_transcript_no_tail_gap_warning_for_exact_threshold():
|
|||||||
source_filename="ok.mp3",
|
source_filename="ok.mp3",
|
||||||
model_name="medium",
|
model_name="medium",
|
||||||
device_info="CPU",
|
device_info="CPU",
|
||||||
language_mode="forced",
|
language_mode=LANGUAGE_FORCED,
|
||||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -257,7 +282,7 @@ def test_format_transcript_repetition_warning():
|
|||||||
source_filename="repeat.mp3",
|
source_filename="repeat.mp3",
|
||||||
model_name="medium",
|
model_name="medium",
|
||||||
device_info="CPU",
|
device_info="CPU",
|
||||||
language_mode="forced",
|
language_mode=LANGUAGE_FORCED,
|
||||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -284,7 +309,7 @@ def test_format_transcript_repetition_warning_uses_hours():
|
|||||||
source_filename="long-repeat.mp3",
|
source_filename="long-repeat.mp3",
|
||||||
model_name="medium",
|
model_name="medium",
|
||||||
device_info="CPU",
|
device_info="CPU",
|
||||||
language_mode="forced",
|
language_mode=LANGUAGE_FORCED,
|
||||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -305,7 +330,7 @@ def test_format_transcript_without_anomalies_has_no_warning_lines():
|
|||||||
source_filename="ok.mp3",
|
source_filename="ok.mp3",
|
||||||
model_name="medium",
|
model_name="medium",
|
||||||
device_info="CPU",
|
device_info="CPU",
|
||||||
language_mode="forced",
|
language_mode=LANGUAGE_FORCED,
|
||||||
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import warnings
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from local_transcriber.backends.onnx_asr import OnnxAsrBackend
|
from local_transcriber.backends.onnx_asr import OnnxAsrBackend
|
||||||
from local_transcriber.types import Segment, TranscribeResult
|
from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, TranscribeResult
|
||||||
|
|
||||||
|
|
||||||
class FakeVadSegment:
|
class FakeVadSegment:
|
||||||
@@ -390,7 +390,7 @@ class TestTranscribe:
|
|||||||
result = backend.transcribe(FakeModel(), wav_file, language=None)
|
result = backend.transcribe(FakeModel(), wav_file, language=None)
|
||||||
|
|
||||||
assert len(result.segments) == 0
|
assert len(result.segments) == 0
|
||||||
assert result.language == "unknown"
|
assert result.language == UNKNOWN_LANGUAGE
|
||||||
assert result.duration == 1.0
|
assert result.duration == 1.0
|
||||||
|
|
||||||
def test_transcribe_skips_zero_length_vad_segments(self, monkeypatch, tmp_path):
|
def test_transcribe_skips_zero_length_vad_segments(self, monkeypatch, tmp_path):
|
||||||
|
|||||||
Reference in New Issue
Block a user