Прошлый пост в этом блоге вышел 21 июля. Ritmolux появился в тот же день — вот чем он стал.

Неоновая калейдоскопическая мандала: глубоко-фиолетовое восьмилепестковое ядро в кольце голубых и пурпурных лепестков, с бледными дугами, расходящимися в черноту

Это музыкальный визуализатор реального времени для Windows и macOS. Общее ядро на Rust превращает поток PCM-сэмплов в картинку, отрисованную на GPU, и этим ядром пользуются два фронтенда: отдельное приложение на чистом Rust (winit + wgpu), которому звук приходит из loopback-захвата системного вывода, и компонент foobar2000 — тонкая C++-прослойка над C ABI ядра, получающая сэмплы из visualisation_stream самого плеера. Оба идут в релизы: приложение отрисовывает живой захват WASAPI на Windows, а компонент прикладывается к каждому тегу v*, начиная с v0.70.0.

До 1.0 проект ещё не дошёл и активно меняется. Формат пресетов и C ABI между релизами пока могут ломаться; стабильность начинается с 1.0.0.

Две вещи об этом разделении стоит сказать прежде всего, потому что из них следует всё дальнейшее. Первая: общая часть — это действительно весь движок, от анализа и сцены до рендера и постобработки, а не служебная библиотека, поверх которой построены два приложения. Вторая: границей служит C ABI, а не граница Rust-крейта. Компонент foobar2000 — это C++ DLL, загружаемая в чужой процесс, поэтому всё общее с отдельным Rust-приложением должно было пережить выражение через тринадцать функций extern "C". Ограничение беспощадное, и именно поэтому абстракция устояла: через границу, говорящую только на C, нельзя случайно протащить ни тип Rust, ни время жизни, ни допущение о вызывающем.

Ядро не знает, откуда пришёл звук

Это тот единственный архитектурный факт, из которого следует всё остальное. Ядро принимает чередующиеся или моно PCM-кадры, и ему безразлично, откуда они взялись: внутри core/ нет ни типов WASAPI, ни ScreenCaptureKit, ни foobar. Loopback-захват живёт в бинарнике отдельного приложения, а foobar сам передаёт свои сэмплы в C ABI. Одна кодовая база визуальной части обслуживает оба фронтенда ровно потому, что ни один из них не может дотянуться внутрь неё.

Шов между звуком и картинкой — lock-free кольцевой буфер SPSC. Звук приходит в темпе устройства, кадры рисуются в темпе дисплея, и ни один из циклов не управляет другим. Кольцо вынесено в отдельный крейт rlx-ring без единой зависимости, и вынесено ради конкретной цели: так его может проверять Miri в CI.

Правило для стороны захвата сформулировано как запрет, а не как цель. Аудио-колбэк никогда не блокируется, не выделяет память, не берёт блокировок и не пишет в лог — он передаёт сэмплы в кольцо и возвращается.

flowchart TD
subgraph src["Источники звука"]
  loop["петлевой захват ОС<br/>WASAPI · ScreenCaptureKit"]
  fb["foobar2000<br/>visualisation_stream"]
end
subgraph fe["Фронтенды"]
  sa["отдельное приложение<br/>окно + поверхность GPU"]
  pl["компонент foobar<br/>C++-прослойка над C ABI"]
end
subgraph core["Движок — не знает источника, не знает GPU"]
  ring["lock-free кольцевой буфер — шов"]
  dsp["анализ<br/>спектр · атака · доля"]
  scene["пресет + сцена"]
  render["движок рендеринга"]
  ring --> dsp --> scene --> render
end
loop --> sa
fb --> pl
sa -->|PCM-кадры| ring
pl -->|PCM-кадры, C ABI| ring
render -->|Metal| mac["macOS"]
render -->|DX12 / Vulkan| win["Windows"]

Что происходит между звуком и фигурой

Анализ идёт по хопам — фиксированным шагам по потоку, 512 сэмплов при 48 кГц, — а не по кадрам. Деталь важнее, чем кажется: то, что читает пресет, не зависит от частоты обновления вашего экрана, поэтому один и тот же пресет ведёт себя одинаково на ноутбуке с 60 Гц и на мониторе со 165 Гц.

