Простите за кликбейтный заголовок оригинала, но пост своё обещание выполняет: стерео 44 100 Гц при битрейтах почти как у Opus и примерно 4% процессорного времени N64.

Сначала немного истории. Со звуком на N64 всё было печально, и с воспроизведением, и с хранением. PlayStation могла просто стримить с компакт-диска несжатое аудио формата Red Book. На N64 место на картридже стоило дорого, а время процессора на 93 МГц было ограничено.

Хуже того, доступ к векторному сопроцессору RSP Nintendo очень долго держала закрытым. Её собственная платформенная библиотека Libultra внутри использовала RSP для разных задач с графикой и звуком, но сторонним разработчикам писать для него код напрямую не разрешали.

В Libultra были функции для прозрачного воспроизведения сжатого звука по схеме ADPCM (адаптивная дифференциальная импульсно-кодовая модуляция). Распаковывал его на лету RSP, прямо перед отправкой в выходной буфер.

Работало это вполне прилично и сокращало место под сэмпл в четыре раза. И всё же пятиминутный стереотрек на 44 100 Гц занимал около 12 МБ, а для картриджа N64 это слишком много, даже если вдвое снизить частоту дискретизации. Весь ROM Mario 64 весит всего 8 МБ. Самые большие игры на N64 занимали 64 МБ.

Поэтому музыку в играх N64 делали примерно как в трекере: короткие сэмплы запускались в нужный момент с небольшим набором эффектов. При таких ограничениях картриджа полноценная «записанная» музыка почти исключалась.

Ближе к концу жизни консоли некоторым избранным разработчикам Nintendo разрешила работать с RSP напрямую. Самый известный случай: Conker’s Bad Fur Day распаковывала так MP3 для множества реплик в игре. Декодирование MP3 обходится дорого, и частота кадров страдала. Для катсцен этого хватало, но для непрерывной фоновой музыки или звуковых эффектов во время игры уже нет.

Звук в открытой библиотеке Libdragon

Как и Libultra от Nintendo, открытая альтернатива Libdragon содержит ADPCM-декодер реального времени, работающий на RSP. ROM сейчас обходится дешевле, так что всю музыку для Xibalba 64 я просто сжал в ADPCM. Звук и музыка заняли около 31 МБ из 32 МБ ROM. Напомню, Mario 64 обходилась ROM на 8 МБ, а Xibalba 64 куда проще и музыки в ней всего 15 минут. Всё работало, но хранить музыку так значит безбожно разбрасываться местом.

Ещё в Libdragon есть декодер Opus реального времени с ускорением на RSP. Сам по себе это чудо. Формат Opus опубликовали через 16 лет после выхода N64, и всё же Джованни Бахо сумел заставить его работать на этом «устаревшем» железе. Сжатие и качество звука отличные, но опять же для использования во время игры он слишком медленный.

Какие ещё есть варианты? Можно написать MP3-декодер с ускорением на RSP, но выигрыш в скорости перед Opus будет незначительным, а качество и сжатие хуже.

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

Простой трансформный кодек, что-то вроде MP3, но без всего багажа, который оставляет проектирование консорциумом, мог бы подойти…

Ultra Low Complexity Codec

Во время ночных «исследований» я наткнулся на старое демо для Game Boy Advance, которое играет записанную музыку всего на 70 кбит/с. Это меньше, чем у типичного MP3, а качество сопоставимое. Повторю: на GBA. С ARM-процессором на 16 МГц. Без векторных инструкций и, если верить странице на GitHub, с некоторым запасом производительности.

Видео: ulcplayer-gba от Aikku93, записано в эмуляторе Game Boy Advance (репозиторий)

Кодекам временной области (таким как ADPCM) для такого качества нужны заметно более высокие битрейты. Субполосные кодеки вроде MP2 при сравнимом битрейте звучат намного хуже, а более «современным» трансформным кодекам вроде MP3 обычно нужен процессор хотя бы на 80–100 МГц. Для этого демо на GBA ни один существующий кодек не годился.

