feat(onnx): добавлен каталог моделей GigaAM

- Зачем:
  - добавлена локальная транскрипция смешанной речи и русского текста с пунктуацией.
- Что:
  - onnx-asr обновлён до 0.12 и зарегистрированы три модели GigaAM.
  - выбор compute_type учитывает опубликованные квантизации и явность настройки.
  - обновлены тесты, README и требования PRD.
- Проверка:
  - uv run pytest -q: 223 passed, 1 skipped.
  - выполнены smoke- и полные прогоны четырёх GigaAM-моделей.
This commit is contained in:
Dmitriy Dementiev
2026-08-11 13:11:18 +03:00
parent 3c519c6860
commit 0af2dbdd17
8 changed files with 177 additions and 27 deletions
+25 -5
View File
@@ -118,8 +118,11 @@ transcribe podcast.wav --model large-v3 --device openvino-gpu
# Максимальная скорость на CPU (русский) # Максимальная скорость на CPU (русский)
transcribe meeting.mp4 --device onnx --model gigaam-v3 transcribe meeting.mp4 --device onnx --model gigaam-v3
# CPU с пунктуацией (русский, для parakeet-v3 нужен явный язык) # CPU с пунктуацией и нормализацией русского текста
transcribe podcast.wav --device onnx --model parakeet-v3 --language ru 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 transcribe interview.m4a --output result.md
@@ -277,9 +280,26 @@ language = "en"
| Модель | Размер (int8) | RTFx CPU | Языки | Пунктуация | | Модель | Размер (int8) | RTFx CPU | Языки | Пунктуация |
|--------|--------------|----------|-------|-----------| |--------|--------------|----------|-------|-----------|
| `gigaam-v3` | ~300 MB | 17-29× | ru | ❌ | | `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 языков | ✅ | | `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` вместо подстановки завершится ошибкой.
</details> </details>
@@ -291,9 +311,9 @@ language = "en"
| `float16` | CUDA | ~4.5-5 GB | Отлично | **По умолчанию для CUDA** | | `float16` | CUDA | ~4.5-5 GB | Отлично | **По умолчанию для CUDA** |
| `int8_float16` | CUDA | ~4.7 GB | Отлично | GPU от 6 GB, альтернатива float16 | | `int8_float16` | CUDA | ~4.7 GB | Отлично | GPU от 6 GB, альтернатива float16 |
| `int8_float32` | CPU | Среднее | Отлично | **Рекомендуется для CPU** — 1.5x быстрее float32 при том же качестве | | `int8_float32` | CPU | Среднее | Отлично | **Рекомендуется для CPU** — 1.5x быстрее float32 при том же качестве |
| `int8` | CUDA / OpenVINO | Низкое | Хорошо, но бывают галлюцинации | **По умолчанию для OpenVINO** | | `int8` | CUDA / OpenVINO / ONNX | Низкое | Хорошо, но бывают галлюцинации | **По умолчанию для OpenVINO и ONNX** |
| `fp16` | OpenVINO | Низкое | Отлично | OpenVINO large-v3 (выбирается автоматически) | | `fp16` | OpenVINO | Низкое | Отлично | OpenVINO large-v3 (выбирается автоматически) |
| `float32` | CPU | Среднее | Отлично | **По умолчанию для CPU** | | `float32` | CPU / ONNX | Среднее | Отлично | **По умолчанию для CPU** |
**Важно:** `int8` на длинных записях может давать галлюцинации (повтор фраз, потеря контента). **Важно:** `int8` на длинных записях может давать галлюцинации (повтор фраз, потеря контента).
`float16`/`fp16` и `float32` значительно стабильнее на записях >20 минут. `float16`/`fp16` и `float32` значительно стабильнее на записях >20 минут.
+2 -2
View File
@@ -118,7 +118,7 @@ transcribe <путь_к_файлу> [опции]
### 4.2. Требования к окружению ### 4.2. Требования к окружению
- Python ≥ 3.10 - Python ≥ 3.13
- ffmpeg в PATH (используется faster-whisper внутри для декодирования любых медиаформатов) - ffmpeg в PATH (используется faster-whisper внутри для декодирования любых медиаформатов)
- Для GPU: Linux/WSL2 — cuBLAS из nvidia-cublas-cu12 (ставится автоматически через `uv sync`); Windows — системный CUDA toolkit (см. ADR-001) - Для GPU: Linux/WSL2 — cuBLAS из nvidia-cublas-cu12 (ставится автоматически через `uv sync`); Windows — системный CUDA toolkit (см. ADR-001)
- Дисковое пространство для моделей: ~3 GB (large-v3) - Дисковое пространство для моделей: ~3 GB (large-v3)
@@ -134,7 +134,7 @@ transcribe <путь_к_файлу> [опции]
| Компонент | Технология | | Компонент | Технология |
|---------------------|-------------------------------------------------| |---------------------|-------------------------------------------------|
| Язык | Python 3.10+ | | Язык | Python 3.13+ |
| Управление проектом | uv (pyproject.toml) | | Управление проектом | uv (pyproject.toml) |
| Распознавание речи | faster-whisper (CTranslate2 backend) | | Распознавание речи | faster-whisper (CTranslate2 backend) |
| Медиа-декодирование | ffmpeg (системная зависимость, используется faster-whisper внутри) | | Медиа-декодирование | ffmpeg (системная зависимость, используется faster-whisper внутри) |
+1 -1
View File
@@ -11,7 +11,7 @@ dependencies = [
"socksio>=1.0.0,<2", "socksio>=1.0.0,<2",
"nvidia-cublas-cu12>=12.4,<13; sys_platform == 'linux' and platform_machine == 'x86_64'", "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')", "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", "onnxruntime>=1.28,<2",
] ]
+1 -1
View File
@@ -32,7 +32,7 @@ def get_backend(device: str, *, compute_type_explicit: bool = True) -> Backend:
raise ValueError( raise ValueError(
"onnx-asr бэкенд недоступен. Установите: pip install onnx-asr[cpu,hub]" "onnx-asr бэкенд недоступен. Установите: pip install onnx-asr[cpu,hub]"
) from None ) from None
return OnnxAsrBackend() return OnnxAsrBackend(compute_type_explicit=compute_type_explicit)
# cuda, cpu и всё остальное → faster-whisper # cuda, cpu и всё остальное → faster-whisper
from .faster_whisper import FasterWhisperBackend from .faster_whisper import FasterWhisperBackend
+77 -6
View File
@@ -3,14 +3,46 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
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 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] = { MODEL_ALIASES: dict[str, str] = {
"gigaam-v3": "gigaam-v3-ctc", alias: spec.model_id for alias, spec in MODEL_CATALOG.items()
"parakeet-v3": "nemo-parakeet-tdt-0.6b-v3",
} }
SUPPORTED_ALIASES = ", ".join(MODEL_ALIASES) SUPPORTED_ALIASES = ", ".join(MODEL_ALIASES)
@@ -45,7 +77,8 @@ def _normalize_quantization(compute_type: str) -> str | None:
class OnnxAsrBackend: class OnnxAsrBackend:
"""Бэкенд транскрипции через onnx-asr (ONNX Runtime).""" """Бэкенд транскрипции через 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.actual_compute_type: str | None = None
self._resolved_model_id: str | None = None self._resolved_model_id: str | None = None
self._vad: Any = None self._vad: Any = None
@@ -61,7 +94,26 @@ class OnnxAsrBackend:
onnx-asr downloads models automatically via load_model(), onnx-asr downloads models automatically via load_model(),
so this just validates the alias and returns the identifier string. 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) self._resolved_model_id = self._resolve_model(model_name)
return self._resolved_model_id return self._resolved_model_id
@@ -79,7 +131,8 @@ class OnnxAsrBackend:
""" """
import onnx_asr 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 = onnx_asr.load_model(
model=model_path, model=model_path,
@@ -113,7 +166,9 @@ class OnnxAsrBackend:
segments: list[Segment] = [] segments: list[Segment] = []
detected_language = language or "unknown" 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( seg = Segment(
start=max(0.0, vad_seg.start), start=max(0.0, vad_seg.start),
end=max(0.0, vad_seg.end), 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: def _notify(on_status: Callable[[str], None] | None, message: str) -> None:
if on_status is not None: if on_status is not None:
on_status(message) on_status(message)
+10 -3
View File
@@ -1,10 +1,9 @@
"""Загрузка конфигурации из ``.transcriber.toml`` и каскад приоритетов.""" """Загрузка конфигурации из ``.transcriber.toml`` и каскад приоритетов."""
import tomllib
import warnings import warnings
from pathlib import Path from pathlib import Path
import tomllib
HARDCODED_DEFAULTS: dict[str, str] = { HARDCODED_DEFAULTS: dict[str, str] = {
"model": "medium", "model": "medium",
"language": "ru", "language": "ru",
@@ -23,7 +22,15 @@ DEVICE_DEFAULTS: dict[str, dict[str, str]] = {
# Одно место правды для допустимых ключей конфига # Одно место правды для допустимых ключей конфига
_VALID_KEYS = set(HARDCODED_DEFAULTS) _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: def find_config_file() -> Path | None:
+57 -5
View File
@@ -1,9 +1,8 @@
"""Tests for onnx-asr backend.""" """Tests for onnx-asr backend."""
import pytest 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 from local_transcriber.types import Segment, TranscribeResult
@@ -24,7 +23,7 @@ class TestEnsureModelAvailable:
def test_returns_model_id_for_parakeet(self): def test_returns_model_id_for_parakeet(self):
backend = OnnxAsrBackend() 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" assert result == "nemo-parakeet-tdt-0.6b-v3"
def test_stores_compute_type(self): def test_stores_compute_type(self):
@@ -33,6 +32,51 @@ class TestEnsureModelAvailable:
assert backend._resolved_model_id == "gigaam-v3-ctc" assert backend._resolved_model_id == "gigaam-v3-ctc"
assert backend.actual_compute_type == "float32" 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: class TestCreateModel:
def test_calls_load_model_with_correct_args(self, monkeypatch): 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) monkeypatch.setattr("onnx_asr.load_vad", fake_load_vad)
backend = OnnxAsrBackend() backend = OnnxAsrBackend()
model = backend.create_model("gigaam-v3-ctc", "onnx", "int8") backend.create_model("gigaam-v3-ctc", "onnx", "int8")
assert vad_calls == ["silero"] assert vad_calls == ["silero"]
@@ -228,7 +272,7 @@ class TestTranscribe:
monkeypatch.setattr("faster_whisper.decode_audio", fake_decode_audio) monkeypatch.setattr("faster_whisper.decode_audio", fake_decode_audio)
backend = OnnxAsrBackend() backend = OnnxAsrBackend()
result = backend.transcribe( backend.transcribe(
FakeModel(), wav_file, language=None, FakeModel(), wav_file, language=None,
on_segment=lambda s: segments_captured.append(s), on_segment=lambda s: segments_captured.append(s),
) )
@@ -291,6 +335,14 @@ class TestBackendRegistration:
backend = get_backend("onnx") backend = get_backend("onnx")
assert isinstance(backend, OnnxAsrBackend) 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: class TestModelAliases:
def test_gigaam_v3_resolves(self): def test_gigaam_v3_resolves(self):
Generated
+4 -4
View File
@@ -277,7 +277,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "faster-whisper", specifier = ">=1.2.1,<2" }, { 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 = "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 = "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 = "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" }, { name = "rich", specifier = ">=14.3.3,<15" },
@@ -369,14 +369,14 @@ wheels = [
[[package]] [[package]]
name = "onnx-asr" name = "onnx-asr"
version = "0.11.0" version = "0.12.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "numpy" }, { 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 = [ 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] [package.optional-dependencies]