← Гордеев Виталий · все работы
Кейс · свой продукт
NeuroDJ · десктопное приложение

ИИ-диджей, который сводит сам

Приложение ведёт музыкальный сет без человека за пультом: выбирает следующий трек по темпу, тональности и энергии, находит в нём точку входа и сводит так, как свёл бы живой диджей. Это мой собственный продукт. Дальше расскажу, что он умеет, как устроен и что я делаю, когда что-то ломается.

Сайт продукта: neuro-dj.ru ↗

60 000строк Python в 133 модулях серверной части
627автотестов
441трек в рабочей библиотеке, на ней я всё и проверяю
65%попаданий разметки на слепом тесте на слух, лучший результат из трёх способов
Промо · 20 секундПромо продукта: пластинка, очередь треков и деки нового интерфейса.
Промо · 29 секундСцены как на сайте продукта: сфера, разметка трека, деки в фазе, микшер.
Первый экран сайта neuro-dj.ru: сфера и заголовок «Сводит сам.»
Сайт · neuro-dj.ruСайт продукта: сцены сменяются при прокрутке, 3D-ноутбук с настоящим интерфейсом программы. Открыть сайт ↗
Главное окно NeuroDJ: две деки с треками, волны, микшер, очередь следующих треков
Главное окноДве деки, микшер посередине, снизу очередь. Треки загружены, на полосах под названием видна размеченная структура.
01 · Задача

Что делает
живой диджей

Чтобы машина играла как человек, её надо научить трём вещам. Каждую пришлось решать как отдельную инженерную задачу.

Выбрать следующий трек. Одного похожего темпа мало. Сет строится дугой: разогрев, подъём, пик, передышка, финал. Программа помнит, в какой точке сейчас вечер, и берёт трек с нужной энергией, который ложится по тональности на текущий.

Найти, где сводить. У каждого трека есть вступление, куплеты, припевы, затишья и концовка. Заходить надо на границе фразы, посреди припева нельзя. Поэтому трек размечается заранее: где что начинается.

Сделать переход. В коде есть набор рецептов: плавный уход, резкая склейка на дроп, подмена вокала, эхо на выходе. Какой из них взять, зависит от пары треков. Если для рецепта не хватает данных, скажем, вокал не отделён, программа его пропускает и берёт другой.

Вкладка «Дуга»: график энергии сета на 90 минут с точками, которые можно двигать
Дуга энергииПлан вечера на 90 минут. Точки можно двигать мышкой, есть пресеты: клуб, фест, разогрев.
02 · Устройство

Интерфейс и мозг
в разных процессах

Интерфейс и звук сделаны на Electron. Всё тяжёлое (анализ, выбор трека, решения) крутится в отдельном процессе на Python. Между собой они общаются по WebSocket.

PythonElectronWeb AudioWebSocketSQLitePyTorchall-in-oneDemucslibrosaPyInstallerNuitkaelectron-builder

Клиенту ставить Python не нужно. Сервер я собираю в server.exe, разметку в отдельный allin1_run.exe, и всё это кладу в установщик. Модули лицензирования компилирую в бинарные .pyd, чтобы их нельзя было просто открыть и поправить.

Разметку я вынес в отдельный exe не для красоты. Модели разметки нужна одна версия библиотеки NumPy, остальному серверу другая, и в одну программу они не собираются. Развёл их по двум процессам, и конфликт ушёл.

03 · Как работаю

Шесть правил,
которые я держу

Все шесть выросли из моих же ошибок в этом проекте. Под каждым правилом случай, на котором я его выучил.

01

Сначала замер, потом вывод

«Кажется, стало лучше» не считается. Точность разметки я проверял вслепую, на слух, по 143 меткам. Скорость мерил секундами на трек, нагрузку числом процессов и гигабайтами памяти.

02

Сначала найти причину

Когда программа съедала всю память, проще всего было урезать потоки. Но потоки были ни при чём: чужая библиотека запускала по процессу на каждое ядро. Нашёл эту строку и поправил.

03

Ручная метка важнее любой модели

Если диджей сам поставил метку в треке, ни одна модель её не перетрёт. Порядок источников прописан в коде отдельной лестницей приоритетов.

04

Продукт должен работать без меня

У меня на машине всё может летать, а у клиента не запуститься вовсе. Поэтому про каждую фичу спрашиваю себя: как её запустит человек, у которого нет ни меня, ни моих настроек.

05

Тихий запасной путь

Вокал не разделён, значит рецепт с вокалом не берётся. Модуля разметки нет, работает упрощённый анализ. Падать там, где можно сыграть чуть проще, программа не должна.

