Я написал свой музыкальный плеер со своим стримингом: Android, Windows, Linux и сервер на Rust

У меня есть коллекция музыки: FLAC, японские альбомы, которых нет в стримингах обычно, редкие релизы с додзин‑фестивалей. Слушать её хотелось так же удобно, как Spotify: с телефона, с компьютера, с текстами, статистикой и итогами года. А получалось как в 2010-м: файлы копируются руками, плейлисты живут на одном устройстве, а тексты японских песен без чтений иероглифов бесполезны.
Готовые решения закрывали задачу по кусочкам. Poweramp отлично играет, но только на одном телефоне. Plex и Plexamp умеют стриминг, но требуют аккаунт, а часть функций у них за подпиской. Связка Navidrome и Symfonium хороша, но это два разных продукта от разных авторов, и половина того, что мне было нужно, не входила ни в один из них.
Поэтому я написал своё. Сразу оговорюсь: Nami — мой проект. Проект называется Nami (波, «волна»). Сейчас это:
приложение для Android на Kotlin и Jetpack Compose;
приложение для Windows и Linux на Tauri (Rust + React);
свой сервер на Rust, который ставится одной командой на VPS или домашний компьютер;
сайт nami‑music.ru.
Всё бесплатно, без рекламы и без обязательного аккаунта.
Что получилось
Сначала коротко покажу продукт, чтобы дальше было понятно, о чём речь.

Библиотека. Треки, альбомы, артисты, жанры. Обложки и фото артистов кешируются на диск, поэтому прокрутка большой коллекции не дёргается.

Плеер. Перемотка по форме волны трека, формат и битрейт файла, BPM и тональность. Всё это считается на сервере при загрузке трека.

Тексты. Синхронизированные тексты с караоке‑подсветкой. Для японских песен есть фуригана (чтение над иероглифами) и ромадзи (текст латиницей), а для любых — перевод.

Итоги месяца и года. Как Spotify Wrapped, только по вашей собственной музыке. Все же любят посмотреть сколько они наслушали?
А ещё: эквалайзер с импортом профилей AutoEq, совместное прослушивание (джем), перенос воспроизведения между телефоном и компьютером с той же секунды, статус в Discord, умные плейлисты, теги и оценки.
Как всё связано

