diff --git a/README.md b/README.md index af2220a..522d848 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,11 @@ transcribe podcast.wav --model large-v3 --device openvino-gpu # Максимальная скорость на CPU (русский) transcribe meeting.mp4 --device onnx --model gigaam-v3 -# CPU с пунктуацией (русский, для parakeet-v3 нужен явный язык) -transcribe podcast.wav --device onnx --model parakeet-v3 --language ru +# CPU с пунктуацией и нормализацией русского текста +transcribe podcast.wav --device onnx --model gigaam-v3-e2e-ctc + +# Смешанная русско-английская речь +transcribe meeting.wav --device onnx --model gigaam-multilingual-ctc # Сохранить в конкретный файл transcribe interview.m4a --output result.md @@ -277,9 +280,26 @@ language = "en" | Модель | Размер (int8) | RTFx CPU | Языки | Пунктуация | |--------|--------------|----------|-------|-----------| | `gigaam-v3` | ~300 MB | 17-29× | ru | ❌ | +| `gigaam-multilingual-ctc` | ~300 MB | не измерено | ru, en, kk, ky, uz | ❌ | +| `gigaam-v3-e2e-ctc` | ~300 MB | не измерено | ru | ✅ | +| `gigaam-v3-e2e-rnnt` | ~300 MB | не измерено | ru | ✅ | | `parakeet-v3` | ~600 MB | 12-20× | 25 языков | ✅ | -> **Рекомендация**: для русского — `gigaam-v3` (единственный из onnx-моделей, дающий пригодный для конспекта транскрипт на русских встречах; см. [ADR-006](docs/adr/006-onnx-asr-backend.md)). `parakeet-v3` уместен только для англоязычного / multilingual контента — на русском воспроизводит проблемы из [ADR-005](docs/adr/005-parakeet-evaluation.md) (Mm-hmm-редукция тихих реплик, иноязычные вставки). +> **Рекомендация**: для русского по-прежнему используйте проверенный `gigaam-v3` +> (см. [ADR-006](docs/adr/006-onnx-asr-backend.md)). E2E-модели добавляют +> пунктуацию и нормализацию, но их скорость на слабых CPU не измерялась; +> `gigaam-v3-e2e-rnnt` декодирует последовательно и особенно медленна на длинных +> записях. `parakeet-v3` на русском воспроизводит проблемы из +> [ADR-005](docs/adr/005-parakeet-evaluation.md). + +GigaAM Multilingual сама распознаёт русский, английский, казахский, кыргызский и +узбекский внутри одной записи. `onnx-asr` не передаёт этой модели подсказку +языка, поэтому `--language` не управляет её выбором языка. + +Для моделей из таблицы опубликованы `int8` и `float32`. Если неявный +device-aware дефолт недоступен для выбранной модели, CLI сообщит о подстановке +доступного варианта. Явное значение из `--compute-type` или +`.transcriber.toml` вместо подстановки завершится ошибкой. @@ -291,9 +311,9 @@ language = "en" | `float16` | CUDA | ~4.5-5 GB | Отлично | **По умолчанию для CUDA** | | `int8_float16` | CUDA | ~4.7 GB | Отлично | GPU от 6 GB, альтернатива float16 | | `int8_float32` | CPU | Среднее | Отлично | **Рекомендуется для CPU** — 1.5x быстрее float32 при том же качестве | -| `int8` | CUDA / OpenVINO | Низкое | Хорошо, но бывают галлюцинации | **По умолчанию для OpenVINO** | +| `int8` | CUDA / OpenVINO / ONNX | Низкое | Хорошо, но бывают галлюцинации | **По умолчанию для OpenVINO и ONNX** | | `fp16` | OpenVINO | Низкое | Отлично | OpenVINO large-v3 (выбирается автоматически) | -| `float32` | CPU | Среднее | Отлично | **По умолчанию для CPU** | +| `float32` | CPU / ONNX | Среднее | Отлично | **По умолчанию для CPU** | **Важно:** `int8` на длинных записях может давать галлюцинации (повтор фраз, потеря контента). `float16`/`fp16` и `float32` значительно стабильнее на записях >20 минут. diff --git a/docs/PRD.md b/docs/PRD.md index fcd3c4f..f83bbc0 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -118,7 +118,7 @@ transcribe <путь_к_файлу> [опции] ### 4.2. Требования к окружению -- Python ≥ 3.10 +- Python ≥ 3.13 - ffmpeg в PATH (используется faster-whisper внутри для декодирования любых медиаформатов) - Для GPU: Linux/WSL2 — cuBLAS из nvidia-cublas-cu12 (ставится автоматически через `uv sync`); Windows — системный CUDA toolkit (см. ADR-001) - Дисковое пространство для моделей: ~3 GB (large-v3) @@ -134,7 +134,7 @@ transcribe <путь_к_файлу> [опции] | Компонент | Технология | |---------------------|-------------------------------------------------| -| Язык | Python 3.10+ | +| Язык | Python 3.13+ | | Управление проектом | uv (pyproject.toml) | | Распознавание речи | faster-whisper (CTranslate2 backend) | | Медиа-декодирование | ffmpeg (системная зависимость, используется faster-whisper внутри) | diff --git a/pyproject.toml b/pyproject.toml index dfe33d7..6a3f7af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "socksio>=1.0.0,<2", "nvidia-cublas-cu12>=12.4,<13; sys_platform == 'linux' and platform_machine == 'x86_64'", "openvino-genai>=2026.0.0.0,<2026.1; sys_platform != 'darwin' and (platform_machine == 'x86_64' or platform_machine == 'AMD64')", - "onnx-asr[cpu,hub]>=0.11.0,<0.12.0", + "onnx-asr[cpu,hub]>=0.12,<0.13", "onnxruntime>=1.28,<2", ] diff --git a/src/local_transcriber/backends/__init__.py b/src/local_transcriber/backends/__init__.py index 99fcfa3..ab744b8 100644 --- a/src/local_transcriber/backends/__init__.py +++ b/src/local_transcriber/backends/__init__.py @@ -32,7 +32,7 @@ def get_backend(device: str, *, compute_type_explicit: bool = True) -> Backend: raise ValueError( "onnx-asr бэкенд недоступен. Установите: pip install onnx-asr[cpu,hub]" ) from None - return OnnxAsrBackend() + return OnnxAsrBackend(compute_type_explicit=compute_type_explicit) # cuda, cpu и всё остальное → faster-whisper from .faster_whisper import FasterWhisperBackend diff --git a/src/local_transcriber/backends/onnx_asr.py b/src/local_transcriber/backends/onnx_asr.py index f3d7b87..dece5a6 100644 --- a/src/local_transcriber/backends/onnx_asr.py +++ b/src/local_transcriber/backends/onnx_asr.py @@ -3,14 +3,46 @@ from __future__ import annotations from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path from typing import Any from local_transcriber.types import Segment, TranscribeResult + +@dataclass(frozen=True) +class OnnxModelSpec: + """Имя onnx-asr и опубликованные варианты квантизации модели.""" + + model_id: str + quantizations: frozenset[str | None] + + +_INT8_AND_FLOAT32 = frozenset({"int8", None}) + +MODEL_CATALOG: dict[str, OnnxModelSpec] = { + "gigaam-v3": OnnxModelSpec("gigaam-v3-ctc", _INT8_AND_FLOAT32), + "parakeet-v3": OnnxModelSpec( + "nemo-parakeet-tdt-0.6b-v3", + _INT8_AND_FLOAT32, + ), + "gigaam-multilingual-ctc": OnnxModelSpec( + "gigaam-multilingual-ctc", + _INT8_AND_FLOAT32, + ), + "gigaam-v3-e2e-ctc": OnnxModelSpec( + "gigaam-v3-e2e-ctc", + _INT8_AND_FLOAT32, + ), + "gigaam-v3-e2e-rnnt": OnnxModelSpec( + "gigaam-v3-e2e-rnnt", + _INT8_AND_FLOAT32, + ), +} + +# Оставлено как совместимое представление публичного каталога алиасов. MODEL_ALIASES: dict[str, str] = { - "gigaam-v3": "gigaam-v3-ctc", - "parakeet-v3": "nemo-parakeet-tdt-0.6b-v3", + alias: spec.model_id for alias, spec in MODEL_CATALOG.items() } SUPPORTED_ALIASES = ", ".join(MODEL_ALIASES) @@ -45,7 +77,8 @@ def _normalize_quantization(compute_type: str) -> str | None: class OnnxAsrBackend: """Бэкенд транскрипции через onnx-asr (ONNX Runtime).""" - def __init__(self): + def __init__(self, compute_type_explicit: bool = True): + self._compute_type_explicit = compute_type_explicit self.actual_compute_type: str | None = None self._resolved_model_id: str | None = None self._vad: Any = None @@ -61,7 +94,26 @@ class OnnxAsrBackend: onnx-asr downloads models automatically via load_model(), so this just validates the alias and returns the identifier string. """ - self.actual_compute_type = compute_type + spec = MODEL_CATALOG.get(model_name) + quantization = _normalize_quantization(compute_type) + if spec is not None and quantization not in spec.quantizations: + if self._compute_type_explicit: + available = _format_compute_types(spec.quantizations) + raise ValueError( + f"Модель '{model_name}' недоступна с compute_type='{compute_type}' " + f"для onnx-asr. Доступные варианты: {available}" + ) + + resolved_compute_type = _preferred_compute_type(spec.quantizations) + _notify( + on_status, + f"Модель {model_name} недоступна с compute_type={compute_type}; " + f"использую {resolved_compute_type}.", + ) + else: + resolved_compute_type = _compute_type_for_quantization(quantization) + + self.actual_compute_type = resolved_compute_type self._resolved_model_id = self._resolve_model(model_name) return self._resolved_model_id @@ -79,7 +131,8 @@ class OnnxAsrBackend: """ import onnx_asr - quantization = _normalize_quantization(compute_type) + actual_compute_type = self.actual_compute_type or compute_type + quantization = _normalize_quantization(actual_compute_type) model = onnx_asr.load_model( model=model_path, @@ -113,7 +166,9 @@ class OnnxAsrBackend: segments: list[Segment] = [] detected_language = language or "unknown" - for vad_seg in model.recognize(audio_array, sample_rate=16000, language=language): + for vad_seg in model.recognize( + audio_array, sample_rate=16000, language=language + ): seg = Segment( start=max(0.0, vad_seg.start), end=max(0.0, vad_seg.end), @@ -149,6 +204,22 @@ class OnnxAsrBackend: ) +def _format_compute_types(quantizations: frozenset[str | None]) -> str: + values = [_compute_type_for_quantization(value) for value in quantizations] + return ", ".join(sorted(values)) + + +def _compute_type_for_quantization(quantization: str | None) -> str: + return "float32" if quantization is None else quantization + + +def _preferred_compute_type(quantizations: frozenset[str | None]) -> str: + for quantization in ("int8", None, "fp16"): + if quantization in quantizations: + return _compute_type_for_quantization(quantization) + raise ValueError("Для ONNX-модели не указаны доступные квантизации") + + def _notify(on_status: Callable[[str], None] | None, message: str) -> None: if on_status is not None: on_status(message) diff --git a/src/local_transcriber/config.py b/src/local_transcriber/config.py index 44062ea..adfc801 100644 --- a/src/local_transcriber/config.py +++ b/src/local_transcriber/config.py @@ -1,10 +1,9 @@ """Загрузка конфигурации из ``.transcriber.toml`` и каскад приоритетов.""" +import tomllib import warnings from pathlib import Path -import tomllib - HARDCODED_DEFAULTS: dict[str, str] = { "model": "medium", "language": "ru", @@ -23,7 +22,15 @@ DEVICE_DEFAULTS: dict[str, dict[str, str]] = { # Одно место правды для допустимых ключей конфига _VALID_KEYS = set(HARDCODED_DEFAULTS) -_VALID_DEVICES = {"auto", "cpu", "cuda", "openvino", "openvino-gpu", "openvino-cpu", "onnx"} +_VALID_DEVICES = { + "auto", + "cpu", + "cuda", + "openvino", + "openvino-gpu", + "openvino-cpu", + "onnx", +} def find_config_file() -> Path | None: diff --git a/tests/test_onnx_asr.py b/tests/test_onnx_asr.py index ff192eb..68938be 100644 --- a/tests/test_onnx_asr.py +++ b/tests/test_onnx_asr.py @@ -1,9 +1,8 @@ """Tests for onnx-asr backend.""" import pytest -from pathlib import Path -from local_transcriber.backends.onnx_asr import OnnxAsrBackend, MODEL_ALIASES +from local_transcriber.backends.onnx_asr import OnnxAsrBackend from local_transcriber.types import Segment, TranscribeResult @@ -24,7 +23,7 @@ class TestEnsureModelAvailable: def test_returns_model_id_for_parakeet(self): backend = OnnxAsrBackend() - result = backend.ensure_model_available("parakeet-v3", "fp16") + result = backend.ensure_model_available("parakeet-v3", "int8") assert result == "nemo-parakeet-tdt-0.6b-v3" def test_stores_compute_type(self): @@ -33,6 +32,51 @@ class TestEnsureModelAvailable: assert backend._resolved_model_id == "gigaam-v3-ctc" assert backend.actual_compute_type == "float32" + @pytest.mark.parametrize( + "model_name", + [ + "gigaam-multilingual-ctc", + "gigaam-v3-e2e-ctc", + "gigaam-v3-e2e-rnnt", + ], + ) + def test_explicit_unavailable_compute_type_is_rejected(self, model_name): + backend = OnnxAsrBackend(compute_type_explicit=True) + + with pytest.raises(ValueError, match="недоступна с compute_type='fp16'"): + backend.ensure_model_available(model_name, "fp16") + + def test_implicit_unavailable_compute_type_falls_back_and_reports( + self, monkeypatch + ): + quantizations = [] + statuses = [] + + class FakeAsrAdapter: + def with_vad(self, vad): + return self + + def fake_load_model(*, model, quantization): + quantizations.append(quantization) + return FakeAsrAdapter() + + monkeypatch.setattr("onnx_asr.load_model", fake_load_model) + monkeypatch.setattr("onnx_asr.load_vad", lambda model: None) + + backend = OnnxAsrBackend(compute_type_explicit=False) + model_id = backend.ensure_model_available( + "gigaam-v3-e2e-ctc", + "fp16", + on_status=statuses.append, + ) + backend.create_model(model_id, "onnx", "fp16") + + assert backend.actual_compute_type == "int8" + assert quantizations == ["int8"] + assert statuses == [ + "Модель gigaam-v3-e2e-ctc недоступна с compute_type=fp16; использую int8." + ] + class TestCreateModel: def test_calls_load_model_with_correct_args(self, monkeypatch): @@ -80,7 +124,7 @@ class TestCreateModel: monkeypatch.setattr("onnx_asr.load_vad", fake_load_vad) backend = OnnxAsrBackend() - model = backend.create_model("gigaam-v3-ctc", "onnx", "int8") + backend.create_model("gigaam-v3-ctc", "onnx", "int8") assert vad_calls == ["silero"] @@ -228,7 +272,7 @@ class TestTranscribe: monkeypatch.setattr("faster_whisper.decode_audio", fake_decode_audio) backend = OnnxAsrBackend() - result = backend.transcribe( + backend.transcribe( FakeModel(), wav_file, language=None, on_segment=lambda s: segments_captured.append(s), ) @@ -291,6 +335,14 @@ class TestBackendRegistration: backend = get_backend("onnx") assert isinstance(backend, OnnxAsrBackend) + def test_get_backend_preserves_implicit_compute_type(self): + from local_transcriber.backends import get_backend + + backend = get_backend("onnx", compute_type_explicit=False) + backend.ensure_model_available("gigaam-v3-e2e-rnnt", "fp16") + + assert backend.actual_compute_type == "int8" + class TestModelAliases: def test_gigaam_v3_resolves(self): diff --git a/uv.lock b/uv.lock index b5b5bed..e350d05 100644 --- a/uv.lock +++ b/uv.lock @@ -277,7 +277,7 @@ dev = [ requires-dist = [ { name = "faster-whisper", specifier = ">=1.2.1,<2" }, { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=12.4,<13" }, - { name = "onnx-asr", extras = ["cpu", "hub"], specifier = ">=0.11.0,<0.12.0" }, + { name = "onnx-asr", extras = ["cpu", "hub"], specifier = ">=0.12,<0.13" }, { name = "onnxruntime", specifier = ">=1.28,<2" }, { name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')", specifier = ">=2026.0.0.0,<2026.1" }, { name = "rich", specifier = ">=14.3.3,<15" }, @@ -369,14 +369,14 @@ wheels = [ [[package]] name = "onnx-asr" -version = "0.11.0" +version = "0.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/f6/b154881761a593312f509522f99542acffa2516f7a1df6ddf5660ad4a162/onnx_asr-0.11.0.tar.gz", hash = "sha256:57ad8d9571dc17db95f0daf9ba432b9472383de320c610735850e56b5375a37d", size = 43665, upload-time = "2026-03-23T02:30:57.349Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/11/136f556cda02ecc172674dcea79232e9e84e74b951bbcd2e2722117f633b/onnx_asr-0.12.0.tar.gz", hash = "sha256:c1fcacddbced392f9f769ed5c6223f4ce739a6e4549346de33b9b520d4ead713", size = 45991, upload-time = "2026-07-15T00:09:58.852Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/04/bdffd682cc38b43144b6528186c80451f219a05e3fd0eb331a548f455b9a/onnx_asr-0.11.0-py3-none-any.whl", hash = "sha256:142d8b3ce7716684992826a269304f5ce9cf1c0fe704b751358e223f45d2a5cf", size = 138349, upload-time = "2026-03-23T02:30:58.566Z" }, + { url = "https://files.pythonhosted.org/packages/6a/60/2fa469a2ee674c35ab48821a1039762ae7b9d0b88188ac1012e779477f76/onnx_asr-0.12.0-py3-none-any.whl", hash = "sha256:5e7ceca454609819ea7833f61e2302e0c8f6ece4f8a78b66c5daba53cb51de4a", size = 3980006, upload-time = "2026-07-15T00:09:57.322Z" }, ] [package.optional-dependencies]