
Кому лень читать полностью
Я реализовал 4 модели распознавания речи (Whisper, Qwen3-ASR, GigaAM, Parakeet) на чистом Rust через Candle — 12 000 строк кода, zero Python-зависимостей в runtime, поддержка Metal GPU, GGUF-квантизация, VAD и диаризация. RTF от 0.017 (GigaAM) до 0.11 (Whisper) на Apple Silicon.
Привет, Хабр! В предыдущей статье я рассказывал, как портировал модель синтеза речи Qwen3-TTS на Rust. Тот проект (RustTTS) получился достаточно успешным — один бинарник, мгновенный старт, никаких Python-зависимостей.
Естественным продолжением стала обратная задача — распознавание речи (ASR, Automatic Speech Recognition). Логика казалась простой: у Qwen есть и TTS и ASR, архитектуры похожи, опыт с Candle уже есть, значит справимся за пару недель. Ну... не совсем.
Предыстория: почему одной модели недостаточно
Начав с Qwen3-ASR — мультиязычной модели от Alibaba — я довольно быстро реализовал полный пайплайн: mel-спектрограммы, AuT-энкодер, Qwen3 LLM-декодер. Технически всё работало. Но когда дело дошло до качества на русском языке, я был разочарован.
Вот реальный пример. Оригинальная фраза из разговора:
«...по результатам изучения документации пятничной встречи с Алиной возникло три блока вопросов...»
Что выдал Qwen3-ASR 0.6B:
«...по результатам изучения документации пятничной встречи с Олиной возникло три блок вопросов...»
А фраза «...на витрине партнёра...» превратилась в «...на витрине для спортсменов...». «Виджет» стал «видеото». Пунктуация отсутствовала вовсе.
Увеличение модели до 1.7B параметров ситуацию улучшило (★★★★☆ вместо ★★★☆☆), но до «production-ready» качества было далеко. Тогда я принял решение, которое определило архитектуру всего проекта: поддержать несколько моделей с единым API.
Какие модели и почему
Изучив ландшафт ASR-моделей, я отобрал четыре принципиально разные архитектуры:
Модель | Параметры | Архитектура | Языки | Почему выбрана |
|---|---|---|---|---|
Whisper Large v3 Turbo | ~809M | Encoder-Decoder (Transformer) | 99 языков | Эталон качества, уже есть в candle-transformers |
GigaAM v3 E2E CTC | ~220M | Conformer + CTC | Русский | Лучшее качество на русском, минимальный размер |
Parakeet TDT v3 | ~627M | FastConformer + TDT | 25 языков | NVIDIA, SOTA на английском, уникальный декодер |
Qwen3-ASR | 0.6B / 1.7B | AuT Encoder + Qwen3 LLM | Мультиязычная | Начальная цель проекта |
Каждая модель — это отдельная ASR-архитектура со своим энкодером, декодером, форматом mel-спектрограмм и способом декодирования. Реализовать все четыре в одном проекте — нетривиальная инженерная задача.
Архитектура проекта
Проект организован как Cargo Workspace из 12 крейтов:
rustasr/ ├── crates/ │ ├── asr-core/ # Базовые типы, ошибки, trait AsrModel │ ├── audio/ # WAV, ресемплинг, mel-спектрограммы │ ├── aut-encoder/ # AuT энкодер (Qwen3-ASR) │ ├── qwen3-decoder/ # Qwen3 LLM декодер │ ├── asr-pipeline/ # E2E пайплайн Qwen3-ASR │ ├── model-qwen3/ # Qwen3 → AsrModel адаптер │ ├── model-whisper/ # Whisper → AsrModel │ ├── model-gigaam/ # GigaAM → AsrModel │ ├── model-parakeet/ # Parakeet → AsrModel │ ├── asr-engine/ # Фасад-диспетчер │ └── asr-cli/ # CLI-приложение ├── models/ # Локальные веса (не в git) └── scripts/ # Утилиты конвертации
Общий объём: ~12 000 строк Rust-кода в 55 файлах.
Единый trait AsrModel
Ключевое архитектурное решение — все модели реализуют один trait:
pub trait AsrModel: Send { fn name(&self) -> &str; fn model_type(&self) -> ModelType; fn sample_rate(&self) -> u32 { 16_000 } fn supported_languages(&self) -> &[&str]; fn model_info(&self) -> ModelInfo; fn transcribe( &mut self, samples: &[f32], options: &TranscribeOptions, ) -> AsrResult<TranscriptionResult>; }
Потребитель API не знает, из какой именно модели пришёл результат — интерфейс единообразный:
use asr_engine::AsrEngine; use asr_core::{ModelType, TranscribeOptions}; let mut engine = AsrEngine::load( ModelType::Whisper, "models/whisper-large-v3-turbo", &device, )?; let result = engine.transcribe(&samples, &TranscribeOptions { language: Some("ru".into()), ..Default::default() })?; println!("{}", result.text);
Feature gates
Модели компилируются условно — можно собрать бинарь только с нужными:
[features] default = ["whisper", "gigaam", "parakeet", "qwen3"] whisper = ["dep:model-whisper"] gigaam = ["dep:model-gigaam"] parakeet = ["dep:model-parakeet"] qwen3 = ["dep:model-qwen3"]
Если вам нужен только Whisper — соберите с --no-default-features --features whisper и получите бинарь меньшего размера.
Три mel-спектрограммы — три мира
Самый неожиданный факт этого проекта: каждая модель требует свою mel-спектрограмму. Не просто "другое количество mel-бинов". Различается всё:
Параметр | Whisper | GigaAM | Parakeet | Qwen3-ASR |
|---|---|---|---|---|
Mel bins | 128 | 64 | 80 | 128 |
n_fft | 400 | 512 | 512 | 400 |
hop_length | 160 | 160 | 160 | 160 |
Mel scale | Slaney | HTK | Из весов | Slaney |
Логарифм | log₁₀ | ln | ln | log₁₀ |
Center padding | ✅ | ❌ | ✅ | ✅ |
Нормализация | Dynamic Range | None | Per-Utterance | Dynamic Range |
Чтобы не дублировать код, я создал параметризованную конфигурацию FeatureExtractorConfig:
pub struct FeatureExtractorConfig { pub n_fft: usize, pub hop_length: usize, pub n_mels: usize, pub sample_rate: u32, pub mel_scale: MelScale, // Slaney | HTK pub log_type: MelLogType, // Log10 | Ln pub normalization: MelNorm, // WhisperDynamicRange | PerUtterance | None pub center: bool, }
Одна структура покрывает все 4 модели. STFT реализован через rustfft с reflect-padding, совместимым с torch.stft(center=True).
Модели: как это устроено изнутри
Whisper Large v3 Turbo
Самая «простая» интеграция — candle-transformers уже содержит реализацию Whisper. Я добавил обёртку над ней:
enum InnerModel { Normal(whisper::model::Whisper), Quantized(whisper::quantized_model::Whisper), }
Ключевая тонкость — temperature fallback: если при temperature=0 модель генерирует мусор (высокий compression_ratio или низкий avg_logprob), автоматически пробуются более высокие температуры [0.2, 0.4, 0.6, 0.8, 1.0].
GigaAM v3 E2E CTC
GigaAM от Сбера — специализированная модель для русского языка. Conformer-архитектура: 16 слоёв, 768-мерное пространство, 12 голов внимания.
Нативной реализации Conformer на Candle не существовало. Пришлось писать с нуля:
// Macaron-style Conformer block: // FFN₁(×0.5) → MHSA + RoPE → DepthwiseConv → FFN₂(×0.5) → LayerNorm pub struct ConformerBlock { ffn1: ConformerFeedForward, // SiLU activation mhsa: RotaryMHSA, // Multi-Head Self-Attention + RoPE conv: ConformerConvolution, // Pointwise → GLU → Depthwise(k=31) → BN → SiLU → Pointwise ffn2: ConformerFeedForward, norm: LayerNorm, }
Интересная деталь: GigaAM применяет RoPE (Rotary Position Embedding) до линейных проекций Q/K — нестандартный порядок, который я обнаружил только при послойном сравнении тензоров с Python.
Декодер — простой CTC-greedy: argmax → удаление blanks и дублей → SentencePiece detokenize. Именно простота декодера делает GigaAM невероятно быстрой.
Parakeet TDT v3
Самая сложная модель в проекте — 2 085 строк Rust-кода. FastConformer-энкодер (24 слоя, ×8 субдискретизация) + LSTM Prediction Network + Joint Network + TDT-декодер.
Две вещи, которые пришлось реализовать вручную:
1. LSTM — Candle не имеет встроенной реализации LSTM. Пришлось писать на уровне тензорных операций:
// LSTM cell: gates = x·Wih + h·Whh + bias let gates = x_proj.broadcast_add(&h_proj)?; let chunks = gates.chunk(4, D::Minus1)?; // i, f, g, o let i_gate = candle_nn::ops::sigmoid(&chunks[0])?; let f_gate = candle_nn::ops::sigmoid(&chunks[1])?; let g_gate = chunks[2].tanh()?; let o_gate = candle_nn::ops::sigmoid(&chunks[3])?; // c_new = f * c_old + i * g let c_new = (f_gate * c_old)?.broadcast_add(&(i_gate * g_gate)?)?; let h_new = (o_gate * c_new.tanh()?)?;
2. TDT (Token-and-Duration Transducer) — в отличие от стандартного RNN-T, TDT предсказывает не только токен, но и длительность (сколько фреймов перескочить). Это даёт ускорение ~2.8× по сравнению с обычным RNNT, но усложняет декодер:
// TDT: предсказываем (token, duration) let (token_logits, duration_logits) = joint_network.forward( &encoder_out, &prediction_out )?; let token = token_logits.argmax()?; let duration = duration_logits.argmax()?; // Если не blank — добавляем токен, перескакиваем `duration` фреймов if token != blank_id { output.push(token); frame_idx += DURATIONS[duration]; // [1, 2, 4, 8] }
Qwen3-ASR
Архитектура, с которой всё начиналось. AuT (Attention-based Audio Transformer) — это по сути обычный Transformer-энкодер с Conv2D-субдискретизацией на входе (×8 сжатие по времени). Выход энкодера проецируется в пространство Qwen3 LLM, и декодер авторегрессивно генерирует текст.
Именно LLM-природа декодера делает Qwen3-ASR самой медленной из четырёх моделей на коротких записях, но потенциально самой умной — модель может использовать языковой контекст для расшифровки неоднозначных мест.
Проблемы и их решения
Проблема 1: Различия в Mel-фильтрах
Симптом: GigaAM выдаёт мусор.
Причина: Я использовал Slaney mel-scale (как для Whisper), а GigaAM обучена на HTK:
// Slaney: линейная шкала ниже 1000 Гц, логарифмическая выше // HTK: f_mel = 2595 * log10(1 + f_hz / 700)
Разница в mel-фильтрах приводила к полностью неверным входам для модели. Решение — параметризация через MelScale::Slaney | MelScale::HTK.
Parakeet пошла ещё дальше: mel-фильтры хранятся в весах модели (preprocessor.featurizer.fb), а не генерируются по формуле. Пришлось добавить загрузку фильтров из safetensors.
Проблема 2: RoPE до проекций Q/K (GigaAM)
Симптом: Тензоры после attention не совпадают с Python.
Причина: В стандартном Transformer RoPE применяется после линейных проекций Q и K. GigaAM применяет RoPE до — сначала поворачивает входные эмбеддинги, потом проецирует. Обнаружил только при layer-by-layer сравнении.
Проблема 3: Ручная реализация LSTM (Parakeet)
Симптом: Предсказания Prediction Network не совпадают с NeMo.
Причина: Candle не имеет встроенного LSTM. При ручной реализации я перепутал порядок гейтов (i,f,g,oi,f,g,o вместо i,f,o,gi,f,o,g, как в PyTorch). Один переставленный гейт — и весь выход мусор.
Проблема 4: Шардированные SafeTensors
Симптом: Часть весов модели не загружается — модель выдаёт мусор.
Причина: Большие модели (Qwen3-ASR 1.7B) хранятся в нескольких файлах (model-00001-of-00002.safetensors). Нужно парсить model.safetensors.index.json для маппинга weight_name → file:
fn find_safetensors(model_dir: &Path) -> Vec<PathBuf> { // Сначала ищем index.json для шардированных моделей let index_path = model_dir.join("model.safetensors.index.json"); if index_path.exists() { let index: SafetensorsIndex = serde_json::from_reader(...)?; return index.weight_map.values() .collect::<HashSet<_>>() .iter() .map(|f| model_dir.join(f)) .collect(); } // Иначе — один файл vec![model_dir.join("model.safetensors")] }
Проблема 5: STFT reflect padding
Симптом: Mel-спектрограмма отличается от Python на первых и последних фреймах.
Причина: PyTorch torch.stft(center=True) использует reflect-padding: [a, b, c, d] → [c, b, a, b, c, d, c, b]. Я изначально использовал zero-padding. После реализации reflect-padding MSE упал с 10−310−3 до 10−1010−10.
GGUF-квантизация
Проект включает встроенный квантайзер — команда quantize конвертирует safetensors в GGUF:
rustasr quantize \ --input models/whisper-large-v3-turbo/model.safetensors \ --output models/whisper-large-v3-turbo/model-q8_0.gguf \ --qtype q8_0
Результаты впечатляют — особенно для Whisper:
Формат | Размер | Cold Start | RTF | Качество |
|---|---|---|---|---|
safetensors (fp16) | 1.5 ГБ | 4.03 с | 0.110 | ★★★★★ |
GGUF Q8_0 | 825 МБ | 1.38 с | 0.269 | ★★★★★ |
GGUF Q4_0 | 442 МБ | 0.23 с | 0.233 | ★★★★☆ |
Q8_0 даёт нулевую потерю качества при двукратном уменьшении размера и 3× быстрее холодный старт. Q4_0 — заметная деградация: на длинных записях модель может зацикливаться.
Для Qwen3-ASR квантизируется только декодер (LLM-часть), энкодер остаётся в fp32 — он чувствителен к точности.
Диаризация
Помимо транскрибации, проект поддерживает диаризацию — определение говорящих:
rustasr diarize \ --model models/whisper-large-v3-turbo \ --model-type whisper \ --audio interview.wav \ --speaker-mode auto \ --num-speakers 2 \ --out-dir output/
Два режима:
Channel (stereo) — левый канал = микрофон, правый = система. Автовыбор для stereo-файлов
Cluster (mono) — VAD-сегментация через WebRTC, затем k-means кластеризация по акустическим эмбеддингам (средний log-mel вектор + L2-нормализация, cosine distance)
Это не нейросетевая диаризация (ECAPA-TDNN / x-vector), а простой эвристический подход — но для многих сценариев (подкасты, интервью, конференц-звонки) его достаточно.
Бенчмарки
Тестирование на 60 секундах русской речи (запись рабочего созвона), Apple Silicon, Metal GPU:
Производительность
Модель | Параметры | RTF | Время транскр. | Cold Start | Peak RAM |
|---|---|---|---|---|---|
GigaAM v3 CTC | 220M | 0.017 | 1.02 с | 2.64 с | 1 719 МБ |
Parakeet TDT v3 | 627M | 0.038 | 2.30 с | 5.87 с | 4 672 МБ |
Whisper v3 Turbo | 809M | 0.110 | 6.60 с | 4.03 с | 1 711 МБ |
Qwen3-ASR 0.6B | 600M | 0.114 | 6.84 с | 2.60 с | 1 932 МБ |
Qwen3-ASR 1.7B (Q8) | 1.7B | 0.187 | 11.19 с | 5.66 с | 4 178 МБ |
RTF (Real-Time Factor) — отношение времени обработки к длительности аудио. RTF 0.017 означает: 1 секунда аудио обрабатывается за 17 миллисекунд.
GigaAM — абсолютный чемпион по скорости: в 6.5 раз быстрее Whisper при сопоставимом качестве на русском. Parakeet тоже быстра, но бесполезна для русского.
Качество на русском
Одна и та же запись, все модели через RustASR:
Whisper Large v3 Turbo (★★★★★):
«У нас по результатам изучения документации пятничной встречи с Алиной возникло три, скажем так, блока вопросов...»
Правильная пунктуация, верные имена собственные (Алина, РТК, партнёра).
GigaAM v3 CTC (★★★★☆):
«У нас по,ну, результатам изучения документации пятничной встречи с Алиной возникло три, скажем так, блока вопросов...»
Близко к Whisper. Мелкие ошибки, но текст осмысленный. Использует букву «ё».
Qwen3-ASR 0.6B (★★★☆☆):
«...пятничной встречи с Олиной... на витрине для спортсменов будет.»
Искажения имён собственных, нет пунктуации, «виджет» → «видеото».
Parakeet TDT v3 (★☆☆☆☆):
«У нас зачем документации пятьсот при скажете блок вопросов...»
Бессмысленный текст. Модель оптимизирована для английского — на русском непригодна.
Рекомендации
Сценарий | Рекомендуемая модель |
|---|---|
Русский, лучшее качество | Whisper Large v3 Turbo |
Русский, макс. скорость | GigaAM v3 CTC |
Мультиязычный контент | Whisper Large v3 Turbo |
Английский | Parakeet TDT v3 или Whisper |
Минимальный RAM | GigaAM (1.7 ГБ) |
Rust vs Python
Сравнение Rust-реализации с оригинальным HuggingFace Python-инференсом (Qwen3-ASR 1.7B, safetensors):
Метрика | Python (HF) | RustASR |
|---|---|---|
Время транскрибации | ~48 с | ~19.5 с |
Cold start | 7-10 с | 5.3 с |
Размер зависимостей | ~2 ГБ | ~15 МБ (бинарь) |
Совпадение текста | — | ~96% |
Ускорение 2.5× на том же железе при полном совпадении по текстов. При этом бинарь не зависит от Python, virtualenv, pip, PyTorch.
Стек технологий
Компонент | Библиотека |
|---|---|
ML-бэкенд | Candle (candle-core, candle-nn, candle-transformers) |
Формат весов | SafeTensors + GGUF |
Распознавание | tokenizers (HuggingFace) |
Аудио I/O | hound (WAV читалка) |
Ресемплинг | rubato (FFT-based) |
FFT | rustfft |
VAD | webrtc-vad |
CLI | clap (derive макросы) |
Логирование | tracing |
Все зависимости — чистый Rust. Единственная «внешняя» зависимость — Metal framework на macOS (системный, входит в Xcode).
Уроки и выводы
Что сработало
Мульти-модельная архитектура с trait — инвестиция в абстрактный интерфейс окупается: каждая новая модель — это
impl AsrModel, а не переписывание CLIGolden tests — послойное сравнение тензоров с Python. Без этого я бы никогда не нашёл баг с порядком RoPE в GigaAM или перепутанные LSTM-гейты в Parakeet
Параметризованная mel-конфигурация — одна структура вместо четырёх дублирующихся реализаций
Feature gates — пользователь компилирует только нужные модели
Что было сложно
Каждая модель — отдельный мир. Нельзя просто «подставить другой энкодер». Различаются mel-параметры, способ декодирования, формат токенов, логика постобработки
Отсутствие примитивов в Candle — нет LSTM, нет BatchNorm1d, нет einsum. Всё писал руками на уровне тензорных операций
Конвертация весов — GigaAM хранится как PyTorch checkpoint, Parakeet — как NeMo модель. Нужны скрипты конвертации в safetensors — одноразовая, но нетривиальная работа
Отладка нестандартного RoPE — когда два Transformer'а различаются только порядком одной операции, а выход — полный мусор
Советы для тех, кто хочет повторить
Начинайте с Whisper. Candle-transformers уже имеет реализацию — это быстрый старт и база для сравнения
Не пренебрегайте mel-параметрами. Даже
center=Truevscenter=Falseв STFT — это разница между работающей и неработающей модельюПишите скрипты сравнения с Python. Отдельный скрипт, который дампит тензоры на каждом слое — бесценен
Не привязывайтесь к одной модели. Как показал мой опыт, «мультиязычная» не значит «одинаково хорошая на всех языках»
Что дальше
CUDA-поддержка — сейчас оптимизировано для Metal (macOS). CUDA работает через Candle, но не оптимизировано
Beam Search — текущий декодер у Whisper только greedy. Beam search может улучшить качество
Streaming — VAD-сегменты уже обрабатываются отдельно, следующий шаг — стриминговый вход
Больше моделей — архитектура позволяет добавлять новые модели как отдельные крейты
Исходный код
Проект полностью открыт под MIT/Apache-2.0:
🔗 GitHub: https://github.com/askidmobile/RustASR
Буду рад звёздочкам ⭐, issues и PR!
Спасибо за прочтение! Если есть вопросы — пишите в комментариях.
Подписывайтесь на канал для получения информации от ИТ-архитектора с более чем 20-летним стажем.
