
Кому лень читать полностью
Я реализовал 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, а не переписывание CLI -
Golden 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-летним стажем.
Автор: askid