Поэтому Aikku93 не только написал декодер на ассемблере ARM, но и создал целый новый кодек: Ultra Low Complexity Codec (ULC).

ULC это трансформный кодек, он в чём-то похож на MP3. ULC переводит отрезки PCM-данных в частотную область. Как и MP3, он использует для этого модифицированное дискретное косинусное преобразование (MDCT). Но, в отличие от MP3, он не разбивает сигнал на несколько полос: MDCT в ULC «полнополосное». И там, где MP3 применяет классическое энтропийное кодирование (а именно коды Хаффмана), ULC… не применяет ничего такого. Вместо этого преобразованные коэффициенты хранятся в очень простом синтаксисе на основе полубайтов.

Коротко говоря, ULC это то, чем мог бы быть MP3. Та же основная идея, но без наслоений вокруг неё. «Сверхнизкая сложность» берётся не из другого алгоритма, а из аккуратного применения и упаковки того же самого.

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

  • -7 … -2, 2 … 7: значение коэффициента

  • 0: серия нулей, длина серии (1 … 16) идёт в следующем полубайте

  • 1: длинная серия нулей, длина (17 … 288) идёт в следующих двух полубайтах

  • -1: смена квантователя, значение идёт в следующем полубайте

  • -8: заполнение шумом, длина и амплитуда идут в следующих трёх полубайтах

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

Отдельно стоит отметить полезное дополнение, заполнение шумом. Тарелки, хай-хэты и малые барабаны от него сильно выигрывают. В MP3 ничего подобного нет, и ему приходится кодировать квазислучайные коэффициенты как есть.

ULC даёт пригодный результат даже на 16 кбит/с. Качество, конечно, страдает, но для некоторых применений его может хватить.

Aikku93 работает над ULC с 2019 года и постоянно улучшает качество кодирования. Весь код распространяется под разрешительной лицензией. Самое время попробовать его на N64.

ULC на N64

Ещё до эпохи LLM я потратил вечер, чтобы скомпилировать эталонный декодер ULC под N64 и собрать небольшое демо. Всё заработало без сучка без задоринки.

Единственная проблема: процессор был очень занят. На RSP ничего не выгружалось. Как доказательство концепции это мало что давало: мы и так знали, что процессора на 93 МГц хватает для декодирования трансформного кодека, но нам не нужен «музыкальный плеер», который больше ничего не делает. Нужна фоновая музыка и озвучка прямо во время игры. Занимать процессор целиком нельзя.

От чисел с плавающей точкой к 16-битной фиксированной

Очевидно, IMDCT надо перенести на сопроцессор RSP. Можно было бы просто передавать через DMA один блок декодированных коэффициентов в память RSP, выполнять преобразование и передавать готовые сэмплы через DMA обратно в RDRAM, в выходной буфер.

Легко сказать. У RSP всего 4096 байт памяти данных (DMEM) и ещё 4096 байт для инструкций (IMEM). Векторные инструкции работают с 16- или 32-битной фиксированной точкой, чисел с плавающей точкой нет. IMDCT будет эффективным, только если все данные преобразования разом помещаются в DMEM RSP. Часть DMEM нужна ещё и под временные значения и, по возможности, под буфер перекрытия: половину предыдущего декодированного блока. Так что с размером блока ULC по умолчанию в 2048 сэмплов нам не повезло, даже с 16-битной фиксированной точкой.

ULC умеет работать и с меньшими блоками MDCT, но оптимизирован он под 2048, поэтому размер стоит держать как можно большим. К тому же эталонные кодировщик и декодер ULC используют числа с плавающей точкой, так что очевидный выбор для сохранения качества это 32-битная фиксированная точка, хотя 16-битная тоже может подойти. Получаем два варианта, оба на 3072 байта:

  • 32-битные значения, блок на 512 сэмплов (буфер перекрытия на 256 сэмплов): (512 + 256) * 4 байта

  • 16-битные значения, блок на 1024 сэмпла (буфер перекрытия на 512 сэмплов): (1024 + 512) * 2 байта

