Я перестал верить стримингам. Не философски, а практически. Половина любимых альбомов либо ушла из каталогов, либо вернулась пересведенной так, что слушать тошно. Концертные записи, винил‑рипы, региональные релизы — их там и не было. А моя коллекция в FLAC просто лежит на диске и никуда не девается.
Для своей коллекции я написал плеер. Для себя. Через полгода это превратилось в нишевое приложение для iOS на 11 языках: с собственным DSP, распознаванием музыки и CarPlay.
Что в итоге внутри и где было не очевидно. Места, в которых документация молчит, а ты сидишь и гадаешь, почему оно вообще так себя ведет.
Зачем вообще свой плеер
Претензия к Apple Music у меня одна, и она бытовая. Я хочу просто закинуть в телефон папку и слушать ее. Не «синхронизировать», не подгонять формат, не ждать, пока что‑то там обработается в облаке — а взять рип ровно таким, каким он лежит в моей аудиотеке, и нажать play. Вместо этого начинается ритуал. Файлы надо «добавить в Музыку», после чего они растворяются в общей библиотеке.
Сторонних плееров на iOS много, но у каждого свой набор боли:
-
Один не умеет CUE‑альбомы (один файл + сепаратор → треки).
-
Другой ресемплит все под 48 кГц «потому что Apple».
-
Третий называется hi‑fi, но не показывает, что на самом деле приходит в ЦАП — в Bluetooth‑наушниках играет то же, что и любой попсовый плеер.
-
Четвертый прибит к стримингу.
Я сформулировал для себя, каким плеер должен быть: только локальная музыка, никакой телеметрии, все работает оффлайн и без аккаунтов. Что ты слушаешь дома по ночам — твое дело и больше ничье. Задача плеера — честно играть твои файлы и не лезть, куда не просят.
С этого началась Lumiya.
Стек и общее устройство
-
iOS 26, минимум. Никаких
if #available, никаких костылей под старые версии. -
SwiftUI на фронте, AVAudioEngine на бэкенде звука.
-
Никакого Core Data — библиотека хранится снимками JSON в Application Support, с дебаунсенным сохранением и фоновым прогревом.
В архитектуре есть три кита:
-
LibraryStore— медиатека: альбомы, треки, артисты, плейлисты, любимое.@Observable, все в памяти, на диск ложится дебаунсом. -
AudioPlayer— движок звука наAVAudioEngine. Полный граф: декодер → EQ → loudness compensation → subsonic high-pass → ReplayGain → лимитер → crossfeed → output. -
AudioCoordinator— арбитр между источниками. Появился, когда я делал онлайн-радио. О нем позже.
Аудио, в которое пришлось закопаться
Если делать «как у всех», получится «не звучит». Я не аудиофил-фанат, но у меня есть желание, чтобы звук был честным. Пара вещей, на которых я задержался.
Правильный crossfeed, а не «псевдо»
В большинстве мобильных плееров crossfeed реализован чем-то вроде дельта-смешивания каналов с фиксированной задержкой и подрезанием высоких частот. Звучит ватно и теряет позиционирование. Я сделал кастомный AU (AVAudioUnit) на базе биквадных фильтров, по сути Bauer-style: смешиваю каналы с фазовым сдвигом и частотно-зависимым ослаблением, чтобы стерео-сцена компрессировалась к фронту, но не теряла деталей. Три пресета — легкий, средний, сильный — подбирались на слух.
Большая часть багов была не в DSP, а в склейке буферов между потоками. AVAudioUnit на iOS работает, и работает хорошо, нужно только не лениться разбираться с буферами и фазовой когерентностью.
Loudness compensation по кривым равной громкости. Эквалайзер в Lumiya есть, и я не из тех, кто на него фыркает. Штука универсальная — подстроить под конкретные наушники, под жанр, под настроение. Просто Loudness решает другую задачу. И решает ее для всех, а не только для аудиофилов.
Дело в физиологии слуха. На тихой громкости ухо реально хуже слышит бас и верха — это кривые равной громкости, и работают они одинаково что у меломана с дорогими ушами, что у человека, который просто сделал потише перед сном. Я сделал компенсацию по этим кривым, с тремя интенсивностями. Она не «бустит басы» наугад — она возвращает их к тому балансу, в котором трек сводили на студийной громкости. На прикроватной громкости разница слышна сразу. И слышна кому угодно.
Кнопку Loudness я вынес прямо в CarPlay, на экран «сейчас играет». В наушниках с ней мягче, а в машине из‑за дорожного гула наоборот хочется ровного баланса — переключается одним тапом, и не нужно лезть в настройки приложения на ходу.
Bit-perfect и согласование частот
Автоматическое согласование sample rate с AVAudioSession для подключенных ЦАПов. 24-bit/192 кГц проходят без даунсемплинга, если ЦАП их поддерживает. Никакого принудительного 48 кГц, в отличие от штатного маршрута Apple Music на стороннем ЦАПе.
Отдельный экранчик «Качество звука на виду» — одно касание ⓘ и на экране плеера видно:
-
исходный формат файла;
-
идет ли воспроизведение без ресэмплинга;
-
что происходит с цепочкой при Bluetooth, AirPlay, USB-ЦАПе.
Без маркетингового тумана. Если ты выводишь lossless через AirPods Pro — там пишется честно: «по Bluetooth, поток ужат до AAC». Никаких hi-res-значков^ которые по факту лгут при воспроизведении через Bluetooth.
CUE-альбомы
Один большой файл + .cue-разметка — и парсер раскладывает его на отдельные виртуальные треки, с правильными границами и тегами. Так раздают часть рипов: винил, концерты, иногда классику. Без поддержки CUE такой альбом — это один сплошной трек на сорок минут, по которому не перескочишь. С поддержкой — нормальный альбом, каким он и задуман.
ReplayGain и лимитер
Поддержка тегов ReplayGain (album/track gain) + мягкий лимитер на выходе — чтобы EQ и Loudness не клиппили, но в нормальной ситуации совершенно не вмешивались. Включается отдельно: я лично включаю и забываю.
Распознавание музыки: почему удобный способ пришлось выбросить ShazamKit с iOS 15 предлагает SHManagedSession — путь «открыл и забыл». Apple его и рекомендует: сессия сама поднимается, сама слушает микрофон, сама отдает результат. Идеально — пока распознавание живет в приложении в одиночку.
У меня оно живет внутри плеера. И тут вылезает то, о чем документация молчит: закончив работу, managed-сессия закрывает микрофонную сессию по-своему — и заодно роняет мой audio engine, который держит воспроизведение. Удобный API чинит одно и ломает другое. Распознал трек — потерял звук.
Пришлось спуститься на уровень ниже, к ручному SHSession. Сам поднимаю AVAudioEngine на запись, сам кормлю буфер в matchStreamingBuffer, попутно с того же буфера считаю уровень для пульсирующего кружка в интерфейсе, чтобы было видно, что приложение «слышит». После распознавания возвращаю аудиосессию в .playback руками. Кода больше, зато без побочек. Своя музыка не вздрагивает.
Историю находок храню в JSON, обложки — в Caches. Ссылка на Apple Music на карточке результата — не прихоть, а обязательное условие ShazamKit: пользуешься их каталогом — веди в их магазин. Честная сделка.
Радио — и как родился координатор
Когда дошел до онлайн-радио, столкнулся с архитектурной задачей. AVAudioEngine не любит потоки без длины. Можно было заворачивать стриминг в кастомный загрузчик, но это путь страдания. Решение пошло другим путем. Радио играет через отдельный AVPlayer.
Возникает вопрос: как два движка делят системные ресурсы? AVAudioSession единственная, MPNowPlayingInfoCenter — глобальный, MPRemoteCommandCenter — тоже. Плюс надо аккуратно обрабатывать прерывания.
Так родился AudioCoordinator, который владеет общими системными поверхностями и держит activeSource: .none / .library / .radio. Каждый плеер реализует протокол AudioSourcePlayer и регистрируется как источник. При смене источника координатор пинает старый: «эй, я тебя отключаю». Таймер сна тоже один, общий для двух источников.
Самый «терапевтический» рефакторинг во всем проекте — расщепить монолитный AudioPlayer на арбитра и два плеера. Делал маленькими шагами, с проверкой на устройстве после каждого. И это оказалось редким инженерным счастьем, когда большой кусок архитектуры переезжает, разрезается на части, собирается заново, и просто продолжает играть. Ни одного «почему оно молчит». Все перенеслось, все разделилось, музыка играет, приложение работает.
Радио устроено просто: пользователь добавляет станцию по ссылке на поток. Каталога нет — это сразу снимает вопросы лицензий и убирает соблазн превратить плеер в агрегатор чужого контента.
Любопытнее техническая часть. DSP-цепочки на потоке нет вообще: радио и так уже lossy, гонять ужатый звук через эквалайзер и loudness — это полировать то, что уже потеряно. А вот неочевидный момент — пауза. Если просто запомнить позицию и потом нажать play, AVPlayer попробует доиграть с того места в буфере — а оно давно протухло, эфир-то ушел вперед. Поэтому на резюме я пересоздаю AVPlayerItem: поток подхватывает реальный текущий эфир, а не застрявший в памяти кусок из прошлого. В документации про это не сказано — доходишь сам, когда ловишь рассинхрон.
CarPlay — отдельная история
Заявку на CarPlay Audio entitlement я подал и ждал ее 2.5 недели. Это нормально, но эмоционально долго. Когда пришло «да», ты внезапно понимаешь: entitlement — это просто разрешение. Сам CarPlay в приложении нужно еще написать.
Здесь обнажилась самая интересная сторона CarPlay-фреймворка — тебе не дают рисовать свой UI. Совсем. Только готовые шаблоны (CPListTemplate, CPTabBarTemplate, CPNowPlayingTemplate, CPListImageRowItem и прочие). Это разумно (безопасность за рулем, о которой часто говорит Apple), но иногда жмет.
Мой стек граблей в CarPlay:
-
Раздельная сцена и общие сервисы. CarPlay — это вторая сцена в том же процессе. Если ты создаешь
LibraryStore,AudioPlayer, координатор в SwiftUI App, то CarPlay-делегат об этом не знает. Решение: одинAppServices.shared(@MainActor-синглтон) с инстансами. Обе сцены берут оттуда. Иначе у тебя два независимых плеера. -
Гонка загрузки. При холодном старте прямо в CarPlay (сел в машину, не открывая приложение на телефоне) загрузка библиотеки/станций жила в
.taskSwiftUI-сцены, которая не запускалась. CarPlay видел пустые списки. Пришлось вынести стартовый bootstrap вAppServices.ensureLoaded(), который зовут обе сцены, и пересобирать корневой шаблон после завершения. Теперь работает. -
Apple Music визуально не повторить. Их широкие плитки 2-в-ряд с иконкой слева — приватная верстка их собственного приложения. Стандартное публичное API дает либо обычный список (одна колонка), либо
headerGridButtons(компактная сетка, иконка сверху, подписи обрезаются). Я попробовал второе — выглядит криво на узких экранах, подписи режутся. В итоге — обычный список. Это не «хуже Apple», это нормальный путь, которым идут все сторонние плееры. -
SF Symbols в CPListItem не тинтятся. Сюрприз. В строках CarPlay картинка рисуется как есть, без перекраски. Template-символ выходит черным, его не видно на темном фоне. Решение: растрировать символ в bitmap с залитым белым и пометить
.alwaysOriginal. Корявенько с точки зрения кода, но другого пути нет — это упоминается в форумах Apple Developer. -
Кнопки навбара не показываются на корне вкладки. Положил
trailingNavigationBarButtonsс лупой поиска — ее нет. Это поведениеCPTabBarTemplate: на корневых шаблонах вкладок навбар-кнопки не отображаются. Перенес поиск в строку. Потом вообще убрал — пришел к выводу что он и не нужен. -
iOS 26 переписал API ленты обложек.
CPListImageRowItem(text:images:)пометили deprecated, новый API — через конкретные подклассы (CPListImageRowItemGridElementи компания). БазовыйCPListImageRowItemElement— абстрактный, без публичного init. Я угадал сигнатуру неправильно, поймал ошибку компиляции, полез в SDK-заголовки — там все оказалось четко. -
Обложки в альбоме подгружаются с задержкой. Сначала список появлялся текстом, через секунду появлялись миниатюры. Apple советует подгружать картинки и только потом показывать шаблон. Для альбома обложка одна — грузим один раз, ставим на все строки, паузу в ~100 мс перед открытием почти не видно, зато список появляется уже с обложками.
В Now Playing — стандартные CPNowPlayingShuffleButton/CPNowPlayingRepeatButton, плюс кастомные звездочка (лайк) и Loudness-тумблер (иконка l.circle / l.circle.fill). На радио все эти кнопки скрыты за ненадобностью, т к у потока нет ни перемешивания, ни лайка, ни DSP.
Итог по CarPlay: фреймворк хороший, но ожидания по возможностям приходится мягко гасить. Делаешь то, что доступно. Полезности это не отнимает.
Локализация на 11 языков
Я не переводчик. Но приложение на 11 языках: русский, английский, немецкий, французский, китайский, японский, испанский, итальянский, португальский, корейский, турецкий.
Делается это так. Сначала я пишу все надписи на русском — это оригинал. Потом перевожу их на остальные языки: где-то сам, где-то с помощью инструментов. Каждая фраза лежит в одном общем файле и помнит, на какие языки ее уже перевели. Поэтому для следующего обновления не нужно переводить все заново — только новые надписи, которых раньше не было.
Описание в App Store и текст «Что нового» к каждому релизу — по той же схеме. Рутины много, но она монотонная. Добавил несколько новых строк — перевел несколько новых строк, остальное уже готово.
Что не сделал и почему
Чтобы не выглядело идеально)
-
Метаданные «сейчас в эфире» (ICY) для радио.
AVPlayerих из обычных Icecast-потоков не вытягивает — нужен свойAVAssetResourceLoaderDelegate. На HLS можно черезtimedMetadata, но HLS-потоков среди типичных радио меньшинство. Делать не на всех радиостанциях — будет выглядеть как баг для пользователя: тут есть, а тут нет. Или делать полноценно, или не делать. Пока не делаю. -
Радио-каталог. Отказался по соображениям лицензионной чистоты. Только пользовательские URL.
-
Up Next / album-artist кнопки в Now Playing. Опциональные, закрывают потребность 90% и без них.
Философия Lumiya — не «маленький Spotify», и я даже не пытаюсь им быть. На поле стримингов я проиграю все: охват, рекомендации, инфраструктуру, каталог в десятки миллионов треков. Глупо соревноваться там, где у тебя нет ни единого шанса.
Поэтому я играю в другую игру — для тех, у кого музыка уже есть. Кто понимает простую вещь: любимый альбом может в любой момент уйти из стрима — переехать к другому правообладателю, выпасть из региона, вернуться пересведенным. А файл на диске не денется никуда. Он твой. Тот самый рип, та самая версия, то самое качество — ровно как ты его сохранил.
Без аккаунтов, без телеметрии, полностью оффлайн. Плеер ничего о тебе не знает и знать не хочет. Он просто играет твою музыку.
Если ты держишь коллекцию локально и тебе это близко — Lumiya для тебя. Если нет — стриминги прекрасны. Просто это разные истории.
Если твоя — Lumiya в App Store.
ссылка на оригинал статьи https://habr.com/ru/articles/1043690/