Главная идея: сервер необязателен. Приложение на телефоне и приложение на компьютере работают и сами по себе, с папками на устройстве. Сервер добавляет то, что без него невозможно: стриминг, синхронизацию, джем и итоги по всем устройствам сразу.
Сервер написан на Rust (axum + SQLite) и общается с клиентами через REST и WebSocket. Дополнительно он частично реализует протокол OpenSubsonic, поэтому библиотеку можно слушать даже с iPhone через любой Subsonic‑клиент: Amperfy, play:Sub и другие. Своего приложения для iOS пока нет, а так музыка доступна уже сегодня.
Синхронизация: одна таблица вместо девяти
Первая по‑настоящему интересная задача: синхронизировать между устройствами плейлисты, оценки, теги, заметки, моменты в треках и историю прослушиваний. Устройства бывают офлайн, меняют одно и то же, а потом приходят со своими изменениями.
Классическое решение — стратегия last‑write‑wins, то есть побеждает последняя запись. Но если применять её ко всей записи целиком, теряются данные. Допустим, на телефоне я переименовал плейлист, а на компьютере добавил в него трек. Кто пришёл позже, тот и затёр изменения другого.
Нужна last‑write‑wins на уровне поля. Прямолинейная реализация — отдельная колонка *_updated_at у каждого поля каждой таблицы и отдельная ветка сравнения для каждой. Это девять наборов почти одинакового SQL, и их надо трогать при каждом новом поле.
Вместо этого состояние хранится по одной строке на поле:
CREATE TABLE state (
user_id INTEGER NOT NULL,
entity TEXT NOT NULL, -- 'playlist', 'rating', 'tag', 'moment', ...
id TEXT NOT NULL,
field TEXT NOT NULL, -- 'name', 'stars', 'tracks', ...
value TEXT NOT NULL, -- JSON
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, entity, id, field)
);Тогда вся логика слияния — это один запрос (в реальном коде он ещё хранит предыдущее значение, чтобы отличить новую правку от повторно доставленного запроса):
INSERT INTO state (user_id, entity, id, field, value, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT (user_id, entity, id, field)
DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
WHERE excluded.updated_at > state.updated_at;
Клиент отправляет пачку изменений вида {entity, id, field, value, updated_at}, а в ответ получает всё, что изменилось с его прошлого запроса. Удаление — это та же запись со специальным значением. Новая сущность в приложении не требует ни миграции, ни нового кода на сервере: достаточно начать отправлять новый entity.
Караоке: выравнивание текста нейросетью прямо на телефоне
Синхронизированных текстов с таймингом по словам почти не бывает. LRCLIB и другие базы в лучшем случае дают тайминг строк. А для караоке‑подсветки нужно знать, когда звучит каждое слово.
Решение — whisper.cpp с таймстемпами на уровне токенов, запущенный на самом телефоне. Модель распознаёт трек, получает слова со временем, и эти слова сопоставляются с уже известным текстом строк.
Обвязка написана на Rust и подключается к Kotlin через uniffi. Здесь я наступил на неочевидную проблему: whisper.cpp принимает 16 кГц моно в f32. Самый естественный интерфейс выглядит так:
#[uniffi::export]
pub fn align(pcm: Vec<f32>) -> Vec<WordTiming> { /* ... */ }
И он роняет приложение по нехватке памяти. Сгенерированная Kotlin‑обвязка для последовательности f32 упаковывает каждый элемент в отдельный java.lang.Float. Трек длиной 4 минуты — это почти 4 миллиона сэмплов, то есть 4 миллиона объектов в куче. Стандартной куче приложения в 256 МБ этого хватает, чтобы умереть.
Исправление — передавать сырые байты:
#[uniffi::export]
pub fn align(pcm_bytes: Vec<u8>) -> Vec<WordTiming> {
let pcm: Vec<f32> = pcm_bytes
.chunks_exact(4)
.map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
.collect();
}Один массовый буфер вместо миллионов объектов. Распознавание полного трека на процессоре телефона всё равно занимает минуты, поэтому в интерфейсе есть индикатор прогресса. Его значение берётся из колбэка самого whisper.cpp.
Фуригана и ромадзи
Для японских песен текст без чтений — половина удовольствия. На телефоне и на компьютере работает одна и та же схема:
Морфологический анализатор kuromoji со словарём IPADIC разбивает строку на слова и даёт каждому чтение катаканой.
Над словами с иероглифами ставится чтение хираганой через HTML/Compose‑аналог
<ruby>.Ромадзи — это транслитерация тех же чтений по Хэпбёрну, с учётом сдвоенных согласных (
がっこう→gakkou) и слогов с маленькимиゃゅょ.
export function romanize(h: string): string {
let out = '' for (let i = 0; i < h.length; ) {
if (h[i] === 'っ' && i + 1 < h.length) {
const next = DI[h.slice(i + 1, i + 3)] ?? MONO[h[i + 1]]
if (next) { out += next[0]; i++; continue }
}
const di = DI[h.slice(i, i + 2)]
if (di) { out += di; i += 2; continue }
out += MONO[h[i]] ?? h[i]
i++
}
return out
}
Словарь весит около 17 МБ, поэтому он загружается только тогда, когда вы впервые включаете фуригану или ромадзи.
На десктопе нашлись свои подводные камни. Сборка kuromoji под ESM ломалась на встроенном gunzip, а Vite отдавал сжатые файлы словаря с заголовком Content-Encoding: gzip, и браузер распаковывал их раньше, чем библиотека. В итоге сработало так: UMD‑сборка подключается обычным <script>, а файлы словаря в режиме разработки отдаются как есть.
Звук: bit‑perfect и FFmpeg на пять декодеров
Для аудиофилов важно, чтобы файл 24 бит / 96 кГц дошёл до ЦАП без пересэмплирования и без системного микшера. На Android для этого пришлось написать собственный вывод на USB‑ЦАП в обход аудиостека системы. На Windows используется эксклюзивный режим WASAPI на частоте файла.
С форматами отдельная история. Android сам умеет FLAC, MP3, AAC, Opus и WAV, но не APE, WavPack, TAK и Musepack. К версии 1.0.33 APK разросся до 234 МБ. Сейчас FFmpeg собирается с флагом --disable-everything, и в нём включены только пять декодеров, которых нет в Android:
--enable-decoder=ape,wavpack,tak,mpc7,mpc8
Со следующей версии APK весит 96 МБ. Трекерная музыка (MOD, XM, IT, S3M) играется через libopenmpt, а музыка из игр (NSF, SPC, VGM) — через game‑music‑emu.
Приложение для ПК: почему Tauri, а не Electron
Десктопная версия написана на Tauri: интерфейс на React, всё остальное на Rust. Главный аргумент — звук не должен идти через браузер. HTML5 audio не даёт ни эксклюзивного режима, ни контроля над частотой вывода. В Tauri аудиодвижок работает в отдельном потоке на чистом Rust (rodio + symphonia), а WebView только рисует интерфейс.
Приятный побочный эффект — размер: установщик для Windows весит 26 МБ.

Отдельно расскажу про статус в Discord. Rich Presence работает через локальный IPC: это именованный канал на Windows и unix‑сокет на Linux к клиенту Discord на той же машине. Поэтому приложение на ПК публикует статус напрямую, а если Discord на компьютере не запущен, статус публикуется через сервер. Так в профиле видно и то, что играет на телефоне.
Сервер: 69 МБ памяти
Сервер — один статический бинарник на Rust. Установка на Linux выглядит так:
curl -fsSL https://raw.githubusercontent.com/MozzarellaCheesee/nami/main/install.sh | bashТакже есть установщик для Windows и образ Docker. Сервер сам:
сканирует папку с музыкой, читает теги и обложки;
считает форму волны, BPM, тональность и выравнивание громкости;
отдаёт трек в исходном качестве или перекодирует в HLS с AAC 128/192 для мобильного интернета;
находит тексты песен в LRCLIB и переводит их через DeepL, если указан ключ;
обновляется одной командой
nami update.
Для масштаба — живые цифры с моего VPS: библиотека 27 ГБ, 441 трек, два часа работы после обновления. Сервер занимает 69 МБ оперативной памяти и в простое не нагружает процессор. Бинарник весит 8 МБ.
Сравнение с другими плеерами

Честно о том, где Nami проигрывает:
Нет приложения для iOS. Пока выручают Subsonic‑клиенты. Настоящий bit‑perfect на iPhone невозможен в принципе: у iOS нет публичного доступа к USB‑аудио.
Исходный код закрыт. Релизы, установщики и документация лежат в публичном репозитории, но сами исходники приватные.
Нет каталога. Это плеер для своей музыки. Если вы открываете для себя новое через рекомендации стриминга, Nami его не заменит.
Что дальше
Приложение для iOS. Оценка и план уже готовы, это отдельный большой проект.
Рекомендации по собственной коллекции: что давно не слушали и что похоже на любимое.
Попробовать
Сайт: nami‑music.ru
Android (8.0 и новее): скачать APK
Windows и Linux: скачать для компьютера
Сервер: инструкция по установке
Буду рад фидбэку в комментариях: чего вам не хватает в музыкальных плеерах и что стоит сделать следующим.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.