Как я целый год писал эмулятор звука Game Boy на C++
В этой статье я разберу устройство звуковой подсистемы Nintendo Gameboy и расскажу о трудностях, с которыми столкнулся, когда пытался генерировать звук в реальном времени и синхронизировать его с изображением. Материал продолжает мою предыдущую публикацию (https://habr.com/ru/articles/908752/).
Введение
О принципе Парето сегодня слышали почти все: 20% усилий приносят 80% результата, а оставшиеся 20% результата поглощают 80% времени. Работа над моим эмулятором Gameboy в очередной раз это подтвердила. Когда CPU и PPU заработали как надо, я переключился на APU (Audio Processing Unit), чтобы наконец сыграть в тетрис под «Коробейников». Но выяснить, как правильно генерировать звук, оказалось задачей совсем не тривиальной.
Отдельно стоит упомянуть особенности восприятия. Слух реагирует быстрее зрения: мозг обрабатывает звуковой сигнал заметно оперативнее зрительного. Для эмулятора это создаёт неприятный перекос — к звуку предъявляются более жёсткие требования по реальному времени, чем к видео. Провал в звуке слышен мгновенно, а рассинхрон звука и картинки раздражает сильнее, чем кадр, отрисованный с задержкой.

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

Программная архитектура воспроизведения звука на ПК
К счастью, авторам эмуляторов не приходится напрямую управлять электрическими импульсами — это делает звуковая карта. Ей достаточно передать описание звука в виде сэмплов: дискретных значений, каждое из которых фиксирует положение мембраны динамика в определённый момент времени. Человеческий слух ограничен сверху примерно 20 кГц, поэтому частоту дискретизации можно снизить без потери качества. Так, стандарт Audio CD с частотой 44.1 кГц в соответствии с теоремой Найквиста — Шеннона точно воспроизводит любой слышимый сигнал. Современным эталоном цифрового аудио стали 48 кГц: это значение нацело делится на привычные 24, 25 и 30 кадров в секунду, что было существенно для ранней цифровой техники.
Поскольку слух реагирует быстрее зрения, аудиостек обязан укладываться в жёсткие real-time ограничения. Достаточно заглянуть в таблицу процессов Ubuntu: демон PulseAudio имеет очень низкое значение nice и работает под управлением real-time планировщика.
Со звуком нужно избегать двух крайностей: нехватки сэмплов и их избытка. Если сэмплов не хватает, звук резко обрывается, сигнал на динамик перестаёт поступать, мембрана мгновенно возвращается в нейтральное положение, и мы слышим характерное «хрюканье». Если же сэмплов слишком много, аудиокарта не успевает их обработать, и звук начинает отставать от картинки на экране.

Сколько сэмплов требуется в данный момент, знает только сама аудиокарта, а работает она асинхронно по отношению к остальному оборудованию. На уровне приложения это выражается в том, что аудиобиблиотеки действуют либо в pull-, либо в push-режиме. При запуске библиотека создаёт отдельный высокоприоритетный поток: с одной стороны он взаимодействует с операционной системой, которая передаёт задачи драйверу, а с другой — по мере необходимости откуда-то получает сэмплы. Приложения бывают очень разными, да и источники сэмплов отличаются, поэтому разработчик передаёт в библиотеку функцию, которую поток вызывает, когда ему нужны новые данные. Это и есть pull-модель. Есть и альтернатива: внутри библиотеки выделяется очередь сэмплов, приложение отправляет в неё звук, когда хочет его воспроизвести, а поток библиотеки забирает нужный объём и передаёт дальше — это push-модель. В конкретных библиотеках детали реализации могут отличаться. Например, профессиональные решения вроде ASIO обходят ОС и работают с драйвером карты почти напрямую. А разработчики SDL в третьей версии отказались от жёсткой real-time pull-модели SDL2 в пользу более простой push-модели со встроенным микшированием, сохранив возможность подключить callback, вызываемый из звукового потока.
Переход на push-модель с поддержкой нескольких звуковых потоков стал важным нововведением SDL3. Библиотека учитывает, что сэмплы могут закончиться, и в этом случае автоматически воспроизводит «тишину». От переизбытка же спасает возможность узнать количество сэмплов в очереди с помощью функции SDL_GetAudioStreamQueued. С выходом SDL3 работать со звуком стало гораздо проще.

Архитектура звукового движка Gameboy (APU)
Теперь перейдём к тому, как звук генерируется на самом Gameboy. Компоненты консоли связаны между собой гораздо теснее, чем в современном ПК. Применительно к APU это означает, что все его счётчики тактируются от частоты CPU — 4 194 304 Гц, а смена фаз и сэмплов происходит реже: счётчики каналов получаются делением главной частоты. Если говорить точнее, прямоугольные каналы переключаются в диапазоне от 64 Гц до 131 кГц, волновой канал выдаёт сэмплы с частотой от 1 кГц до 2 МГц, а шумовой тактируется в пределах от 4.6 Гц до 524 кГц.

Тут возникает первая проблема: нельзя просто добавлять сэмпл в очередь на каждом такте. Необходим ресэмплинг, и, к счастью, частоту нужно только понижать, а значит, достаточно брать сэмпл раз в N тактов. Но 4 194 304 Гц не делятся нацело ни на 44.1 кГц, ни на 48 кГц: на один выходной сэмпл приходится 95.11 такта CPU при 44.1 кГц и 87.38 такта при 48 кГц, то есть оба значения дробные. Сначала я выбрал простой путь: счётчик тактов для сэмплирования хранился в double, и как только накапливалось более 87.38 полного такта, брался очередной сэмпл. Получился ресэмплинг по ближайшему соседу. Делать так, конечно, не стоит, и почему — я расскажу в одном из следующих разделов.

Звук в Gameboy строится на четырёх независимых каналах. Каждый из них — конечный автомат: его поведение настраивается набором регистров, а переходы между состояниями управляются таймерами, привязанными к тактовой частоте CPU. В зависимости от текущего состояния на выход подаётся сигнал определённой амплитуды. Амплитуда задаётся 4-битным числом, поэтому лежит в диапазоне от 0 до 15.
У каждого канала своё жёстко заданное поведение (пример см. на картинке в начале статьи):
-
Прямоугольные волны 1 и 2 — первые два канала устроены одинаково. Основой служит генератор прямоугольного сигнала с переменной скважностью (доступно четыре значения: 12.5%, 25%, 50%, 75%). Сигнал колеблется между нулём и значением громкости (envelope). У первого канала дополнительно можно настроить изменение частоты в процессе воспроизведения.
-
Волновой канал — воспроизводит произвольную 4-битную волну. Для неё выделена область из 16 байт, где каждый байт содержит по два сэмпла (старший и младший полубайты). Канал берёт амплитуду сэмпла, удерживает её заданный период, а затем переходит к следующему.
-
Шум — классический генератор на регистре сдвига с линейной обратной связью (LFSR). Работает в 15- или 7-битном режиме и выдаёт нечто похожее на белый шум.


Сигналы каналов поступают в простейший «микшер», который объединяет их и отправляет на динамик. Хотя динамик в Gameboy всего один, звук формируется в стерео: громкость для левого и правого уха регулируется независимо, как и подключение каждого канала к каждому из них. Углубляться в детали реализации я не буду — они подробно описаны на https://gbdev.io/pandocs/, ресурсе, о котором я упоминал в прошлой статье. Мне хочется сосредоточиться на том, что не удалось понять с первого раза.
В архитектуре Gameboy есть таймер: его можно по-разному настраивать, и тогда в нужные моменты он вызывает прерывания. С секвенсором APU таймер связан лишь через регистр DIV: запись в DIV сбрасывает секвенсор, после чего тот продолжает считать такты самостоятельно. Прерывания таймера в тактировании каналов не участвуют. Поскольку вся система построена на счётчиках, программист Gameboy оперирует не частотами, а длительностями. «Счётчик секвенсора» срабатывает каждые 8192 такта CPU (512 Гц). Сам секвенсор — конечный автомат, который при каждом срабатывании меняет состояние, циклически проходя по восьми шагам. На каждом чётном шаге (то есть с частотой 256 Гц) уменьшается счётчик длительности ноты, и когда он доходит до нуля, канал выключается. Вдвое реже (128 Гц) срабатывает счётчик изменения частоты для канала 1, а раз за круг (64 Гц) происходит изменение громкости. Это накладывает ограничения на то, какие звуки вообще возможны: громкость меняется довольно медленно, и в мелодиях это заметно.


Напомню: амплитуда в Gameboy задаётся 4-битным числом, то есть громкость меняется от 0 до 15, и именно это число поступает на вход DAC. Нам же нужен нормализованный биполярный сигнал в диапазоне от -1.0 до 1.0, но простым растяжением 0…15 на этот интервал не обойтись. DAC не биполярный: на вход ему подаются числа от 0 до 15, а не от -15 до +15. У прямоугольных каналов и шума на вход попеременно приходит то текущая громкость, то ноль, поэтому среднее значение зависит от громкости и скважности. Если сдвинуть шкалу так, чтобы 7.5 стало нулём, у «молчащего» канала всё равно останется постоянный уровень (-7.5), а у работающего появится смещение, меняющееся вместе с громкостью и скважностью. Эти смещения суммируются по всем каналам, и при включении или выключении канала возникают щелчки.
Поэтому на выходе эмулятора я ставлю «DC-блокер» — фильтр высоких частот на 30 Гц. Он убирает постоянную составляющую и оставляет переменную: средний уровень становится нулевым вне зависимости от громкости и скважности канала. В реальном железе эту роль играет разделительный конденсатор. После такого фильтра о смещении можно не думать: достаточно просто вычислять уровень DAC для текущего 4-битного значения.
Уровень DAC считается как, где value — 4-битное входное значение (0…15). У каналов 1, 2 и 4 в активной фазе value равно текущей громкости, а в пассивной — нулю. При нулевой громкости оба уровня совпадают, и после DC-блокера сигнал обнуляется. При ненулевой остаётся переменная составляющая — на рисунке (скважность 50%) это ±volume / 15.
У канала 3 огибающей нет: его громкость определяется выходным уровнем. 4-битный сэмпл сдвигается вправо на 0, 1, 2 или 4 бита (100%, 50%, 25% или 0% соответственно) и только после этого попадает в тот же DAC.

Работа с регистрами
Весь APU управляется регистрами в диапазоне $FF10–$FF26 и шестнадцатью байтами волновой памяти по адресам $FF30–$FF3F. Регистры делятся на две группы: отдельный набор для каждого канала (NR1x, NR2x, NR3x, NR4x) и три общих. NR50 и NR51 задают громкость и коммутацию каналов для левого и правого уха, а NR52 управляет APU в целом.

Из схемы видно, что каждый из регистров NRx1, NRx2, NRx3 и NRx4 отвечает за свой аспект настройки канала (например, NRx4 хранит триггер запуска). Однако при написании кода обработки я не стал усложнять себе жизнь и реализовал каждый канал по отдельности.
Тестирование
Соответствие поведения реальному железу я проверял с помощью тестов dmg_sound от Blargg. Я автоматизировал процесс: теперь тесты умеют запускать ядро bdmg без вывода картинки, а сами ROM-файлы встроены в сегмент данных тестового бинарника.

При этом тесты покрывают только цифровую часть: они подтверждают, что с программной стороны эмулятор ведёт себя так же, как настоящее железо, но то, как именно звучит результат, — совсем другой вопрос.
Ресемплинг и фильтрация
Вернёмся к ресэмплингу, который я обещал разобрать отдельно. Частота CPU — 4.19 МГц, и для звука это избыточно (вспомним про 20 кГц). Эмулятор работает M-тактами, где один M-такт равен 4 тактам CPU. На каждом M-такте берутся текущие уровни каналов, суммируются, и получается один сэмпл микшера. Это 1 048 576 сэмплов в секунду — почти в 22 раза больше, чем нужно звуковой карте (48 000 Гц). Не пугайтесь: в начале статьи я считал в тактах CPU, и для 48 кГц на выходной сэмпл приходилось 87.38 так
Red Dead Redemption 2 исполнилось 8 лет, а владельцы консолей всё ещё ждут некстген-патч
Одной разработки игры мало
Фильм «Клэйфейс» сменил название перед российским прокатом, в кинотеатрах он появится 29 октября
God of War III Remastered портировали на Android с опорой на наработки проекта Bloodborne для Android — работа пока ведётся
Бюджет экранизации Elden Ring оказался выше, чем у некоторых блокбастеров Marvel
JoyToy продемонстрировала распаковку фигурки касркина из вселенной Warhammer 40,000
Sony усилила защиту PS5 от взлома, из-за чего владельцам консолей придётся чаще обновлять систему
Опубликовано превью заключительного эпизода аниме «Блич: Тысячелетняя кровавая война — Бедствие»