Как я написал ASR-движок на Rust: от разочарования в одной модели до мульти-модельной архитектуры
Кому лень читать полностью
Я реализовал 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-летним стажем.