Частота дискретизации, число каналов и размер буфера проверяются один раз, там, где звук входит в движок. Всё ниже по течению считает их корректными — по принципу: горячий путь, который перепроверяет, это горячий путь, занятый не тем.

Окон FFT работает два, а не одно. Окно на 2048 сэмплов питает всё, что чувствительно ко времени: атаку, долю, темп, а также средние и верхние полосы. Окно на 8192 питает только полосы ниже примерно 246 Гц, потому что бин шириной 23,4 Гц не отличит бочку от басовой ноты. Длинное окно стоит около 85 мс групповой задержки на этих нижних полосах, и это принято как физика, а не скомпенсировано: путь «удар → реакция» его не касается, поэтому бюджет задержки не затронут.

Уровни — это отношения, и в этом весь фокус

bass, mid, treb и onset делятся каждый на свой медленно спадающий бегущий пик, с порогом тишины, чтобы тихая комната читалась нулём, а не усиленным шумом.

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

Цена осознанна и названа: абсолютная динамика скрыта. Тихий и громкий фрагменты оба доходят до 1.0 по своим собственным пикам. Там, где пресету действительно нужна абсолютная величина, её несут bass_raw и три его собрата, ненормированными.

Атаки — это не доли

Детектор атак — это спектральный поток: насколько спектр изменился с прошлого хопа. Он даёт всплеск на атаках, а не на громкости, поэтому долгая нота его не запускает, а приглушённый удар — запускает.

Трекер темпа превращает атаки в оценку BPM и фазу доли: ноль на каждой доле, нарастание к единице перед следующей. Над этим стоят часы такта — какая сейчас доля такта, насколько такт пройден, сколько тактов прошло.

Различие тут принципиальное, и спутать эти вещи легко. Детектор атак срабатывает от 1,2 до 2,3 раза на долю в зависимости от материала. Поэтому счётчик долей — храповик, а не метроном, и поэтому фаза такта существует отдельно. Пресет, принимающий атаки за доли, будет выглядеть верно на ровной бочке и рассыплется на чём угодно синкопированном.

Все сцены едут по одной цепочке

Пресет называет одну систему отрисовки, и эта система рисует кадр — но никогда прямо на экран. Каждая сцена едет по общей композитной цепочке:

фон → сцена → следы → калейдоскоп → переход → чернила → вывод

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

Выигрыш в том, что новая система отрисовки получает всё это бесплатно. Напишите сцену, которая рисует фигуры, — и она сразу получит работающие следы, калейдоскоп, палитру и кроссфейд пресетов. Именно поэтому систем двенадцать, а не три.

Двенадцать систем, и каждая из них — это текст

Всё, что рисует приложение, задаётся пресетом: небольшим TOML-файлом, который называет одну встроенную систему отрисовки и связывает её параметры с короткими выражениями поверх живого анализа звука. Ни Rust, ни шейдеров, ни пересборки.

Светящаяся сине-зелёная розетка из тонких нитей частиц на чёрном фоне

attractor

Радиальная развёртка спектра: цветные спицы, расходящиеся из тёмного центра

spectrum

Крупное золотое окно-роза из вложенных двенадцатиконечных звёзд

star_pattern

Это три системы из двенадцати. Остальные — fragment_field, swarm, parametric_curve, lsystem, reaction_diffusion, emitter, shape_field, shape_collage и warp_mesh.

Полноценный рабочий пресет умещается в десять строк:

system = "parametric_curve"
name = "Ten Lines"

[curve]
family = "maurer_rose"

[params]
n          = "6"
scale      = "0.55 + clamp(bass * 0.35, 0, 0.30)"
brightness = "0.80 + clamp(mid * 0.45, 0, 0.40)"
hue        = "0.55 + time * 0.02"

Четыре вещи в этом файле и составляют всю модель.