06

Каждый разбор записан, с датой и цифрами

Почему сделано так, что пробовали, какие были замеры, всё пишу рядом с кодом. Через месяц не надо заново наступать на те же грабли.

04 · Качество

Слепой тест
на слух

Разметку трека можно получить тремя способами. Гадать, какой лучше, я не стал и проверил все три.

По самолюбию это ударило: собственный анализатор, в который я вложил кучу времени, оказался худшим. Я оставил его в продукте как запасной путь, а главным сделал all-in-one.

Но и 65% значит, что каждая третья метка мимо. Поэтому источники сложены в лестницу, и верхний всегда перекрывает нижний:

  1. Метка, которую диджей поставил руками (эталон)
  2. Разметка моделью all-in-one (основа)
  3. Метки из тегов файла (если модели нечего сказать)
  4. Собственный анализ (запасной, когда больше ничего нет)
Полоса деки: волна трека, над ней пронумерованные метки, под ней части трека: вступление, куплет, припев, инструментал
Разметка на декеСверху метки из тега файла (эталон), снизу части трека от модели: вступление, куплет, припев, инструментал.
05 · Разбор

Один день отладки:
шесть поломок

18 сентября. Жалобы были такие: «анализ идёт 9 часов», «забирает всю память», «разметки нет», «сразу пошла долбёжка». У каждой нашлась своя причина, и каждый раз не там, где я искал сначала.

01

18 процессов и ноль свободной памяти

Симптом
Пакетная разметка библиотеки поднимала 18–19 процессов Python и съедала всю оперативку.
Причина
Внутри чужой библиотеки стоял Pool() без аргументов, то есть по процессу на каждое ядро. На Windows каждый такой процесс заново грузит PyTorch, а это около 700 МБ.
5 процессов и 1,7 ГБ вместо 19 и всей памяти. Хватило одной настройки, которая в библиотеке давно была.
allin1/spectrogram.py:43 — источник
# внутри библиотеки, по процессу на каждое из 16 ядер
pool = Pool()
сталоpython/allin1_run.py
r = allin1.analyze(src, demix_dir=tmp, device="cpu",
                   keep_byproducts=False,
                   multiprocess=False)
02

Девять часов на 157 треков

Симптом
Разметка шла по 3,5 минуты на трек. На оставшиеся 157 треков выходило около девяти часов.
Причина
Перед разметкой каждый трек заново раскладывался отдельной моделью на четыре дорожки. Модель разметки ждёт их на входе, но проверка показала, что сама структура от них почти не зависит.
Как проверил
Прогнал один трек двумя способами и сравнил границы частей. Семь границ совпали до десятой секунды, одна сдвинулась на секунду, одну модель без разделения не нашла.
35 секунд на трек вместо 3,5 минут. 155 треков за полтора часа, ноль ошибок.
сталоpython/allin1_run.py
def stage_self_as_stems(path, tmp):
    """Подсовывает сам трек на все четыре канала — вместо разделения."""
    stem = os.path.splitext(os.path.basename(path))[0]
    dst = os.path.join(tmp, "htdemucs", stem)
    os.makedirs(dst, exist_ok=True)
    for name in ("vocals", "bass", "drums", "other"):
        subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", path,
                        "-ac", "2", "-ar", "44100",
                        os.path.join(dst, name + ".wav")], check=True)
03

«Сразу пошла долбёжка»

Симптом
Сет начинался сразу с самых жёстких треков, без разогрева.
Причина
Дуга энергии молчит, пока не запущен таймер сета. А таймер запускался на строку позже, чем набиралась стартовая очередь. Первые двадцать треков подбирались вообще без плана вечера.
Поменял две строки местами. Теперь стартовая очередь сразу строится с учётом разогрева.
былоpython/dj_brain.py · start_first_track()
self._fill_queue()                      # очередь строится здесь…
if self._set_start_time <= 0:
    self._set_start_time = time.time()  # …а план вечера включается тут
стало
if self._set_start_time <= 0:
    self._set_start_time = time.time()
self._fill_queue()
04

Энергия ни на что не влияла