16-битная фиксированная точка не только сократила бы трафик DMA, но и ускорила бы вычисления на RSP. Ценой был бы более высокий уровень шума. Насколько высокий? Понятия не имею. Поэтому я аккуратно переработал эталонный декодер, по-прежнему работающий на процессоре N64, под 16-битную фиксированную точку (Q1.14).

Несколько раз чуть не оглушив себя, я заставил его работать, и на большой громкости мне слышался (или мерещился?) лишь едва заметный белый шум. Вопрос закрыт: берём 16 бит.

Втискиваем всё в RSP

Теперь оставалось перенести этот код на C на RSP. Ассемблер MIPS я знаю очень поверхностно, и написать декодер для RSP было бы серьёзной затеей.

На этом я сделал паузу в проекте: мне надо было закончить игру. Когда я вернулся к нему в начале этого года, LLM заметно подтянулись. И всё же GPT-5 я это не вполне доверял. В прошлых экспериментах он справлялся очень плохо: часто игнорировал слоты задержки и другие тонкости RSP и никогда не доводил работу до конца.

(Замечу в скобках: GPT-6 или Opus-5 сейчас, вероятно, сделали бы это с одной попытки.)

Поэтому я выбрал более пошаговый подход. Макс Бебёк создал небольшой C-подобный язык для написания кода под RSP с подходящим названием RSPL. Вооружившись документацией к языку, я попросил Codex переводить код с C на RSPL по частям, каждый раз проверяя результат. Перенос и поиск разумного дизайна API заняли несколько дней. На следующих шагах Codex оптимизировал ассемблерный вывод RSPL: сначала переписал некоторые части (уже прямо на ассемблере), потом минимизировал простои конвейера, доработав оптимизатор RSPL.

В итоге получилось около 1400 строк ассемблера MIPS, которые отвечают за IMDCT, перекрытие, чередование стереоканалов и масштабирование вывода. В IMEM на 4096 байт они едва помещаются.

Часть декодера на C по-прежнему написана вручную, но только потому, что у LLM нет вкуса. Весь процесс занял у меня несколько недель, и меня всё ещё поражает, что это вообще сработало.

Видео: моё демо ulc64 показывает качество на разных битрейтах (репозиторий)

Чтобы по ULC было удобно перематывать, я добавил в конец файла разреженную таблицу с байтовыми смещениями каждого восьмого кадра. Декодер быстро доходит от ближайшего такого кадра до нужного. Это добавляет примерно 20 байт в секунду, что приемлемо даже для звука на 16 кбит/с.

ULC оказался вполне жизнеспособной альтернативой ADPCM и Opus на N64. Качество и битрейт близки к Opus, а сложность всего в 4 раза выше, чем у ADPCM. Один стереотрек на 44 100 Гц стоит около 4% времени процессора и 5% времени RSP. Этого вполне хватает для фоновой музыки, речи, окружающего звука и более длинных звуковых эффектов.

Декодер работает в Libdragon прозрачно, как и все остальные аудиокодеки. Некоторое время назад его влили в ветку preview.

Дополнение

Несколько дней назад вышел отличный порт Half-Life на N64. В его README сказано:

[…] аудиокодек ULC из libdragon для 2500 звуков и 27 музыкальных треков — вот что позволяет уместить всё в одном образе картриджа на 64 МБ.

github.com/ToxyCola/Half-Life-Nintendo-64-Port

Видео: трейлер Half-Life 64 на YouTube

Большое спасибо Aikku93 за создание ULC. Это замечательный кодек. Я его всего лишь нашёл и адаптировал. Я ищу простоту и изящество во всём, что делаю, и ULC мне по-настоящему нравится. Респект!

Комментарии (0)