Приветствую, сообщество Habr!
Меня зовут Дмитрий, я инженер-программист с почти 20-летним опытом, последние 12 лет живу в Лондоне. Я увлекаюсь Hi-Fi, не представляю свою жизнь без музыки и хочу рассказать о Kalinka — открытом музыкальном стримере, который появился на стыке трёх моих интересов: музыки, программирования и Linux.
За несколько лет Kalinka выросла из небольшого эксперимента с Raspberry Pi в полноценную систему с собственным нативным аудиоплеером, сервером, клиентским приложением, поддержкой локальной музыкальной коллекции и стриминговых сервисов.
В этой статье я сначала кратко расскажу, почему вообще начал разрабатывать собственный стример и как устроена система на высоком уровне. Затем сосредоточусь на одной конкретной задаче: как реализовать локальный поиск музыки по звучанию и настроению, заставить его работать на Raspberry Pi с 4 ГБ памяти и объективно измерить качество результатов.
Другие важные части проекта — бит-в-бит-воспроизведение через ALSA без промежутков между треками, устройство нативного аудиографа, выбор UI-фреймворка и причины, по которым я остановился на Flutter, а также система доставки клиентам уведомлений об изменениях состояния плеера в реальном времени — заслуживают отдельных разборов. Здесь они будут упомянуты только как части общей архитектуры.

