ESPN DeportesMonchi se disculpa por pedir Balón de Oro para LamineESPNHarbaugh offers rare critique of struggling QB Herbert: 'Be better'The Jerusalem PostWATCH: 'Don't mess with us': Netanyahu warns enemies may attack Israel ahead of electionBollywood HungamaJubin Nautiyal welcomes first child with wife after intimate wedding, shares update: “Mom and baby are back home”Daily MaverickTHE CONVERSATION: New world map makes Africa look bigger – What’s the fuss about? Cartographers explainRTP DesportoBrasil vence Austrália com Circati a marcar e Irankunda a cometer penáltiInquirerMost wanted person in Ilocos Sur town fallsBusiness AMRusland wil dit jaar nieuwe ballistische raket in dienst nemen met een bereik van 800 kmThe RegisterApple patches CoreGraphics zero-day already exploited in targeted attacksThe Hollywood ReporterHow a Microdramas Director Landed Her First Feature Film GigDeadlineLauren Cohan & Jake Epstein To Co-Star In Eric Stoltz Directed Rom-Com ‘Both Sides Now’The South AfricanPowerBall Xtra: R21 million up for grabs – plus guaranteed winner twist
The Daily Newsstand · Free, Always
Tuesday, September 29, 2026

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

Translate

У меня есть коллекция музыки: 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, умные плейлисты, теги и оценки.

Как всё связано

Архитектура Nami

Архитектура Nami

Главная идея: сервер необязателен. Приложение на телефоне и приложение на компьютере работают и сами по себе, с папками на устройстве. Сервер добавляет то, что без него невозможно: стриминг, синхронизацию, джем и итоги по всем устройствам сразу.

Сервер написан на 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.

Фуригана и ромадзи

Для японских песен текст без чтений — половина удовольствия. На телефоне и на компьютере работает одна и та же схема:

  1. Морфологический анализатор kuromoji со словарём IPADIC разбивает строку на слова и даёт каждому чтение катаканой.

  2. Над словами с иероглифами ставится чтение хираганой через HTML/Compose‑аналог <ruby>.

  3. Ромадзи — это транслитерация тех же чтений по Хэпбёрну, с учётом сдвоенных согласных (がっこう → 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

Размер APK

Со следующей версии 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 МБ.

Сколько весит Nami

Сколько весит Nami

Отдельно расскажу про статус в 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 с Poweramp, Symfonium, Plexamp и Spotify

Сравнение Nami с Poweramp, Symfonium, Plexamp и Spotify

Честно о том, где Nami проигрывает:

  • Нет приложения для iOS. Пока выручают Subsonic‑клиенты. Настоящий bit‑perfect на iPhone невозможен в принципе: у iOS нет публичного доступа к USB‑аудио.

  • Исходный код закрыт. Релизы, установщики и документация лежат в публичном репозитории, но сами исходники приватные.

  • Нет каталога. Это плеер для своей музыки. Если вы открываете для себя новое через рекомендации стриминга, Nami его не заменит.

Что дальше

  • Приложение для iOS. Оценка и план уже готовы, это отдельный большой проект.

  • Рекомендации по собственной коллекции: что давно не слушали и что похоже на любимое.

Попробовать

Буду рад фидбэку в комментариях: чего вам не хватает в музыкальных плеерах и что стоит сделать следующим.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.