- BrainTools - https://www.braintools.ru -

Как я написал TTS-движок на Rust за месяц: путь от Python к production-ready решению

Как я написал TTS-движок на Rust за месяц: путь от Python к production-ready решению - 1
Кому лень все читать

Я переписал Qwen3-TTS (600M параметров) с Python/PyTorch на чистый Rust.

Результат:

  • бинарник 12 МБ вместо 2 ГБ venv

  • холодный старт 1.9 сек вместо 7.7 сек

  • RTF на CPU до 1.37x

Введение

Сегодня я расскажу историю о том, как модель синтеза речи Qwen3-TTS [1] от Alibaba обрела новую жизнь на Rust.

Почему Rust?

Python-экосистема ML прекрасна для прототипирования, но когда дело доходит до продакшена, начинаются проблемы:

  • ~2 ГБ зависимостей (PyTorch, transformers, и т.д.)

  • 7-10 секунд холодного старта (импорт модулей, JIT-компиляция)

  • GC-паузы — непредсказуемые задержки

  • Сложность развёртывания — virtualenv, версии Python, CUDA

Rust решает все эти проблемы: один статически слинкованный бинарник, мгновенный запуск, предсказуемые latency.

Архитектура Qwen3-TTS

Перед тем как писать код, нужно понять, что мы реализуем. Qwen3-TTS — это end-to-end модель синтеза речи, состоящая из нескольких компонентов:

Pipeline

Text Normalizer
(числа, даты)

Tokenizer
(BPE)

Acoustic Model
(Transformer)

Decoder
(HiFi-GAN)

Компоненты

  1. Text Normalizer — преобразует “100 рублей” → “сто рублей”

  2. Tokenizer — BPE-токенизация текста + специальные аудио-токены

  3. Acoustic Model — Transformer с 600M параметров, генерирует акустические токены

  4. Audio Codec (HiFi-GAN) — декодирует токены в PCM-аудио

Особенность Qwen3-TTS — использование 16 codebook’ов (RVQ — Residual Vector Quantization), что даёт высокое качество звука при низком битрейте.

Структура проекта

Мы разбили проект на 8 независимых crate’ов:

Crate

Строк кода

Описание

tts-core

~500

Базовые типы, трейты, ошибки [2]

text-normalizer

~1200

Нормализация (числа, даты, валюты)

text-tokenizer

~800

BPE-токенизация

acoustic-model

~4000

Transformer + KV cache

audio-codec-12hz

~3300

HiFi-GAN декодер

runtime

~1700

Pipeline, streaming

tts-cli

~400

CLI интерфейс

tts-server

~600

gRPC + HTTP сервер

Общий объём: ~12 500 строк Rust кода.

Путь разработки: хронология коммитов

Анализируя git log, можно проследить эволюцию [3] проекта:

Фаза 1: Базовая инфраструктура

22eea02 init
800e67e feat: добавить начальную структуру workspace
837dee0 feat: добавить правила нормализации текста
7fbfa3d feat: расширить токенизатор аудио-токенами
283dd7f feat: реализовать acoustic model с transformer блоками
a8279f2 feat: реализовать нейронный декодер audio-codec

На этом этапе я создал скелет проекта и базовые компоненты.

Основные решения:

  • Candle как ML-фреймворк (vs tch-rs) — нативный Rust, без биндингов к libtorch

  • Модульная архитектура — каждый компонент изолирован

Фаза 2: Интеграция с реальными весами

Самая сложная часть — загрузка и использование реальных весов модели.

78eb669 feat(text-tokenizer): добавить поддержку Qwen3-TTS токенов
65cb4ab feat(acoustic-model): добавить загрузку конфигурации из JSON
65d895a feat: добавить поддержку CustomVoice формата

Здесь я столкнулся с первыми серьёзными проблемами…

Проблемы и их решения

Проблема 1: Формат codebook’ов

Симптом: Модель загружается, но генерирует белый шум.

Причина: Qwen3-TTS хранит codebook’и в EMA-формате (Exponential Moving Average):

// НЕПРАВИЛЬНО: брать embedding_sum напрямую
let codebook = vb.get("embedding_sum")?;

// ПРАВИЛЬНО: нормализовать по cluster_usage
let embed_sum = vb.get("embedding_sum")?;
let cluster_usage = vb.get("cluster_usage")?;
let codebook = embed_sum / (cluster_usage + 1e-7);

