Как я написал ASR-движок на Rust: от разочарования в одной модели до мульти-модельной архитектуры. asr.. asr. candle.. asr. candle. inference.. asr. candle. inference. machine learning.. asr. candle. inference. machine learning. rust.. asr. candle. inference. machine learning. rust. speech recognition.. asr. candle. inference. machine learning. rust. speech recognition. speech-to-text.. asr. candle. inference. machine learning. rust. speech recognition. speech-to-text. whisper.
Как я написал ASR-движок на Rust: от разочарования в одной модели до мульти-модельной архитектуры - 1
Кому лень читать полностью

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

Общий объём: ~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>;
}
Как я написал ASR-движок на Rust: от разочарования в одной модели до мульти-модельной архитектуры - 3

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

Feature gates

Модели компилируются условно — можно собрать бинарь только с нужными:

[features]
default = ["whisper", "gigaam", "parakeet", "qwen3"]
whisper  = ["dep:model-whisper"]
gigaam   = ["dep:model-gigaam"]
parakeet = ["dep:model-parakeet"]
qwen3    = ["dep:model-qwen3"]
Как я написал ASR-движок на Rust: от разочарования в одной модели до мульти-модельной архитектуры - 5

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

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

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

Интересная деталь: 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()?)?;
Как я написал ASR-движок на Rust: от разочарования в одной модели до мульти-модельной архитектуры - 9

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

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

Разница в 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")]
}
Как я написал ASR-движок на Rust: от разочарования в одной модели до мульти-модельной архитектуры - 12

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

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

Два режима:

  • 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).

Уроки и выводы

Что сработало

  1. Мульти-модельная архитектура с trait — инвестиция в абстрактный интерфейс окупается: каждая новая модель — это impl AsrModel, а не переписывание CLI

  2. Golden tests — послойное сравнение тензоров с Python. Без этого я бы никогда не нашёл баг с порядком RoPE в GigaAM или перепутанные LSTM-гейты в Parakeet

  3. Параметризованная mel-конфигурация — одна структура вместо четырёх дублирующихся реализаций

  4. Feature gates — пользователь компилирует только нужные модели

Что было сложно

  1. Каждая модель — отдельный мир. Нельзя просто «подставить другой энкодер». Различаются mel-параметры, способ декодирования, формат токенов, логика постобработки

  2. Отсутствие примитивов в Candle — нет LSTM, нет BatchNorm1d, нет einsum. Всё писал руками на уровне тензорных операций

  3. Конвертация весов — GigaAM хранится как PyTorch checkpoint, Parakeet — как NeMo модель. Нужны скрипты конвертации в safetensors — одноразовая, но нетривиальная работа

  4. Отладка нестандартного RoPE — когда два Transformer’а различаются только порядком одной операции, а выход — полный мусор

Советы для тех, кто хочет повторить

  1. Начинайте с Whisper. Candle-transformers уже имеет реализацию — это быстрый старт и база для сравнения

  2. Не пренебрегайте mel-параметрами. Даже center=True vs center=False в STFT — это разница между работающей и неработающей моделью

  3. Пишите скрипты сравнения с Python. Отдельный скрипт, который дампит тензоры на каждом слое — бесценен

  4. Не привязывайтесь к одной модели. Как показал мой опыт, «мультиязычная» не значит «одинаково хорошая на всех языках»

Что дальше

  • 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

Источник

Rambler's Top100