Про что это?

Это статьи про серию программ для Game Boy (DMG/CGB), эксплуатирующих идеи модульного синтеза и секвенсинга.

Начинался проект как попытка сделать Rungler Circuit для Nintendo Game Boy.

Эта первая статья из серии, вводная, лишь с небольшими техническими подробностями.

Предыстория

Есть такой Ess Mattisson, инженер и музыкант, работавший дизайнером устройств в шведской компании Elektron, а теперь независимо выпускающий аудиоплагины на https://fors.fm/

И есть такой железный трекер Dirtywave M8, в сообществе которого как-то проводился конкурс на композицию, написанную с использованием только одной дорожки, в котором Ess занял первое место.

Я попытался повторить подобное на самом известном chiptune-трекере для Game Boy LSDJ, интерфейс которого лёг в основу интерфейса M8, а заодно в нём разобраться. Не могу сказать, что трек у меня получился, да я и не настоящий музыкант, но интерфейс освоил и про аудио-возможности Game Boy что-то понял.

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

Примерно в это же время Ess выпустил плагин Junior, для 4-битного синтеза звука, построенный на тех же идеях, что и синтезатор в LSDJ, а я наткнулся на плагин RetroPlug. RetroPlug — это VST-обёртка вокруг эмулятора Same Boy, которую можно использовать в DAW и гонять тот же LSDJ или nanoloop, как виртуальные инструменты, причём даже с синхронизацией и ограниченным управлением по MIDI.

Ну и меня стала заедать идея что-то выжать из звуковых и вычислительных возможностей Game Boy, для создания любимых мной шумов, блипсов и блупсов.

И да, самого устройства у меня нет, поэтому всё тестировалось на эмуляторах.

Rungler

В качестве пробного шара я решил сделать что-то вокруг идей Rungler circuit. Изобрёл схему уже покойный, к сожалению, автор многих модулей Rob Hordijk, называется она по разному, например Benjolin или Blippoo Box, и вот тут можно посмотреть, как схема выглядит.

Если совсем упрощать, то есть два осциллятора, один из которых служит как триггер, что пихает сдвиговый регистр (этот регистр и называется Rungler), а гейт со второго задаёт значение на входе этого сдвигового регистра. Три нижних бита этого регистра преобразуются в напряжение ЦАП-ом (это в схеме, в цифре это чиселка от 0 до 7) и это напряжение управляет модуляцией тех самых двух осцилляторов.

(Картинка сделана ИИшкой, но суть, надеюсь, передаёт)
(Картинка сделана ИИшкой, но суть, надеюсь, передаёт)

Наружу торчат ручки, чтобы управлять частотой осцилляторов и силой модуляции.

Схема простая, но демонстрирует очень интересное поведение, поскольку хаотична по природе своей. “Хаотична” здесь значит, что работает она согласно теории хаоса, когда мимимальные изменения входных параметров могут приводить к резким, “катастрофическим“ изменениям, варьируя звук на выходе от спокойных ритмических пульсаций до жутких шумов и обратно.