system выбирает, что именно рисуется, и всё остальное читается уже относительно этого выбора: n что-то значит для parametric_curve и не значит ничего для swarm. Значения в [params] — это выражения, а не числа: каждое пересчитывается заново на каждом кадре по текущему анализу. bass, mid и time — три слова из словаря, где есть ещё доля, такт, атака, темп, счётчики и доступ к 64-полосному спектру. А основную работу делает clamp(x, lo, hi): bass * 0.35 — это усиление, оно решает, какая часть музыки доходит до параметра, а clamp — предел, он решает, как далеко параметру позволено уйти.

Промахнуться с усилением — самый частый способ получить мёртвый на вид пресет. Правите строку, сохраняете — и запущенное окно подхватывает изменение примерно за 150 мс.

Некоторые системы принимают ещё и структурную таблицу: [curve] в примере выше, а также [generator], [particles], [spectrum]. Она читается один раз при загрузке, а не каждый кадр, и выбирает какую фигуру рисовать; параметры потом эту фигуру анимируют.

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

Что представляют собой эти двенадцать

Перечислить их по именам мало толку, если не сказать, для чего каждая, — поэтому коротко.

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

swarm — около десяти тысяч частиц, симулируемых на CPU и дрейфующих по полю течения, отрисованных инстансированными аддитивными метками. Их мир — тор, поэтому из кадра ничто не уходит и поле остаётся населённым без рывков на респауне.

emitter — противоположность: объекты рождаются, летят по аналитической баллистической траектории, стареют и выбывают. Это единственная система, у которой популяция не фиксирована, — а тор роя, где всё сворачивается по краям, как раз этого и не умеет. Существует она потому, что некоторые вещи должны запускаться долей, а не модулироваться полосой.

parametric_curve — одна непрерывная линия, пересэмплируемая каждый кадр из замкнутой формы, а не кэшируемая, — поэтому звук может вести саму форму, а не только её цвет и масштаб.

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

star_pattern строит звёздчатую розетку Ханкина методом контактного угла: n точек контакта на окружности, лучи, выходящие под непрерывным углом и встречающиеся в вершинах лепестков. Угол непрерывен, поэтому переплетение может раскрываться и закрываться.

reaction_diffusion — симуляция Грея — Скотта, шагающая по паре текстур с пинг-понгом. Она с состоянием: поле каждого кадра зависит от предыдущего, и именно это даёт перестраивающийся, растущий, органический вид, недоступный сценам без состояния.

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

spectrum — прямая развёртка 64-полосного логарифмического анализа: столбцами, ломаной или радиальным кольцом спиц. Единственная система, где звук буквально читается в картинке.

shape_field рисует силуэт как полноэкранное поле расстояний, из-за чего координата палитры становится расстоянием: поднимите число ступеней палитры — и вы получите концентрические эквидистантные контуры этой фигуры, а не концентрические окружности. Это же единственная система, где фигуру можно задать самому, а не выбрать из списка: таблица [path] принимает данные SVG-пути, вставленные прямо из графического редактора, а второй контур позволяет фигуре превращаться в другую по доле такта.

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

Набор замыкает shape_collage.

Список стоит читать как запись решений, а не как перечень возможностей. emitter существует потому, что swarm не умеет менять популяцию. warp_mesh существует потому, что никакое одно преобразование обратной связи по всему кадру не выражает разное движение в разных областях. Каждая добавлялась потому, что конкретную вещь нельзя было выразить тем, что уже было.

Словарь, под который пишется пресет

До слоя выражений доходит около двух десятков read-only переменных: четыре нормированных уровня, их ненормированные двойники, гейт доли, часы доли и такта, темп и горстка тех, что несут положение, а не звук. Выражения компилируются один раз при загрузке пресета и вычисляются раз в кадр, так что пресет стоит нескольких операций с плавающей точкой на параметр, а не разбора.

Грамматика намеренно мала — арифметика, набор функций и select() для ветвления, — потому что это язык связывания, а не программирования. Ни циклов, ни состояния: значение параметра в этом кадре есть чистая функция анализа в этом кадре. Именно это делает пресет описанием, а не программой, и именно поэтому плохой пресет падает при загрузке с сообщением, а не начинает вести себя странно на четырёхтысячном кадре.