Этот баг занял 2 дня отладки с layer-by-layer сравнением тензоров между Python и Rust.

Проблема 2: Multimodal RoPE (M-RoPE)

Симптом: Модель генерирует бессмысленные токены.

Причина: Qwen3-TTS использует модифицированный RoPE с тремя типами позиционных эмбеддингов:

// M-RoPE: разные позиции для текста, временной разметки и 3D-позиций
pub struct MultimodalRoPE {
    text_positions: Tensor,      // Позиции текстовых токенов
    temporal_positions: Tensor,  // Временные метки
    spatial_positions: Tensor,   // 3D-позиции (для мультимодальности)
}

impl MultimodalRoPE {
    pub fn apply(&self, q: &Tensor, k: &Tensor) -> Result<(Tensor, Tensor)> {
        // Разделяем hidden dimension на 3 секции
        let section_size = q.dim(D::Minus1)? / 3;
        
        let q_text = q.narrow(D::Minus1, 0, section_size)?;
        let q_temp = q.narrow(D::Minus1, section_size, section_size)?;
        let q_spatial = q.narrow(D::Minus1, section_size * 2, section_size)?;
        
        // Применяем RoPE к каждой секции с разными позициями
        let q_text = apply_rope(&q_text, &self.text_positions)?;
        let q_temp = apply_rope(&q_temp, &self.temporal_positions)?;
        let q_spatial = apply_rope(&q_spatial, &self.spatial_positions)?;
        
        Tensor::cat(&[q_text, q_temp, q_spatial], D::Minus1)
    }
}

Проблема 3: Causal Padding в HiFi-GAN

Симптом: Щелчки и артефакты на границах фреймов.

Причина: Декодер использует causal (причинную) свёртку, но мы применяли обычный same-padding.

// Causal Conv1d: padding только слева
pub struct CausalConv1d {
    conv: Conv1d,
    padding: usize,
}

impl CausalConv1d {
    pub fn forward(&self, x: &Tensor) -> Result<Tensor> {
        // Pad только слева (causal = не заглядываем в будущее)
        let padded = x.pad_with_zeros(D::Minus1, self.padding, 0)?;
        
        // Для transposed conv — обрезаем справа
        let out = self.conv.forward(&padded)?;
        let seq_len = x.dim(D::Minus1)?;
        out.narrow(D::Minus1, 0, seq_len)
    }
}

После исправления корреляция с Python SDK достигла 0.99+.

Проблема 4: Snake Activation

Симптом: Приглушённый, неестественный звук.

HiFi-GAN использует Snake activation — нестандартную функцию активации:

/// Snake activation: x + sin²(αx) / α
pub struct Snake {
    alpha: Tensor,  // Learnable parameter
}

impl Snake {
    pub fn forward(&self, x: &Tensor) -> Result<Tensor> {
        // snake(x) = x + sin²(αx) / α
        let ax = (x * &self.alpha)?;
        let sin_ax = ax.sin()?;
        let sin_sq = (&sin_ax * &sin_ax)?;
        x + sin_sq.broadcast_div(&self.alpha)
    }
}

Ключевой момент: alpha — это learnable параметр, который нужно загрузить из весов, а не инициализировать константой.

Проблема 5: Early EOS (преждевременное завершение)

Симптом: Модель генерирует только первые несколько слов.

Workaround: Устанавливаем минимальное количество токенов на основе длины текста:

/ Оценка минимальной длины аудио
// ~12 токенов/сек при 12Hz, ~0.1 сек на текстовый токен
let estimated_duration_s = (text_tokens.len() as f32 * 0.1).max(0.5);
let min_tokens = (estimated_duration_s * 12.0) as usize;

// Игнорируем EOS до достижения min_tokens
if token == eos_id && generated.len() < min_tokens {
    continue;  // Продолжаем генерацию
}

Это workaround, а не полное решение. Root cause требует дальнейшего исследования.

Оптимизация

KV-Cache с блочным хранением

Для эффективной автогрессивной генерации критически важен KV-cache:

pub struct BlockKVCache {
    // Кольцевой буфер для экономии памяти
    key_cache: Tensor,    // [batch, num_layers, max_seq, head_dim]
    value_cache: Tensor,
    
    position: usize,
    max_seq_len: usize,
}

