ГлавнаяБлогОптимизация аудио в KeyEcho: 27x ускорение на Rust
Алгоритмы

Оптимизация аудио в KeyEcho: 27x ускорение на Rust

Как переписать горячий путь аудио в KeyEcho на Rust: ускорение в 27 раз, zero-copy, отказ от мьютекса. Читайте и применяйте.

Al
Редакция Algolitalgolit.ru
12 мин чтения15 июля 2026 г.

Зачем это читать?

KeyEcho — десктопное приложение, которое воспроизводит звук механической клавиатуры при каждом нажатии. В версии 1.0 горячий путь аудио был переписан, что дало ускорение в 27 раз на среднем сэмпле и в 38 раз на самом большом. В этой статье я разберу, как именно это было сделано: преддекодирование, zero-copy, отказ от глобального мьютекса. Вы узнаете, как Rust сделал эти изменения безопасными, и какую ошибку предложил AI-агент, которая выглядела чисто, но сломала бы функциональность.

Горячий путь KeyEcho

Горячий путь состоит из нескольких шагов: глобальный хук клавиатуры срабатывает на каждое нажатие; первое нажатие клавиши помещается в очередь с ограничением; аудиопоток забирает из очереди, отображает клавишу на фрагмент звукового пакета и воспроизводит локально. Любая операция внутри этого пути — декодирование, выделение памяти, копирование сэмплов, блокировка — платится на каждое нажатие. Цель — вынести всё это из пути.

Что было в v0.0.5

Предыдущая версия уже была быстрой: Tauri + Rust, нативное прослушивание клавиатуры, WAV без декомпрессии, LRU-кэш декодированного аудио. Но даже при попадании в кэш каждое нажатие копировало сэмплы (66.84 KiB) и проходило через глобальный мьютекс, разделяемый с переключением пакетов и громкостью. Промах кэша декодировал на месте. Версия 1.0 убирает именно это: копирование, блокировку и промахи кэша.

Четыре ключевых изменения

1. Преддекодирование при выборе пакета

Звуковой пакет — папка с файлами sound.ogg и config.json. Конфиг отображает клавишу на фрагмент аудио: key -> [start_ms, duration_ms]. При выборе пакета каждый фрагмент декодируется один раз заранее. Никакого декодирования во время нажатия.

2. Дедупликация одинаковых фрагментов

Многие клавиши указывают на один и тот же фрагмент, поэтому декодировать на каждую клавишу — тратить ресурсы. Строитель пакета использует карту по паре (start_ms, duration_ms), декодирует каждый уникальный фрагмент один раз и делит его через Arc:

// Декодируем каждый уникальный фрагмент один раз; одинаковые фрагменты разделяют один буфер
let mut slice_sources: HashMap<[u64; 2], AudioSource> = HashMap::new();
let mut key_sources: HashMap<Key, AudioSource> = HashMap::new();
for (key, slice) in defines {
    let source = slice_sources.entry(slice)
        .or_insert_with(|| {
            let [start_ms, duration_ms] = slice;
            let samples = decode(start_ms, duration_ms); // Vec<f32>, один раз
            AudioSource::new(Arc::from(samples), channels, sample_rate)
        })
        .clone(); // клонирование Arc, не копирование сэмплов
    key_sources.insert(key, source);
}

3. Zero-copy воспроизведение

Декодированные сэмплы хранятся в Arc<[f32]>. Поиск по клавише возвращает клон этого Arc — увеличение счётчика ссылок, а не копирование сэмплов. Стоимость на нажатие — 0 байт скопировано.

4. Отказ от глобального мьютекса

Старый путь защищал текущий звук и громкость мьютексом. Новый хендлер воспроизведения хранит текущий звук в ArcSwapOption<KeySound> и громкость в AtomicU32 (через f32::to_bits). Поиск клавиши — lock-free загрузка:

struct PlaybackSoundpack {
    current_sound: Arc<ArcSwapOption<KeySound>>,
    volume_bits: Arc<AtomicU32>,
}