Цвет — отдельная поверхность. Пресет может назвать встроенную палитру или задать собственные стопы, а кроссфейд A/B позволяет вести палитру связанным параметром, как что угодно другое. Поскольку поиском по палитре занимается общая цепочка, один и тот же словарь цвета есть у каждой системы, — поэтому пресеты разных систем уживаются в одном шоу и не выглядят выходцами из разных приложений.

Настройку видно в цифрах

Соблазн при работе с визуализатором — крутить всё на глаз, а из этого выходят пресеты, которые хорошо смотрятся на одном треке и мертвы на всех остальных. Пошаговый разбор настройки проводит один пресет через пять шагов и на каждом показывает картинку и ту строку --report, которая изменилась. Именно отчёт делает разницу видимой: по нему можно понять, что параметр так и не ушёл со дна своего диапазона, — в кадре этого не разглядеть.

Есть и второй способ получить пресет. milkconv конвертирует пресеты MilkDrop .milk в описанный выше формат. Это инструмент для разработки, и он намеренно не входит в состав workspace по умолчанию: обычный cargo build его не собирает, и ничто из поставляемого от него не зависит.

Офлайн-рендер трека

Движок умеет отрендерить трек в видеофайл, не записывая при этом окно:

cargo run -p standalone --example shot -- \
  --preset "Supernova" --render track.wav --fps 30 --size 1920x1080 \
  --ffmpeg ffmpeg --out track.mp4

Раз рендер офлайновый, он отвязан от реального времени: каждый кадр рисуется ровно с шагом 1/fps независимо от того, сколько на него ушло, поэтому результат детерминирован и не теряет кадры, как это делает запись экрана. Здесь же дорогой уровень качества оправдан лучше всего — нет дедлайна в 60 Гц, к которому регулятор времени кадра мог бы не успеть.

Кодировщик с проектом не поставляется. Статический ffmpeg больше, чем весь бюджет размера приложения, поэтому shot отдаёт поток Y4M-кадров тому ffmpeg, на который вы его направите, и оставляет контейнер на него.

Он начал управлять светом

Последнее направление никто не планировал, и запись о нём необычно откровенна на этот счёт.

Более раннее решение, ADR-0144, ставило в середину тракта вторую машину с Resolume Arena: Arena владела бы патчем приборов, зонированием и диммированием, Ritmolux отдавал бы ей картинку по NDI и телеметрию по OSC, а наружу Art-Net выдавала бы уже она. Вариант выдавать Art-Net напрямую из приложения там рассмотрели и отвергли одной строкой — «это делает нас световым пультом».

29 августа живой сет отработал по отвергнутому пути: --osc 127.0.0.1 в небольшой мост на той же машине, а тот — Art-Net прямо в приборы. Ни Arena, ни NDI, ни второй машины. Всё получилось, а причина, по которой исходный отказ был неверен, оказалась проверяемой. Прямой вывод отвергли на том основании, что «профили приборов, патчинг, зонирование, кривые диммирования, раскладки каналов и тайминг обновления DMX — это всё настоящая работа, и вся она уже сделана в Arena». Вот только в этом риге ничего из перечисленного нет. Это два контроллера Ethernet-to-SPI, которые говорят по Art-Net на UDP 6454, обычный RGB, вселенные 0–23. Здесь нет пульта, у которого можно было бы перенять работу, и нет профиля, который надо уважать. Контроллерам нужны пиксели.

На этом основании ADR-0145 пришёл на смену ADR-0144, и теперь движок управляет приборами напрямую.

Тёмный клубный зал в красном и пурпурном свете: зигзагообразная ферма из неоновых трубок под потолком, бумажные звёзды на кирпичной стене, люди в зале и проекционный экран за диджейским пультом, на котором концентрические мятные и розовые кольца вокруг восьмиконечной звезды

Зал. На экране за пультом — движок.

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

Документация живёт по адресу igorkonovalov.github.io/Ritmolux

igorkonovalov.github.io/Ritmolux