impl BlockKVCache {
    pub fn append(&mut self, key: &Tensor, value: &Tensor) -> Result<()> {
        // Записываем в кольцевой буфер
        let pos = self.position % self.max_seq_len;
        
        self.key_cache.slice_scatter(key, D::Minus2, pos)?;
        self.value_cache.slice_scatter(value, D::Minus2, pos)?;
        
        self.position += 1;
        Ok(())
    }
}

Поддержка GGUF-квантизации

Для снижения потребления памяти [4] добавили поддержку GGUF (Q8/Q4):

// Автоматический выбор формата весов
fn find_weights(model_dir: &Path) -> Option<PathBuf> {
    // Приоритет: GGUF → Q8 → Q4 → safetensors
    for pattern in &["model.gguf", "model-q8_0.gguf", "model-q4_0.gguf", "model.safetensors"] {
        let path = model_dir.join(pattern);
        if path.exists() {
            return Some(path);
        }
    }
    None
}

Бенчмарки

Сравнение с официальным Python SDK на Apple Silicon (M-серия):

Метрика

Python SDK (MPS)

RustTTS (CPU, Q8)

Cold start

7.7 сек

1.9 сек

Размер

~2 ГБ

~12 МБ

RAM

~2 ГБ

~1.5 ГБ

RTF (short, 7 симв.)

2.59x

3.24x

RTF (medium, 73 симв.)

2.29x

1.43x

RTF (long, 163 симв.)

1.95x

1.37x

RTF (Real-Time Factor) — отношение времени синтеза к длительности аудио. Меньше = лучше.

Вывод: Python быстрее на коротких запросах (GPU ускорение), Rust выигрывает на средних и длинных текстах на CPU.

Примеры использования

CLI

# Синтез текста в WAV
cargo run -p tts-cli --release -- synth 
  --input "" 
  --model-dir models/qwen3-tts-0.6b-customvoice 
  -o output.wav

# Streaming режим
cargo run -p tts-cli --release -- synth 
  --input "Длинный текст для стриминга..." 
  --streaming 
  -o output.wav

gRPC Server

# Запуск сервера
cargo run -p tts-server --release

# Синтез через gRPC
grpcurl -plaintext -d '{"text": "Привет мир", "language": 1}' 
  localhost:50051 tts.v1.TtsService/Synthesize

Desktop App (Tauri)

cd crates/tts-app
cargo tauri dev

Уроки и вывод

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

  1. Модульная архитектура — позволяет тестировать каждый компонент изолированно

  2. Golden tests — сравнение тензоров с Python SDK выявляет баги на ранней стадии

  3. Candle — достаточно зрелый для production ML на Rust

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

  1. Недокументированные особенности — формат codebook’ов, M-RoPE, causal padding

  2. Отладка численных расхождений — layer-by-layer сравнение занимает много времени

  3. Metal (Apple GPU) — текущая реализация в Candle уступает CPU

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

  1. Начинайте с mock-компонентов — убедитесь, что pipeline работает сквозь

  2. Добавляйте debug logging на каждом этапе

  3. Пишите golden tests ДО реализации логики

  4. Используйте профилирование с самого начала

Исходный код

Проект полностью открыт под MIT/Apache-2.0:

🔗 GitHub: [https://github.com/askidmobile/RustTTS](https://github.com/askidmobile/RustTTS) [5]

Буду рад звёздочкам ⭐, issues и PR!


Спасибо за прочтение! Если есть вопросы — пишите в комментариях.

Подписывайтесь на канал [6] для получения информации от ИТ архитектора с более чем 20 летним стажем.

Теги: #rust #tts #machinelearning #qwen #opensource #audio

Автор: askid

Источник [7]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/24879

URLs in this post:

[1] Qwen3-TTS: https://github.com/QwenLM/Qwen3-TTS

[2] ошибки: http://www.braintools.ru/article/4192

[3] эволюцию: http://www.braintools.ru/article/7702

[4] памяти: http://www.braintools.ru/article/4140

[5] https://github.com/askidmobile/RustTTS](https://github.com/askidmobile/RustTTS): https://github.com/askidmobile/RustTTS%5D(https://github.com/askidmobile/RustTTS)

[6] канал: https://t.me/ArchTeamAI

[7] Источник: https://habr.com/ru/articles/990328/?utm_campaign=990328&utm_source=habrahabr&utm_medium=rss

www.BrainTools.ru

Rambler's Top100