Я перестал доверять стримингам. Поэтому написал свой iOS-плеер — с CarPlay и без вранья про звук

от автора

Я перестал верить стримингам. Не философски, а практически. Половина любимых альбомов либо ушла из каталогов, либо вернулась пересведенной так, что слушать тошно. Концертные записи, винил‑рипы, региональные релизы — их там и не было. А моя коллекция в 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:

  1. Раздельная сцена и общие сервисы. CarPlay — это вторая сцена в том же процессе. Если ты создаешь LibraryStore, AudioPlayer, координатор в SwiftUI App, то CarPlay-делегат об этом не знает. Решение: один AppServices.shared (@MainActor-синглтон) с инстансами. Обе сцены берут оттуда. Иначе у тебя два независимых плеера.

  2. Гонка загрузки. При холодном старте прямо в CarPlay (сел в машину, не открывая приложение на телефоне) загрузка библиотеки/станций жила в .task SwiftUI-сцены, которая не запускалась. CarPlay видел пустые списки. Пришлось вынести стартовый bootstrap в AppServices.ensureLoaded(), который зовут обе сцены, и пересобирать корневой шаблон после завершения. Теперь работает.

  3. Apple Music визуально не повторить. Их широкие плитки 2-в-ряд с иконкой слева — приватная верстка их собственного приложения. Стандартное публичное API дает либо обычный список (одна колонка), либо headerGridButtons (компактная сетка, иконка сверху, подписи обрезаются). Я попробовал второе — выглядит криво на узких экранах, подписи режутся. В итоге — обычный список. Это не «хуже Apple», это нормальный путь, которым идут все сторонние плееры.

  4. SF Symbols в CPListItem не тинтятся. Сюрприз. В строках CarPlay картинка рисуется как есть, без перекраски. Template-символ выходит черным, его не видно на темном фоне. Решение: растрировать символ в bitmap с залитым белым и пометить .alwaysOriginal. Корявенько с точки зрения кода, но другого пути нет — это упоминается в форумах Apple Developer.

  5. Кнопки навбара не показываются на корне вкладки. Положил trailingNavigationBarButtons с лупой поиска — ее нет. Это поведение CPTabBarTemplate: на корневых шаблонах вкладок навбар-кнопки не отображаются. Перенес поиск в строку. Потом вообще убрал — пришел к выводу что он и не нужен.

  6. iOS 26 переписал API ленты обложек. CPListImageRowItem(text:images:) пометили deprecated, новый API — через конкретные подклассы (CPListImageRowItemGridElement и компания). Базовый CPListImageRowItemElement — абстрактный, без публичного init. Я угадал сигнатуру неправильно, поймал ошибку компиляции, полез в SDK-заголовки — там все оказалось четко.

  7. Обложки в альбоме подгружаются с задержкой. Сначала список появлялся текстом, через секунду появлялись миниатюры. 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/