Как всё начиналось
В далёком 2018 году я приобрёл ресивер Yamaha R-N602 и пару колонок, впоследствии заменённых на KEF R3. У Yamaha есть собственное решение MusicCast, которое поддерживает воспроизведение музыки из разных стриминговых сервисов, а также передачу аудиопотока между несколькими ресиверами, находящимися в разных комнатах. К сожалению — а может, и к счастью, — эти функции мне особо нужны не были: MusicCast я использовал только для прослушивания музыки через стриминговые сервисы. Я сменил несколько из них — Tidal, Deezer — пока не остановился на Qobuz, который меня полностью устраивал.
Сервис был мне удобен. Единственное, что меня смущало, — само приложение MusicCast, которое предлагало базовый набор функций и фактически частично заменяло официальное приложение Qobuz. Всё это позволяло слушать музыку в высоком качестве — до 192 кГц / 24 бит. Однако MusicCast был очень ограниченным и, например, не поддерживал рекомендации (autoplay) на основании треков в очереди или поиск похожих альбомов. Всё, что в нём было, — несколько каталогов и поиск по ключевым словам с интерфейсом в виде списков.
Но больше всего меня раздражало то, что ресивер использовал совсем уж медленный Wi-Fi, а настройки буферизации были явно неоптимальны: все треки в 192 кГц начинались с лёгкого затыка, который был особенно слышен в композициях без тишины в начале. Поначалу я об этом не знал. Например, трек Don’t Know Why Норы Джонс начинался с такого затыка, и я верил, что так и должно быть, пока случайно не включил его на YouTube.
Тут мой мир перевернулся. Мой мозг аудиофила, привыкший «слышать» разницу между FLAC 48 кГц и FLAC 192 кГц, не мог принять тот факт, что я что-то упускаю, а инженерный склад ума заставил меня искать решение.
Первым и очевидным вариантом было просто подключить ресивер к сети по кабелю. Но тянуть кабель через всю квартиру было не лучшей идеей, поэтому эта опция сразу отпала. Другой вариант — найти устройство с более качественным Wi-Fi-приёмником, которое могло бы передавать соединение по проводу к Yamaha. Я уже не помню бренды этих устройств — что-то китайское, — но ни одно из них так и не заработало.
В конечном итоге я решил создать такой мост сам и купил отдельный Raspberry Pi 4. После длительных танцев с бубном мне удалось организовать мост между беспроводной и проводной сетями: проблема решилась, и затыки исчезли. Однако появились другие сложности. Мост оказался неустойчивым, особенно при работе с SSDP: MusicCast постоянно терял устройство и отказывался работать. Тем временем мне всё сильнее не хватало нормальных рекомендаций и autoplay от Qobuz. В результате я твёрдо решил искать альтернативные способы стриминга и первым делом купил плату HiFiBerry Digi2 Pro.
Я посмотрел на готовые решения вроде Volumio и Roon, но они меня не устроили. Roon требовал платной подписки, а Volumio хотел дополнительную плату за воспроизведение из Qobuz — при том, что за сам Qobuz я уже платил, и немало. Примерно тогда же мне попался на глаза «утёкший» API Qobuz вместе с утилитой qobuz-dl, написанной на Python. Она скачивала bundle.js с сайта Qobuz и с помощью регулярных выражений находила ключ доступа. Я поэкспериментировал с инструментом и понял: похоже, я могу собрать собственное решение — не хуже, но без лишних свистелок. Так всё и началось.
Вниз по кроличьей норе
Первым делом мне нужен был плеер, который воспроизводит трек из очереди точно как оригинал — без ресемплинга и скрытых манипуляций со звуком, — но при этом позволяет использовать Python и части qobuz-dl. Я перебрал готовые решения — MPD, miniaudio, PortAudio — и каждый раз сталкивался с проблемой: либо не было поддержки декодирования FLAC, либо происходила принудительная конвертация в float32, как в miniaudio, либо решение не работало со ссылками Akamai на файлы, возвращаемыми Qobuz. Эти ссылки генерируются по запросу и истекают примерно через час. В частности, из-за этого был отброшен MPD.
В результате стало ясно: выводить звук всё равно придётся через нативный код — libFLAC и ALSA, — а значит, и сам плеер логично было писать на C++. Так появился модульный аудиограф: каждый этап, включая чтение из сети, декодирование и вывод в ALSA, работает в отдельном потоке, а данные передаются между узлами через ограниченный буфер с так называемым обратным давлением.
Со временем плеер научился воспроизводить музыку бит-в-бит и без промежутков (gapless) между треками, а сервер — управлять очередью и отправлять подключённым клиентам изменения состояния в реальном времени. Параллельно развивалось клиентское приложение: после экспериментов с несколькими UI-фреймворками, включая Kivy, я остановился на Flutter.
Каждая из этих задач оказалась отдельной кроличьей норой и, как я упомянул выше, заслуживает отдельной статьи. Поэтому здесь я не буду подробно разбирать их реализацию и вернусь к той части проекта, которой посвящён основной материал.
По мере развития Kalinka жёсткая привязка к Qobuz стала выглядеть всё более рискованной: API мог измениться, а значительная часть интересующей меня музыки, в частности российских исполнителей, вообще отсутствовала в каталоге сервиса. Я переработал систему в модульную архитектуру, где Qobuz стал лишь одним из подключаемых источников, а локальная музыкальная коллекция постепенно заняла центральное место.
К этому моменту у меня уже была довольно большая коллекция музыки, и я столкнулся со знакомой многим проблемой: музыки много, а включить что-то под настроение тяжело. Теги тут почти не помогают: ни жанр, ни год, ни название альбома или трека не описывают, как звучит композиция. Текстовый поиск по исполнителю, названию трека или альбома тоже не решает проблему, поскольку нужно заранее знать, что именно ищешь. Мне хотелось немного другого: описать настроение запросом вроде something melancholic for tonight и получить для прослушивания подборку спокойных треков из собственной коллекции.
Первая попытка: предобученные теггеры
Самое очевидное решение — автоматически разметить всю библиотеку и искать уже по полученным меткам. В составе проекта Essentia есть целый набор предобученных моделей TensorFlow именно для этой задачи, поэтому я подключил три из них: классификатор жанров Discogs-400, модель кластеров настроения MIREX и модель оценки «танцевальности». Для каждого трека вычислялся набор предсказанных тегов (tags_predicted), по которым затем выполнялся поиск.
|
Predicts |
Model (backbone → head) |
Output |
|---|---|---|
|
Genre |
EffNet-Discogs → Discogs-400 |
top-N genre labels + scores |
|
Mood |
VGGish → MIREX mood |
one of 5 mood clusters |
|
Danceability |
VGGish → danceability |
a single float |
Таблица 1. Первые модели
Это работало… но довольно разочаровывающе. Классификатор жанров оказался шумным: подозрительно большое количество треков получало метку electronic---ambient независимо от того, что это была за музыка. Более того, жанры вообще определялись лишь примерно для трети моей библиотеки. Модель настроения практически сводила всю коллекцию к трём-четырём кластерам. Если посмотреть на опубликованные результаты этих моделей, становится понятно почему: их качество невысокое — PR-AUC меньше 0,2, поскольку задача многометочной классификации сама по себе очень сложна.
Даже если не учитывать посредственное качество тегов, вся эта схема получалась слишком тяжёлой. TensorFlow вместе со всеми зависимостями — совсем не то, что хочется устанавливать на Raspberry Pi.
Ставка на CLAP
Параллельно я уже вычислял эмбеддинг каждого трека с помощью CLAP (Contrastive Language-Audio Pretraining).
Главная идея CLAP показалась мне очень привлекательной: аудио и текст отображаются в одно и то же векторное пространство. Это позволяет превратить текстовый запрос в эмбеддинг и искать ближайшие по звучанию композиции вообще без каких-либо промежуточных тегов.
Довольно быстро стало понятно, что именно это и есть правильное решение, а вся система автоматического тегирования была лишь временным костылём.
Чтобы CLAP хорошо работал на Raspberry Pi, пришлось пройти через несколько итераций оптимизации. Я отказался от связки PyTorch + laion-clap в пользу ONNX Runtime: его значительно проще распространять, и он заметно легче при запуске. Я также заменил универсальную модель, обученную на звуках, моделью на базе HTSAT, специально обученной на музыке.
|
Query category |
P@10 |
Lift over random |
MRR |
Hit-rate@10 |
|---|---|---|---|---|
|
Genre |
0.296 |
+0.212 |
0.670 |
0.880 |
|
Instrument |
0.273 |
+0.157 |
0.476 |
0.933 |
|
Mood / theme |
0.140 |
+0.050 |
0.315 |
0.733 |
|
Overall |
0.247 |
+0.153 |
0.520 |
0.855 |
Таблица 2. Оценка точности модели CLAP на базе HTSAT
Поскольку модель одновременно «слышит» лишь около десяти секунд аудио, я стал выбирать несколько фрагментов каждого трека и усреднять их эмбеддинги.
Именно здесь проявилось первое фундаментальное ограничение. CLAP обучался на английском языке, поэтому русскоязычные запросы — а примерно половина моей библиотеки именно такая — не могут использовать семантический поиск и пока автоматически откатываются к обычному текстовому поиску. Исправить это всё ещё есть в планах.
Как измерить качество — и что CLAP умеет плохо
«Вайб» или «настроение» — слишком субъективные понятия, чтобы на них можно было ориентироваться при разработке. Поэтому, прежде чем что-либо оптимизировать, я собрал полноценный бенчмарк на датасете MTG-Jamendo, где музыкальные треки размечены людьми.
Полученные результаты оказались одновременно понятными и немного отрезвляющими.
CLAP действительно хорошо справляется с конкретными характеристиками звучания. Поиск по инструментам работает отлично: например, для фортепиано P@10 достигает 0,80, то есть восемь из десяти первых результатов оказываются релевантными. Хорошие результаты получаются также для гитары, струнных и скрипки. Среди жанров особенно хорошо распознаются hip-hop и jazz.
А вот с абстрактными эмоциональными понятиями всё значительно хуже. Запросы вроде uplifting, inspiring или love показали практически нулевой результат — хуже случайного выбора треков.
|
|
Strong (P@10) |
Weak (P@10) |
|---|---|---|
|
Instruments |
piano 0.80, guitar 0.50, acoustic guitar / strings / cello 0.40 |
synthesizer 0.30, voice 0.10, “electronic production” 0.00 |
|
Genres |
hip-hop 0.80, jazz / rock / electronic 0.60 |
pop 0.20, techno / world 0.00 |
|
Mood / theme |
epic 0.50, film / meditative 0.30 |
uplifting / inspiring / love 0.00 |
Таблица 3. Результаты оценки CLAP
В среднем запросы по жанрам и инструментам заметно превосходят случайную выдачу — улучшение составляет +0,21 и +0,16 соответственно, — тогда как поиск по настроению почти не отличается от случайного выбора (+0,05).
Оказалось, что это давно известное свойство CLAP. Но увидеть его воспроизведённым на собственном бенчмарке было даже приятно: вместо размытого ощущения, что поиск по настроению работает слабо, появилось конкретное число и стало понятно, в каком направлении двигаться дальше.
Исправляем поиск по настроению: небольшая модель поверх CLAP
Я подумал: если эмбеддинги CLAP хорошо описывают само звучание музыки, возможно, небольшая дополнительная модель сможет научиться извлекать из них эмоциональную составляющую?
Для этого отлично подходил датасет DEAM, где около 1800 композиций вручную размечены по двум шкалам:
-
valence — насколько музыка позитивная;
-
arousal — насколько она энергичная.
Я вычислил для DEAM такие же CLAP-эмбеддинги и обучил поверх них совсем небольшую модель — двухслойный MLP всего на 66 тысяч параметров, отображающий эмбеддинг в точку (valence, arousal).
Смеха ради: обучение происходило на моём ноутбуке Lenovo ThinkBook 13x, который грелся так, что я пару раз обжёг пальцы. В итоге я нашёл выход и стал держать его под кондиционером. Наверное, всё-таки стоит потратиться на охлаждающую подставку.
Первая попытка оказалась хорошим уроком о train/serve skew: для расчёта эмбеддингов датасета DEAM я использовал модель CLAP, обученную на звуках, хотя в рабочей системе уже перешёл на модель, обученную на музыкальных треках.
На реальных эмбеддингах качество оказалось катастрофическим: коэффициент детерминации R² составил −0,42. Иными словами, модель работала хуже наивного алгоритма, который присваивал бы всем трекам одно и то же среднее значение.
После того как я обнаружил ошибку и переобучил модель на тех же эмбеддингах, которые использовались в рабочей системе, результат сразу вырос до R² = 0,59 для valence и R² = 0,71 для arousal.
|
Axis |
Model |
R² |
CCC |
RMSE |
|---|---|---|---|---|
|
Valence |
MLP head |
0.594 |
0.761 |
0.755 |
|
Arousal |
MLP head |
0.711 |
0.827 |
0.683 |
|
Valence |
ridge baseline |
0.480 |
0.626 |
0.855 |
|
Arousal |
ridge baseline |
0.631 |
0.742 |
0.773 |
Таблица 4. Оценка точности полученной модели V/A
Полученный сигнал настроения я добавил в ранжирование через механизм оценки уверенности. Благодаря этому он влияет только на запросы, действительно связанные с эмоциями, и не вмешивается, когда пользователь ищет, например, piano или hip-hop.
После повторного запуска бенчмарка качество поиска по настроению выросло примерно на 80%: улучшение относительно случайной выдачи увеличилось с +0,05 до +0,09, а P@10 для запроса uplifting поднялся с 0,00 до 0,30. При этом поиск по жанрам и инструментам ухудшился настолько незначительно, что этим можно было пренебречь. Вполне удачный компромисс.
|
Категория запроса |
P@10 |
Случайный baseline |
Во сколько раз лучше случайного |
|---|---|---|---|
|
Жанр |
0.296 |
0.084 |
3.5× |
|
Инструмент |
0.273 |
0.116 |
2.4× |
|
Настроение / тема |
0.140 |
0.090 |
1.6× |
|
Итого |
0.247 |
0.094 |
2.6× |
|
Тег |
P@10 |
Случайный baseline |
Во сколько раз лучше |
|---|---|---|---|
|
hip-hop |
0.80 |
0.063 |
12.7× |
|
trance |
0.40 |
0.032 |
12.5× |
|
jazz |
0.60 |
0.067 |
9.0× |
|
rock |
0.60 |
0.083 |
7.2× |
|
piano |
0.80 |
0.277 |
2.9× |
Таблица 5. Итоговая оценка точности
Чтобы всё поместилось
На Raspberry Pi с 4 ГБ ОЗУ основной проблемой оказалась память, а не производительность. Обработка нескольких треков подряд приводила к постоянному росту потребления памяти, пока она не заканчивалась и система не убивала процесс из-за OOM. Помогла принудительная очистка памяти после каждой итерации с помощью сборщика мусора Python.
В дальнейшем наибольший эффект дали две оптимизации.
Во-первых, я квантизировал сохранённые эмбеддинги до int8. Размер векторов уменьшился в четыре раза, при этом сохранилось около 99% точных попаданий в топ-10 — практически бесплатная оптимизация.
Во-вторых, я разделил модель CLAP на две части. Текстовый энкодер, необходимый для обработки пользовательских запросов, постоянно остаётся в памяти. Более тяжёлый аудиоэнкодер автоматически выгружается после завершения индексации и не занимает память без необходимости: он нужен только для начальной индексации и при добавлении новых треков в базу.
Избавляемся от лишнего
В конечном итоге семантический поиск уже полностью обеспечивали CLAP-эмбеддинги, а небольшой предсказатель valence/arousal успешно решал задачу определения настроения, фактически выполняя обе функции, ради которых раньше использовались теггеры Essentia. Причём делал это качественнее и с гораздо меньшими затратами ресурсов.
Сами теггеры тем временем превратились в источник проблем.
Пакет essentia-tensorflow публикует готовые сборки для ARM только под Python 3.11, из-за чего всё окружение оказалось незаметно привязано именно к этой версии Python — как раз тогда, когда мне понадобилось перейти на Python 3.13.
Поэтому я полностью удалил весь конвейер автоматического тегирования.
Вместе с ним исчезли TensorFlow, многосотмегабайтная зависимость, жёсткая привязка к Python 3.11, столбец tags_predicted и вся логика ранжирования, основанная на тегах.
Что пришло на замену определению жанров и танцевальности? Ничего отдельного — CLAP уже умел это делать.
Что заменило модель определения настроения? Собственная небольшая модель, которую я обучил сам.
Что получилось в итоге
Умный поиск, который сегодня используется в Kalinka, состоит всего из двух компонентов: CLAP-эмбеддингов аудио и небольшой модели на 66 тысяч параметров, предсказывающей valence и arousal. Всё это работает локально через ONNX Runtime — без TensorFlow, внешних теггеров и облачных сервисов.