fn source_for_key(&self, key: Key) -> Option<(AudioSource, f32)> {
    let sound = self.current_sound.load(); // lock-free
    let source = sound.as_ref()?.key_source(key)?; // map get + Arc clone
    let volume = f32::from_bits(self.volume_bits.load(Ordering::Relaxed));
    Some((source, volume))
}

Преддекодирование увеличивает потребление RAM, поэтому введены ограничения: очередь событий с границей (backpressure) и бюджет декодированных сэмплов в 10 MiB. Пакет, превышающий бюджет, не загружается.

Результаты бенчмарков

Сценарийv0.0.5v1.0Ускорение
Попадание в кэш, средний фрагмент1184.07 ns/op; 66.84 KiB скопировано43.50 ns/op; 0 байт скопировано27.2x
Попадание в кэш, самый большой фрагмент1638.57 ns/op; 98.88 KiB скопировано43.10 ns/op; 0 байт скопировано38.0x
Шлюз нажатия/отпускания58.82 ns/тап; 2 сообщения54.69 ns/тап; 1 сообщениеполовина сообщений

Это микро-бенчмарки пути поиска, не сквозная задержка. Методология и бюджеты в docs/performance.md.

Почему Rust сделал это безопасно

Компилятор Rust — первый ревьюер для кода, написанного AI. Ошибки с заимствованием, псевдонимами или Send/Sync отлавливаются на этапе cargo check. Переход на Arc<[f32]> и отказ от мьютекса — именно те изменения, где ошибка была бы ошибкой алиасинга, которую Rust не пропустит.

Что реально сделал AI-агент

  • Помощник по миграции: Tauri 1 → 2 затрагивает конфиги, разрешения, обновления, плагины. Агент с документацией экономит часы.
  • Аудитор горячего пути: прошёлся по старому пути и предложил преддекодирование, shared-буферы, отказ от мьютекса.
  • Триаж бэклога: прочитал все открытые issues и отсортировал по важности для 1.0. Один из них — запрос от пользователя, готового платить. Ответ после релиза дал первого платного клиента.
  • Дисциплина бенчмарков и документации: держал заметки производительности чёткими, воспроизводимыми и честными.

Изменение, которое выглядело чисто, но нет

CI для Linux armv7 падал под QEMU: libgit2 не мог загрузить git-зависимость. Агент предложил отказаться от git-пина на крейт аудио и использовать версию с crates.io. CI позеленел. Я отклонил. Пин существует, чтобы удерживать cpal на версии 0.18.1, которая имеет событие DeviceChanged. Без него звук не следует за переключением наушников или Bluetooth. Возврат к crates.io версии тихо даунгрейдит cpal до 0.17.3 и ломает эту фичу. Два issue (#41 и #20) были именно об этом. Реальный фикс — cargo vendor и офлайн-сборка.

Два правила:

  • Пишите «почему» рядом с каждым пином, хаком и магическим числом. У агента нет контекста.
  • Никогда не принимайте изменение версии зависимости, которого не просили, даже если CI зелёный. Зелёный CI — не гарантия, что тесты покрывают то, что вы теряете.

Что я вырезал

Сборка armv7 занимала ~2 часа под эмуляцией, а пользователей 32-битного ARM Linux не было видно. Я убрал её из 1.0. Агент не чувствует sunk cost; решать, когда остановиться — человеческая работа.

Практический вывод

Прямо сейчас: откройте свой проект и найдите горячий путь — место, где выполняется самая частая операция. Измерьте, сколько байт копируется, какие блокировки происходят. Попробуйте применить предварительное выделение и zero-copy через Arc. Используйте lock-free структуры, если позволяют гарантии Rust. И обязательно документируйте причины каждого пина — это спасёт вас и вашего AI-агента от регрессий.

#оптимизация#Rust#zero-copy#аудио#KeyEcho
Al
Редакция Algolit

Пишем про алгоритмы, подготовку к собеседованиям и карьеру в IT — так, чтобы было понятно и полезно.

Хочешь закрепить знания на практике?

Решай задачи на Algolit — интерактивная платформа для обучения

Начать бесплатно →