Симптом
Подбор треков игнорировал энергию, хотя код для неё был.
Причина
Энергия в базе хранится числами от 85 до 468, а пороги сравнения писались под шкалу от 0 до 1. Разница всегда выходила больше порога, и каждый трек получал одинаковый штраф. А одинаковый для всех штраф ничего не решает.
Энергию считаю в долях от размаха. Теперь на разогреве выбираются средние треки, а на пике самые мощные в режиме.
былоpython/dj_brain.py
de = abs(energy - ref_energy)      # 85…468 против порогов 0.05…0.20
if   de < 0.05: s_ref_energy = 8
elif de < 0.12: s_ref_energy = 5
elif de < 0.20: s_ref_energy = 2
else:            s_ref_energy = -2   # срабатывало всегда
стало
_mn_re, _mx_re = arc_energy_range        # реальный размах режима
if _mx_re > _mn_re:
    de = abs(energy - ref_energy) / (_mx_re - _mn_re)
else:
    de = abs(energy - ref_energy)
05

Разметка считалась, но до экрана не доходила

Симптом
У треков с готовой разметкой полоса была почти пустой, без дропов и брейков.
Причина
Причин оказалось сразу три. Сервер ставил хорошую разметку, а через 14 строк молча заменял её худшей. Второй путь загрузки трека про неё не знал вовсе. И четыре метки модели интерфейс просто не умел раскрашивать.
Закрыл все три. Сделал общий метод для обоих путей, защиту от перезаписи и добавил недостающие цвета в палитру.
былоpython/server.py
payload["ssm_sections"] = _segs       # разметка модели…
# …14 строк спустя, внутри другого метода:
payload["ssm_sections"] = merged.get("sections", [])
стало
if payload.get("structure_source") != "allin1" or not payload.get("ssm_sections"):
    payload["ssm_sections"] = merged.get("sections", [])
06

У клиента хороший анализ не работал никогда

Симптом
Кнопка «Полный анализ» на моей машине размечала моделью, а в собранной программе упрощённым способом.
Причина
Модель жила только в моём окружении разработки. В установщик она не попадала, и у клиента программа молча уходила на упрощённый анализ.
Отдельный exe с моделью внутри. Веса модели (11 МБ) вшиты, интернет не нужен. Если exe нет, программа тихо откатывается на упрощённый анализ и не падает.
сталоpython/server.py · поиск модуля разметки
# якорь — сам server.exe: в собранном виде путь к исходнику
# указывает внутрь архива, а модуль лежит рядом с программой
_exe_dir = _P(sys.executable).parent
for _cand in (
    _exe_dir / "allin1" / "allin1_run.exe",
    _exe_dir / "allin1_run.exe",
    SERVER_DIR / "allin1_run.exe",
):
    if _cand.exists():
        _ai1_exe = _cand
        break
06 · Возможности

Что умеет
приложение

Диджейский пульт со всем, что положено, только ведёт его ИИ. Всё, что ниже, есть на экране приложения, и перехватить управление можно в любой момент.

Две деки с волнами треков: подсвечены удары и структура
Волны двух декСверху трек, который играет, снизу следующий. Переход сводится по совпадению долей.
Типы перехода

7 стилей сводки

SMART, RADIO, MEGAMIX, CROSS, CUT, FILTER, LOOP. От плавного, как в радиоэфире, до резкой клубной склейки.

Характер

Интенсивность и момент

Мягко, средне или жёстко. Сводить рано, в середине, поздно или пусть решает автомат.

Дуга вечера

План энергии на весь сет

Длина сета, точки на графике и пресеты: клуб, фест, разогрев, пик, ровно. ИИ подбирает треки под кривую.

Stems 2.0

Вокал отдельно от музыки

Трек делится на вокал и инструментал. Отсюда переходы с подменой вокала и защита от наложения двух голосов.

Микшер

Эквалайзер и кроссфейдер

Хай-хэт, мелодия с голосом, кик и усиление на каждую деку, питч, кроссфейдер, синхронизация темпа.

Живая игра

Хот-кью, лупы, роллы

8 точек на деку, лупы с шагом ½, 1, 2, 4 и 8, слайсер и роллы, отдельные точки входа и выхода.

Саундпад

8 слотов и эффекты

Свои звуки на кнопках, отдельная кнопка SUB DROP и набор эффектов поверх сета.

Сет

Очередь и режимы

ИИ заранее показывает следующие треки с темпом и тональностью. Вкладки режима, стиля и обучения.

Очередь следующих треков с темпом, тональностью и энергией
ОчередьСледующие треки, которые выбрал ИИ: темп и тональность по кругу Camelot у каждого.

Как всё устроено, решаю сам, а результат проверяю на слух и цифрами. Это живой продукт, не демо.

Хотите так же у себя?

Автоматизирую процессы и собираю инструменты, которые потом работают без меня. Работаю удалённо, из Самары.

Telegram · @vitaldemort