Поэтому рядом в результатах поиска вполне могут оказаться русская песня и композиция Pink Floyd, если они похожи по настроению. Система ориентируется не на названия или жанры, а на то, как действительно звучит музыка.
И всё это помещается на Raspberry Pi 4 с 4 ГБ оперативной памяти.
Умный поиск в Kalinka — лишь часть системы рекомендаций на основании пользовательского запроса. Она также включает полнотекстовый поиск FTS через плагин SQLite, поиск с помощью запросов к стриминговым сервисам и семантический поиск по каталогам. Для сопоставления названий разделов каталогов стриминговых сервисов с запросом пользователя используется другая модель. Результаты текстового поиска ранжируются с помощью модифицированного RapidFuzz, а результаты умного поиска отображаются только тогда, когда не найдено точных совпадений. Это позволяет использовать один и тот же интерфейс для разных типов пользовательских запросов.
Что дальше?
В последнее время я много работал над упрощением установки и настройки системы, в частности над сценарием первого запуска. Мой хороший друг помог мне с полным тестированием и предоставил необходимую обратную связь, за что я ему очень благодарен.
Я также добавил поддержку другого бесплатного стримингового сервиса — Jamendo — и сумел реализовать там семантический поиск, используя только компоненты и модели, работающие локально. Об этом я охотно напишу в другой раз.
Я продолжаю изучать возможности дальнейшего улучшения поиска. Например, сейчас модель не умеет искать музыку по стране происхождения или году: запрос italian 80s не находит в моей коллекции композиции Тото Кутуньо из 1980-х.
Kalinka — открытый проект, и я буду рад новым участникам и тестировщикам. Помочь можно не только кодом: полезны тестирование на разном оборудовании, отчёты об ошибках, предложения по интерфейсу, документация и новые плагины.
Использование AI-инструментов при разработке не запрещено. Для меня важнее не то, каким способом был написан код, а то, чтобы автор понимал предложенное решение, мог его объяснить и поддерживать, а сам код был проверен, протестирован и соответствовал архитектуре проекта. Pull request не будет отклонён только потому, что при его подготовке использовался AI.
ссылка на оригинал статьи https://habr.com/ru/articles/1060948/