Ну ок, схема простая, запрограммировать должно быть несложно. Тем более, что я уже писал такой модуль для Korg NTS-1 используя Logue SDK, писал несколько разных модулей сдвиговых регистров (LFSR) для VCV Rack (https://library.vcvrack.com/TyrannosaurusRu/)

Правда, я никогда не писал и вообще ничего не знаю про разработку для Game Boy, но разберусь, там же можно писать на C.

И в итоге я выбрал Ассемблер, на котором никогда, ни для одного процессора, не написал до этого ни строчки.

Что нам понадобится?

Для начала я провёл ревизию возможностей Game Boy, как я их понимал на тот момент.

  • Сдвиговый регистр? Чек.

8-битный регистр — это просто один байт, над которым совершаются разные битовые операции, типа сдвига или “OR“.

  • Два осциллятора? Чек.

У APU (audio processing unit) Game Boy четыре канала.

Канал 3 — настраиваемая волна, про него я подробнее расскажу в следующих постах.

Канал 4 — псевдослучайный шум. Для генерации опять же используется LFSR, то есть сдвиговый регистр с линейной обратной связью.

А вот первые два — пульс, с регулируемой скважностью (Duty Cycle). Каналы немного отличаются, на первом есть возможность делать Sweep, плавно меняя частоту вверх или вниз, на втором нет.

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

  • Можно ли модулировать параметры канала? Чек.

Точнее, я думал на тот момент, что чек.

Параметры звука в канале меняются записью новых значений в так называемые аудио-регистры, специальные байты в памяти. Например, чтобы изменить громкость первого канала, нужно записать 4 старших бита по адресу $FF17.

Четыре — потому что звук на Game Boy 4-ёх битный, уровень меняется от $0 до $F. Так что любители 8-битной эстетики получат только её половину (ну или в двухкратном размере, тут как посмотреть).

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

Тут уже проблема — умножения нет среди команд процессора Sharp SM83, который используется в Game Boy. Да, если писать на C, то проблема не так видна, а если на ассемблере — то можно использовать алгоритмы для побитового умножения в столбик или lookup-таблицы, но все эти алгоритмы тяжёлые и/или громоздкие, и не подходят в тех местах, где я целился на риалтайм-модуляцию. В итоге, я остановился на упрощенном алгоритме использующем только побитовые операции (свою задачу он выполняет, звук видоизменяется, чем больше значение уровня модуляции — тем сильнее).

Впрочем, с риалтайм тоже не получается. Даже если я записал уровень в аудио-регистр, для его применения канал должен быть триггернут заново. А это значит, что звук прервётся, и вместо прямоугольной волны будет слышно только треск. С модулированием частоты ситуация получше, там значения, записанные в два аудио-регистра с алиасами NR14 и NR13 применяются, когда закончится текущий фрагмент волны.

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

  • Читать значения на выходе осциллятора? ½ чек.

Вот тут проблема. Чтобы писать в сдвиговый регистр и сдвигать его, нужно знать текущие значения в каналах. Доступ к значениям каналов стал возможен только начиная с модели Game Boy Color, и то, аудио-регистры, в которых они лежат, не были документированы официально. Видимо поэтому некоторые эмуляторы их не имплементируют, по крайней мере на двух разных Android эмуляторах поведение было не то, на которое я рассчитывал. Однако десктопные BGB или уже упомянутый Same Boy имплементируют их правильно и работать будет, как ожидается.

  • Ручки для управления? Чек.

Понятно, что почти все туториалы по Game Boy про игры, а игры — это про вывод чего-то на экран и про управление этого чего-то кнопками. Здесь тоже было более-менее стандартно — загружаем в видеопамять спрайты (шрифт), загружаем в тайловую область видеопамяти номера спрайтов. Таким образом отрисовываем экран, по которому перемещаем курсор и, отлавливая кнопку-модификатор, изменяем параметры. Я пытался сделать какой-то реактивный фреймворк для UI, не то, чтобы получилось, но код стал поизящнее. Хотя я не в зуб ногой, какой ассемблерный код считается изящным, а какой нет.

Важным, но базовым, является тот факт, что видеопамять доступна для записи в неё только когда луч, бегущий по кинескопу, забежал за небольшую область за его пределами. Происходит это примерно 60 раз в секунду и в этот момент стреляет так называемый VBlank interrupt, в который нужно впихнуть нашу логику работы с экраном — перемещение курсора, изменение значений etc. Можно это событие отлавливать и в CPU цикле, а не ловить интеррапт, более традиционный подход, но с интерраптом удобнее.

Конечно, никакого кинескопа у Game Boy с его LCD экраном нет, но метафора сохраняется, LCD экран точно так же отрисовывается линиями развёртки, 144 линии по 160 пикселей примерно 60 раз в секунду.

Программирование и проблемы

Может показаться странным, но писать я стал на ассемблере (я использую стандартный тулинг: RGBDS с плагином для VSCode). На C я даже не попробовал, посмотрел код на гитхабе, увидел, что многие всё равно, даже когда пишут на C, используют ассемблер для оптимизации, ну и решил пойти сразу the hard way.

Прежде чем приступить — много читал. Документацию по RGBDS, Pandocs, туториалы и чужой код.

И всё равно было непонятно, как начать.

Почему кто-то пишет циклами waitForVBlank и не использует интеррапты? А как правильно-то? Почему надо использовать nop после halt? Когда использовать макро, а когда call процедур? Где правильно процессить аудио? Как работать с 16-битной арифметикой и не запутаться? Почему я могу абы что записать только в регистр a, а в любой другой не абы что? Как работать в условиях ограниченного количества регистров, когда они все mutable, хрен отследишь?

Часть вопросов я задал ИИ-шке, что помогло сдвинуться с мёртвой точки, загрузить, наконец, шрифт в память и вывести “Hello, World“.

Долго ли, коротко, но в конце-концов UI был готов, параметры менялись, звук менялся вслед за параметрами, всё работало. Но вслед за курсором на экране оставался мусор, и я не мог понять почему.

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

Всё оказалось несколько сложнее. Дело в том, что пока я гоняю какую-то логику в VBlank, линия развёртки продолжает бежать в невидимой части экрана до тех пор, пока не уткнётся в строку 153, а потом побежит опять сверху, уже в видимой части. И если я не успел за это время (~4560 процессорных циклов) сделать всё, что планировал, если код оказался слишком громоздким и медленным — то писать в видеопамять будет уже небезопасно, начнётся тиринг, мусор и артефакты.

Я переписал кусок, где неоптимально очищался экран, исправил обработку стирания-отрисовки курсора, исправил (там действительно была ошибка) визуализацию сдвигового регистра — теперь VBlank занимает по профайлеру <1500 циклов, что в безопасной зоне.

Результат

В итоге, выглядит и звучит это вот так (резервная ссылка на vkvideo)

Это не совсем тот Blippoo Box, как задумывалось. Тот получился скучнее за счёт ограничений, перечисленных выше, и его ещё предстоит доделать.
Скорее, данная имплементация идеологически больше похожа на синтезатор Double Knot, который тоже использует сдвиговые регистры для модуляции и секвенций.

Продолжение следует

В следующей статье, если она будет, я расскажу про аудиовизуальные перформансы Роберта Хенке, настоящем четырёхбитном синтезе на Game Boy, Karplus-Strong и Risset Drum, профайлинге кода в дебаггере, и покажу арифметику, которая ограничивает возможности рмалтаймового синтеза на Game Boy.