Сайт публикует ровно те документы, что лежат в репозитории: читает их на месте, никуда не копируя, и добавляет поиск. Это справочник, а не пересказ справочника. Четыре вещи там стоит назвать отдельно:

  • Галерея — по одному кадру от каждого поставляемого пресета, 82 карточки. Все сняты под одним и тем же стимулом в один и тот же момент клипа, поэтому их можно сравнивать между собой.
  • Руководство по пресетам — иллюстрированная точка входа: как выглядит каждая система, когда её стоит брать и в каком цикле вы работаете.
  • Как это работает — диаграмма и путь одного сэмпла до фигуры на экране.
  • Запуск и Конфигурация — два меню, операторская консоль, уровни качества и дисплеи; затем каждый флаг, переменная окружения и ключ config.toml со значением по умолчанию и приоритетом между ними.

В README остался ровно один операторский факт — таблица горячих клавиш, потому что про Space человек должен узнать, не уходя со страницы. Всё остальное, что он раньше нёс, теперь на сайте.

Одна деталь про картинки. Каждое изображение в этом посте, кроме фотографии выше, — и каждое изображение в том репозитории без единого исключения — это headless-рендер движка, снятый утилитой shot под синтезированным аудиоклипом, а не скриншот окна приложения. Их перегенерирует скрипт, в манифесте которого записаны пресет, стимул, шаг, размер и уровень качества для каждой картинки. Нигде нет ни одного снимка браузера пресетов, меню настроек или диагностического оверлея. Единственная фотография здесь — это фотография зала, а не софта.

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

Это осознанный выбор, и у него есть цена: показать интерфейс документация не может. Взамен ни одна картинка в ней не может разойтись с тем, что движок делает на самом деле, — потому что каждая из них перегенерирована из движка.

Как им управляют

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

Space переходит к следующему пресету, растворяя, а не обрезая, и перезапускает таймер автосмены. A переключает автосмену, по умолчанию выключенную. Tab открывает браузер пресетов, S — меню настроек, F — полноэкранный режим, D переходит к следующему дисплею, F3 включает диагностический оверлей.

Две клавиши менее очевидны и более полезны. C открывает операторскую консоль на втором дисплее — ровно то, что нужно, когда основной выход идёт на проектор, который нельзя закрывать меню. А [ и ] понижают и повышают уровень качества вживую.

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

Управляющее правило — то самое, которое всё это и делает безопасным:

Уровень меняет, сколько движок рисует, но никогда — что.

Один и тот же пресет читается одинаково на обоих, при разных бюджетах. Если бы уровень мог менять что рисуется, пресет, созданный на одной машине, был бы другим пресетом на другой, и «у меня на ноутбуке выглядит неправильно» стало бы неотвечаемым. Закрепление уровня — флагом, переменной окружения или ключом конфига, именно в таком приоритете — действует в обе стороны, и регулятор его не трогает: это случай мощной машины, которую один разовый провал несправедливо понизил.

Для авторской работы RLX_PRESET_DIR, указывающий на папку, запускает приложение против неё вместо пользовательской, — так работают над пресетами самого репозитория, ничего не копируя. Правки подхватываются примерно за 150 мс, так что цикл такой: держите окно на втором мониторе, правьте файл, сохраняйте, смотрите.

Где взять

Собранные бинарники приложены к каждому тегу на странице релизов — три архива, в каждом лежит READ-ME-FIRST.txt:

АрхивЧто внутри
…-windows-x64.zipritmolux.exe — Windows x64
…-macos-universal.zipRitmolux.app — универсальный (Apple Silicon + Intel), macOS 13+
…-foobar2000-component.zipfoo_ritmolux.fb2k-component — foobar2000 v2, только x64

Все три не подписаны, поэтому каждая ОС один раз поругается. Windows покажет SmartScreen: «Система Windows защитила ваш компьютер» → Подробнее → Выполнить в любом случае. На macOS подпись только ad-hoc — нажмите на приложение правой кнопкой и выберите Открыть либо снимите карантин: xattr -dr com.apple.quarantine Ritmolux.app. После этого сборка для macOS попросит разрешение на запись экрана (это единственный штатный способ добраться до системного звука) и потребует перезапуска.

Через foobar2000 меньше всего шансов, что что-то пойдёт не так: компонент читает то, что плеер и так уже декодирует, поэтому здесь нет ни захвата звука, который нужно разрешать, ни устройства вывода, которое нужно маршрутизировать.