Брусофония: как устроен звук в игровой приставке «Брус-16»

Не так давно на свет появилась новая учебная игровая приставка «Брус-16», по которой уже вышли две статьи на Хабре. В них была описана история создания приставки, от идеи до реализации на FPGA.
Приставка работает, написаны игры, и в них можно поиграть, что особенно интересно сделать, если вы обзавелись «копеечной» платой Tang Nano 9K или старше. Могло сложиться впечатление, что на этом все и закончится, но разве может игровая приставка остаться без звука? Автор приставки, Пётр Николаевич Советов, решил, что не может, и разработал звуковой синтезатор. На мой взгляд, результат получился качественным, предлагаю читателю убедиться самому и прослушать вступительный трек из игры Gerion на новом сайте приставки. Чтобы услышать звук, нужно нажать на экран эмулятора.
Поскольку ранее я занимался реализацией приставки на FPGA и теперь к приставке добавился звук, то я решил аппаратно реализовать и его. Кроме того, сама область синтеза звука вызвала у меня дополнительный интерес, ведь со звуком я ранее никогда не работал. Получив спецификацию в виде программного эмулятора, я решил разобраться, как именно синтезатор работает и как он обрел свой конечный вид.
Вообще, «Брус-16» создавался как игровая приставка, которая могла бы появиться в параллельном мире начала 80-х. Приставки тех времен в силу возраста я не застал, но многие читатели, наверное, вспомнят Atari 2600, Nintendo Entertainment System, Sega Mega Drive, Mattel Intellivision.
Я прочитал, что в ту эпоху популярностью пользовался чип AY-3-8910, который стоял в ZX Spectrum 128 и даже аркадных автоматах той эпохи. Также я узнал, что чуть позже начал развиваться метод синтеза на основе частотной модуляции, который можно было встретить, например, в звуковых синтезаторах Yamaha, а из сегодняшних примеров я вспомнил PICO-8 и TIC-80 — виртуальные приставки, использующие таблично-волновой способ синтеза — разновидность синтеза на основе семплов.
В процессе общения автор приставки убедил меня, что:
Прямоугольная волна, используемая в AY-3-8910, хотя и подходит «Брусу» по названию (брус/прямоугольник), на практике имеет проблему наложения спектров (aliasing), решения которой довольно громоздкие в реализации и сложные в понимании.
FM-синтез слишком сложен в обращении для пользователя.
Семплы в любом виде использовать не получится из-за недостатка памяти для хранения оцифрованного звука на бюджетных платах FPGA.
В итоге Пётр Николаевич пришел к самому древнему способу синтеза звука — аддитивному. Он не содержит ни одной перечисленной выше проблемы и практически не использовался в реальных чипах приставок. Последний факт дополнительно усилил интерес к испытанию этого способа синтеза в игровом ключе.
Аддитивный синтез
Напомню, что ранее я не работал со звуком, поэтому, чтобы разобраться в синтезаторе, пришлось искать ответы даже на самые простые вопросы.
В этом разделе будет пройден поэтапный путь создания синтезатора, от простейшего варианта до его финальной версии. Полный код всех этапов доступен в репозитории в виде Jupyter-блокнота.
Синус
Основой аддитивного синтеза является синус — знакомая всем функция из школьной программы, но как она поможет получить звук?
Не будем рассуждать впустую и предположим, что мы хотим сыграть конкретную ноту, A2 (ля второй октавы), используя только одну волну синуса. Из википедии можно узнать, что частота колебаний A2 равна 110 Гц.
Основой осциллятора синуса служит стандартная формула синусоидальных гармонических колебаний.
Здесь A — амплитуда, f — частота колебаний, а t означает время. Чтобы получить ноту A2, нужно подставить частоту 110 вместо частоты колебаний, а амплитуду можно взять равной единице для максимальной громкости звучания. В полной формуле еще есть начальная фаза колебания, но для звука она ничего не дает.
Реализовать формулу в таком виде что программно, что аппаратно не получится, ведь время непрерывно и неограниченно растет. Непрерывность времени требует вычисления бесконечного количества значений, что требует бесконечного количества ресурсов, поэтому цифровой звук дискретный. Для записи одной секунды звука обычно используют фиксированное число измерений амплитуды колебания. Это число называют частотой дискретизации, и для непрофессионального аудио часто выбирают значение 44100 Гц. Именно оно и выбрано для синтезатора Бруса.
Неограниченный рост величины времени также является проблемой, ведь однажды произойдет переполнение. Гораздо практичнее использовать императивную модель, основанную на хранении фазы синуса. Для секунды звучания потребуется 44100 семплов. За каждый семпл фаза увеличивается на неизменную величину шага, вычисленного заранее на основе частоты ноты по формуле: 2 × π × f / 44100.
В предлагаемой модели осциллятор описывается параметрами:
amp — амплитуда, громкость звука;
step — фиксированный шаг фазы за семпл, вычисляемый относительно частоты ноты;
phase — текущая фаза синуса, внутренний параметр.
Тогда код для генерации семпла на основе одного осциллятора будет иметь такой вид:
def get_sample(osc):
sample = osc.amp * math.sin(osc.phase)
osc.phase += osc.step
return sampleСинтезируем секунду звука:
osc = OSC(amp=0.2, step=get_step(110)) # A2
samples = [get_sample(osc) for _ in range(SR)]Послушать синтезированный звук можно на сайте «Брус-16».
Хоть звук и прост, это действительно нота A2! Вот частотный спектр такого аудио:

Довольно предсказуемо: один синус с фиксированной частотой в синтезаторе — одна частота на графике.
Орган
Мы узнали, как звучит один синус, однако хочется более богатого звучания. А что, если воспроизвести сразу несколько синусов?
Попробуем смоделировать орган. Для этого в звуке должна присутствовать основная гармоника, гармоника на октаву ниже и различные гармоники низшего порядка — вторая, третья и так далее.
voices = [ # гармоника
OSC(amp=0.2, step=get_step(110)), # основная
OSC(amp=0.2, step=get_step(110 * 0.5)), # на октаву ниже
OSC(amp=0.2, step=get_step(110 * 2)), # 2-я
OSC(amp=0.2, step=get_step(110 * 3)), # 3-я
]Функцию генерации семпла придется немного поменять. Появляется цикл с перебором осцилляторов и накоплением значения в аккумулятор.
def get_sample(voices):
acc = 0
for osc in voices:
acc += osc.amp * math.sin(osc.phase)
osc.phase += osc.step
return accПослушать синтезированный звук можно на сайте «Брус-16».
Теперь на графике отчетливо виден вклад в звучание сразу всех четырех осцилляторов:

Сам принцип получения более сложного звука из нескольких более простых схож с тем, как работает духовой орган. В нем для получения более богатого звука одновременно могут звучать несколько регистров, что создает интересные тембры.
Экспоненциальное затухание
Мы добились более богатого звучания, но остается еще один важный аспект, который принципиально отличает наш синтезированный звук от настоящего.
В настоящем мире звук не появляется и не пропадает в один миг. Даже у такого резкого звука, как выстрел пистолета, будут моменты, когда он нарастает и убывает по громкости. В синтезаторах такое поведение обычно описывают огибающей, отражающей изменение амплитуды во времени. Довольно популярна ADSR-огибающая (Attack, Decay, Sustain, Release), хотя сегодня есть и более совершенные аналоги.
В синтезаторе «Бруса» из всего этого реализовано только экспоненциальное затухание, причем огибающие у каждого синуса свои собственные.
Вот обновленный код:
def get_sample(sample_num, voices):
acc = 0
for osc in voices:
acc += osc.amp * math.sin(osc.phase)
osc.phase += osc.step
if sample_num % 64 == 0:
osc.amp *= osc.decay # Затухание раз в 64 семпла
return accЗдесь для каждого осциллятора добавлен коэффициент затухания decay. Каждые 64 семпла значение амплитуды каждого осциллятора умножается на новый коэффициент, обычно лежащий в промежутке [0.99, 1].
Само число 64 подобрано эмпирически так, чтобы, с одной стороны, затухание было плавным, а с другой, для хранения коэффициента не потребовалось бы 100 знаков после запятой.
Послушать звучание можно на сайте «Брус-16».
Теперь амплитуды всех осцилляторов могут независимо друг от друга изменяться во времени.
Чтобы увидеть влияние нововведения, можно добавить к прошлому графику ось времени:

Здесь изображен простой случай, когда коэффициенты затухания всех осцилляторов одинаковы, но стоит лишний раз отметить, что они могут быть разные.
Простая мелодия
Теперь звук стал довольно приятным, но звучанием одной ноты вряд ли получится удивить игрока, решившего опробовать «Брус-16». Хочется предложить пользователю запоминающуюся мелодию, которая бы осталась в памяти после закрытия игры.
Не берусь играть роль композитора, поэтому возьмем известный отрывок из «Оды к радости» Девятой симфонии Людвига ван Бетховена. Конечно, самый точный вариант воспроизведения — живой, в составе оркестра, но у нас в распоряжении только синтезатор. Нужно объяснить ему все партии, нажатия и отпускания нот. Для этой цели был придуман целый формат — MIDI. По-хорошему, можно было бы использовать его, но обойдемся вариантом попроще — просто запишем ноты основной мелодии.
Мелодия: E-4 E-4 F-4 G-4 G-4 F-4 E-4 D-4 C-4 C-4 D-4 E-4 E-4 D-4 D-4.
Ранее пришлось обратиться к википедии, чтобы узнать, что нота A2 имеет частоту 110 Гц. Теперь хотелось бы переводить ноты в соответствующие им частоты автоматически. Воспользуемся формулами:

С новыми формулами не составит особого труда перевести мелодию в последовательность частот. Останется лишь менять частоту у всех осцилляторов с определенной периодичностью.
В старом описании, к которому теперь добавился коэффициент затухания, можно заметить, что шаги осцилляторов задаются в виде множителей относительно первого осциллятора. Хотя амплитуда здесь у всех одинаковая, в будущем будет удобно работать и с ней в виде множителей.
Так рождается идея master/slave осцилляторов. Первый — master, в нем параметры будут задаваться как абсолютные значения. Все последующие осцилляторы — slave, в них параметры будут записаны в относительном виде, как множители. Идея сильно напоминает интерфейс GPU «Бруса».
С новым интерфейсом описание осцилляторов будет выглядеть так:
voices = [
OSC(abs=True, osc_amp=0.2, decay=0.995, osc_step=get_step(110)), # master
OSC(abs=False, osc_amp=1, decay=0.995, osc_step=0.5), # slave
OSC(abs=False, osc_amp=1, decay=0.995, osc_step=2), # slave
OSC(abs=False, osc_amp=1, decay=0.995, osc_step=3), # slave
]Теперь задаются не сами значения параметров, а их описание, некоторый внешний интерфейс. Появляется потребность в создании дополнительной функции, которая выполняла бы перевод описания в непосредственные абсолютные значения.
def update_voices(voices, freq):
abs = None
for osc in voices:
if osc.abs:
abs = osc
osc.step = osc.osc_step
osc.amp = osc.osc_amp
else:
osc.step = abs.step * osc.osc_step
osc.amp = abs.amp * osc.osc_ampЗдесь функция описана максимально просто. По мере перехода к аппаратной модели к ней добавится явное перемещение данных во внутреннее состояние синтезатора. Чтобы синтезировать мелодию, остается раз в определенное время менять частоту у основного осциллятора и вызывать новую функцию.
Послушать мелодию можно на сайте «Брус-16».
В примере с сайта не слышно никакой проблемы, ведь там работает финальная версия синтезатора. На самом деле с кодом выше возникает проблема. В момент смены ноты отчетливо слышен неприятный щелчок, который портит всю плавность мелодии и неприятно отдает в уши.
Вся суть проблемы в слишком резкой смене ноты. Из-за экспоненциального затухания амплитуды осцилляторов постоянно убывают вплоть до нуля, как и сама громкость семпла. В момент смены ноты амплитуды возвращаются в заданное пользователем значение, 0,2. Получается, что за один семпл выходная амплитуда может измениться от 0 до 1. Такое поведение неприемлемо!
Чтобы защитить уши слушателя и плавность мелодии, пришлось добавить довольно простую защиту от кликов, которая сглаживает смену амплитуды у осцилляторов.
def get_sample(sample_num, voices):
acc = 0
for osc in voices:
osc.amp += (osc.target_amp - osc.amp) / 64 # защита от кликов
acc += osc.amp * math.sin(osc.phase)
osc.phase += osc.step
if sample_num % 64 == 0:
osc.target_amp *= osc.decay
return accЗдесь число 64 подобрано эмпирически, чтобы, с одной стороны, не было щелчков, а с другой — не пропала атака звука.
Это финальный вариант синтезатора с принципиальной точки зрения. Всё еще не впечатляет? Действительно — в оркестре ведь много инструментов, а здесь работает только один орган!
Можно задействовать большее количество голосов. Далее, например, описаны три инструмента, в сумме занимающие 16 осцилляторов. Вообще говоря, создание тембров — процесс творческий. Те же инструменты можно описать по-другому, и это не будет ошибкой, главное, чтобы звучало хорошо.
DECAY_MELODY = 0.99795
DECAY_BASS = 0.99599
DECAY_HARMONY = 0.99899
voices = [
OSC(abs=True, osc_amp=0.150, decay=DECAY_MELODY, osc_step=0),
OSC(abs=False, osc_amp=0.252, decay=DECAY_MELODY * 0.985, osc_step=2),
OSC(abs=False, osc_amp=0.150, decay=DECAY_MELODY * 0.970, osc_step=3),
OSC(abs=False, osc_amp=0.157, decay=DECAY_MELODY * 0.955, osc_step=4),
OSC(abs=False, osc_amp=0.047, decay=DECAY_MELODY * 0.940, osc_step=5),
OSC(abs=False, osc_amp=0.029, decay=DECAY_MELODY * 0.925, osc_step=6),
OSC(abs=True, osc_amp=0.150, decay=DECAY_BASS, osc_step=0),
OSC(abs=False, osc_amp=0.252, decay=DECAY_BASS * 0.985, osc_step=2),
OSC(abs=False, osc_amp=0.268, decay=DECAY_BASS * 0.970, osc_step=3),
OSC(abs=False, osc_amp=0.347, decay=DECAY_BASS * 0.955, osc_step=4),
OSC(abs=False, osc_amp=0.157, decay=DECAY_BASS * 0.940, osc_step=5),
OSC(abs=True, osc_amp=0.150, decay=DECAY_HARMONY, osc_step=0),
OSC(abs=False, osc_amp=0.651, decay=DECAY_HARMONY * 0.985, osc_step=2),
OSC(abs=False, osc_amp=0.276, decay=DECAY_HARMONY * 0.970, osc_step=3),
OSC(abs=False, osc_amp=0.177, decay=DECAY_HARMONY * 0.955, osc_step=4),
OSC(abs=False, osc_amp=0.129, decay=DECAY_HARMONY * 0.940, osc_step=5),
]Оцените звучание мелодии с тремя инструментами на сайте «Брус-16».
Надеюсь, теперь равнодушных не останется. Синтезатор раскрылся новыми красками! При всей простоте, услышав такую партию, вряд ли кто-то скажет, что это «какой-то синус»!
Подготовка модели синтезатора для переноса в железо
У нас есть рабочая модель синтезатора звука, но в таком виде для переноса в железо она не годится. Трудности вызывают три вещи:
Не определено количество осцилляторов.
Использование плавающей точки.
Честное вычисление функции sin.
Кроме того, часть читателей может смутиться, почему не указана операция умножения, но для современных FPGA это нормально, ведь в них есть аппаратные умножители. Теперь разберем трудности из списка.
Количество осцилляторов
Здесь повторяется история с выбором количества прямоугольников. Автор приставки остановился на числе 16. Относиться к этому можно по-разному, но последний пример с мелодией на трех инструментах показывает, что это довольно немало.
Плавающая точка
Поддержать плавающую точку аппаратно, разумеется, можно. Но сложность разработки будет довольно высокой, а операции с плавающей точкой в сравнении с целочисленными потребуют значительно больше ресурсов чипа. Хочется быть уверенным, что плавающая точка действительно необходима и вообще-то для «Брус-16» такая точность была бы избыточной.
Популярным решением для FPGA в таких случаях является переход на фиксированную точку. Если хранить все параметры синтезатора и проводить вычисления с разрядностью не менее 16 бит, то в сравнении с AY-3-8910 качество звука получится просто студийным. Такой вариант проще и занимает меньше ресурсов.
Синус
Теперь подумаем, что делать с синусом, ведь это вычислительно сложная функция. Можно было бы использовать разложение в ряд Тейлора или популярный для данной задачи алгоритм CORDIC, но все это вызвало бы дополнительные сложности в разработке по сравнению со следующим вариантом — табличным. Можно заготовить таблицу с заранее вычисленными значениями синуса. С размером в 1024 значения и 16-битной разрядностью выходной результат будет вполне достойным, а аппаратно таблица займет всего 1 блок блочной памяти в случае с Tang Nano 9K.
Следующим этапом была реализована модель с 16 осцилляторами, работающая на фиксированной точке и вычисляющая синус через таблицу.
Встраивание в систему
Теперь пришло время углубиться во встраивание синтезатора в общую систему «Брус-16». По старой модели все параметры синтезатора будут храниться в системной области памяти данных, вот новая разметка:
Адрес | Назначение |
0-7727 | Данные программы |
7728-7791 | Данные звукового синтезатора |
7792-7807 | Данные кнопок |
7808-8191 | Данные прямоугольников |
Сами данные — это 64 параметра, по 4 параметра для каждого из 16 осцилляторов:
абсолютные или относительные параметры — относительно последнего синтезатора с абсолютными параметрами;
громкость;
коэффициент затухания;
частота осциллятора.
Каждый кадр вызывает аппаратный вариант функции update_voices, который перемещает параметры из памяти данных во внутреннюю память синтезатора и попутно преобразует относительные параметры в абсолютные.
С учетом частоты дискретизации на один кадр приходится 44100 / 60 = 735 семплов. Столько раз отрабатывает аппаратный вариант функции get_sample.
Аппаратная реализация
Теперь у нас есть удобная программная модель синтезатора и довольно детальное понимание того, как она работает. Возникает задача — реализовать такое же поведение в железе. И сразу возникают вопросы:
Как отобразить код в железо?
Как эффективнее всего хранить состояние синтезатора с учетом постоянных циклов с перебором осцилляторов в updatevoices и getsample?
В какое время и по каким принципам будут работать модули, чтобы быть в согласовании со всей системой?
Потребуется ли FIFO?
Сразу дать точный ответ довольно трудно, поэтому можно начать с того, что уже есть — с алгоритмов.
Функция генерации семпла
Уже есть код функции — точное описание ее поведения. Однако поведение описано с учетом последовательной модели выполнения. Такой вид не слишком наглядный, ведь в FPGA многие вычисления могут выполняться параллельно. Более наглядно можно было бы представить ту же функцию в виде графа с зависимостями по данным.
Возьмем тело цикла функции get_sample:

Здесь красным цветом изображена память, зеленым — вычислительные операции, а стрелки обозначают направление движения данных. На рисунке оно происходит слева направо. В левой части изображены параметры синтезатора в состоянии до выполнения тела цикла, а справа — после.
Для update_voices схема схожая, только добавляется связь с памятью данных, из которой будут браться исходные параметры осцилляторов.
В таком виде уже проще заметить, что в функции присутствует параллелизм. Визуально можно выделить четыре ветви, приходящие к правым параметрам. Все их можно выполнять независимо, а если одна ветвь зависит от другой, то ожидать готовности результата той ветви.
Теперь можно чисто механическим способом наложить данную схему на тактовый сигнал. Поскольку все операции простые и могут выполняться за один такт на частоте приставки, то достаточно между всеми операциями вставить промежуточные регистры.

Здесь регистры расставлены несколько избыточно. Например, перед операцией сдвига можно было бы их не ставить, поскольку операция выполняется очень быстро и вносит лишь небольшую задержку. Тем не менее в текущем варианте тело цикла можно выполнить за шесть тактов.
Не стоит забывать, что это лишь тело цикла. При генерации семпла граф должен выполняться для каждого из 16 осцилляторов. Вычисления проходят довольно быстро (6 тактов), но память может стать узким местом. Для каждого осциллятора нужно перезаписать все параметры справа и заменить все параметры слева, кроме acc и is_decay, значениями нового осциллятора.
В идеальном случае все эти операции с памятью не должны вносить никакой задержки. Получается, что за шесть тактов должны произойти три вещи:
Все вычисления должны корректно выполниться.
Результаты вычислений должны записаться в правые регистры.
Левые регистры должны обновиться значениями следующего осциллятора.
Теперь, когда известны требования к модели памяти, пришло время ее обсудить.
Модель памяти
Всего есть 16 осцилляторов, для каждого из которых по 5 параметров:
amp,
target_amp,
decay,
step,
phase.
В сумме 80 параметров, все параметры 16-битные. Такой объем можно уместить на регистрах и обойтись без блочной памяти.
Первая идея — создать для каждого из пяти типов параметров свой регистровый файл, завести регистр адреса, отражающий итерацию цикла, и каждый раз инкрементировать этот адрес. Схематично это можно изобразить так:

Это вполне рабочий вариант, но есть ли у него минусы? Для ответа на этот вопрос предлагаю рассмотреть детальнее очень обобщенную схему регистрового файла.

В центре сами регистры, слева декодер, необходимый, чтобы выбрать регистр для записи, а справа мультиплексор, необходимый, чтобы выбрать регистр для чтения.
Оценим требуемые ресурсы для такой схемы в подсчете на регистры, элементы И и ИЛИ с двумя входами. Это не очень точно для FPGA, ведь в LUT входов обычно больше (в Gowin четыре), но для приблизительной оценки сойдет.
В общем виде для регистрового файла на n значений с разрядностью p:
Регистры: n * p.
Декодер на запись: n * log_2(n) элементов И.
Выходной мультиплексор: n p элементов И, (n – 1) p элементов ИЛИ.
На пять регистровых файлов с 16 значениями по 16 бит получается 256 регистров, 320 элементов И, 240 элементов ИЛИ.
Оказывается, что на логику выбора нужного элемента уходит очень значительная часть ресурсов. Эта логика действительно необходима, когда адреса на чтение и запись каждый раз непредсказуемы, но в нашем случае каждый раз выполняется один и тот же цикл по осцилляторам с неизменным порядком. Возникает мысль избавиться от лишней логики.
Идею для реализации можно взять у систолических массивов, в них данные идут напрямую в соседние элементы. Выбора не происходит, между элементами просто провод, определяющий поток данных. Применив эту идею к нашему циклу, можно прийти к такой схеме:

Видно, что логики стало значительно меньше. Схема напоминает сдвиговый регистр, только не на уровне бит, а на уровне отдельных регистров.
В сравнении с регистровым файлом вместо адреса есть сигнал shift — циклическое продвижение всех регистров. Новое значение записывается в последний регистр, там будет храниться параметр текущего осциллятора, который нужно обновить. Поскольку порядок осцилляторов в цикле всегда остаётся неизменным, достаточно просто записать значения подряд друг за другом.
Всего таких схем пять, отдельно для каждого типа параметра осциллятора. Чтобы обновить сразу все пять параметров, достаточно везде сделать сдвиг.
На практике при явном указании синтезатору, что модуль памяти следует реализовать на регистрах, новый вариант действительно значительно экономнее по ресурсам. Однако в той же Tang Nano 9K есть возможность использования LUT в режиме SSRAM. В этом режиме используется внутренняя память LUT. Если провести аналогию с маленьким регистровым файлом, то в LUT выходной мультиплексор на чтение уже реализован. Для использования LUT в режиме памяти необходим декодер на запись. Обычно в платах с поддержкой памяти такого типа у части LUT такой декодер хотя бы частично реализован аппаратно. Из-за этого при использовании SSRAM вариант с регистровыми файлами занимает даже немного меньше места, чем новый вариант. Поэтому для FPGA он имеет преимущество. Мне же было интересно пойти дальше. Ведь кто знает, может когда-нибудь «Брус-16» все-таки будет реализован в виде СБИС. Поэтому я оставил новый вариант.
Пользоваться новым интерфейсом памяти должно быть удобно, но есть нюанс. При выполнении тела цикла приходится перезаписывать параметры текущего осциллятора. Получается, что при обновлении параметров справа параметры слева меняются еще до начала новой итерации. Например, при перезаписи phase значения в линии для обновления acc начнут меняться сразу после — и это осложняет дело.
Безусловно, можно выделить такт, подгадать точный момент, когда данные валидны, и тогда записать параметр. Но иногда можно облегчить себе задачу — сделать так, чтобы параметры слева в рамках итерации не менялись. Для этого в схему памяти был добавлен дублирующий буфер — в сумме пять регистров, в которых хранятся параметры текущего осциллятора, доступные только для чтения и обновляемые только по сдвигу.

Теперь большая часть функциональности синтезатора реализована, но без управления само по себе ничего не заработает. Нужно обеспечить согласованную работу модулей.
Синхронизация
На данный момент есть две схемы: одна реализует функцию get_sample, другая — update_voices. В результате работы над get_sample удалось добиться того, что на обработку тела цикла требуется всего 4 такта, значит на весь семпл — 64 такта. Update_voices в свою очередь работает порядка 80 тактов.
Планируется, что вся схема звукового синтезатора будет работать на общей частоте приставки — 25,2 МГц. При частоте дискретизации 44100 Гц за кадр нужно сгенерировать 44100 / 60 = 735 семплов. На один семпл имеется 420000 / 735 = ~571.43 такта. Этого ресурса хватит на несколько циклов работы get_sample и пару циклов update_voices, но тактовая частота не делится нацело на частоту дискретизации.
Поначалу я решил пренебречь цифрами после запятой и сделал стандартный счетчик до 571, но в таком случае получился досадный эффект: звук «плыл». Время от времени он то стабилизировался, то разъезжался и превращался в шум. Оказалось, что точность здесь предельно важна.
Нужен такой счетчик, который бы сбрасывался ровно 735 раз за кадр (420000 тактов), и фаза бы за это время никак не менялась, а возвращалась в изначальное значение. Этого можно добиться следующим способом:
Хранить фазу счетчика phase.
Если phase + 735 >= 420000, тогда генерировать сигнал нового семпла и присвоить phase = phase + (735 - 420000).
В ином случае присвоить phase = phase + 735.
При таком счетчике каждые 420000 тактов фаза будет приходить обратно в значение 0 без лишних накоплений. Чтобы немного снизить затраты на суммирование, можно заменить дробь 735 / 420000 на дробь 7 / 4000, сэкономив пару разрядов.
Теперь остается лишь распределить время.

На рисунке показано распределение времени в течение кадра. Схема get_sample работает сразу после начала нового семпла, ровно 64 такта. Поведение повторяется 735 раз за кадр. В последний раз — в свободное время, но строго после генерации семпла — работает схема update_voices. Работает она один раз за кадр во время копирования, тем самым обновляя параметры во внутренней памяти синтезатора значениями из памяти данных.
Тестирование
Тестирование я выполнил теми же инструментами, что и в прошлой статье. Для тестов на уровне отдельных модулей использовалась библиотека cocotb, а для теста системного уровня — verilator.
Я взял тестовую программу, воспроизводящую звучание одного синуса с затуханием, записал в текстовый файл семплы, которые сгенерировал эмулятор на C, а затем за ряд итераций добился того, чтобы и verilator генерировал тот же результат.
Пришло время финального теста в железе, но для этого ведь нужно специальное оборудование — ЦАП и динамик! Заказывать что-то дополнительно, паять и подключать не очень хотелось. Видео сейчас передается по HDMI, но ведь в него можно подмешать еще и звук!
На просторах интернета я нашёл популярную реализацию HDMI, немного поправил код, и появился красочный и невероятно приятный звук мелодии из игры Gerion!
Синтез
Привожу цифры, полученные от синтезатора GowinSynthesis в IDE от Gowin. На весь модуль, отвечающий за звук:
Характеристика | Значение |
LUT | 427 |
Register | 368 |
DSP | 3 |
BSRAM | 1 |
SSRAM | 20 |
Единственный блок блочной памяти — это таблица со значениями синуса, а 3 DSP — это умножители, которых можно было бы использовать и меньше. Но даже у Tang Nano 9K их целых 20 в режиме 18 × 18, поэтому я решил особо не экономить.
Выводы
У «Брус-16» наконец-то появился свой звук!
В прошлой статье про аппаратную реализацию я писал, что с Tang Nano 9K возникала проблема, из-за которой приставка работала на ней нестабильно. Проблема пропала, когда вместо разделения одного порта памяти данных между процессором и DMA я жестко привязал один порт к процессору, а другой отвел на периферийные устройства (все DMA). Теперь проект работает стабильно на этой плате, и более того, вышло еще одно крупное обновление!
В той же статье я заявил, что планируются улучшения в функциональности смены игр. На тот момент каждая игра для «Брус-16» представляла собой отдельный файл прошивки, и чтобы сменить игру, нужно было перепрошивать плату. Рад заявить, что теперь реализовано простое меню с загрузкой игр с SD-карты!
Если вкратце описать детали, то добавлено еще одно ядро — управляющее. Оно имеет ту же архитектуру, но меньше памяти: всего 1024 слова на код и 512 на данные. Для ядра написана прошивка на том же DSL, которая через SPI считывает игры с SD-карты и записывает в память игрового ядра. Теперь приставка наконец в достаточной мере обрела целостность.
Репозиторий с эмулятором доступен на GitHub, репозиторий с аппаратной реализацией — также на GitHub.
Хочу напомнить, что у Брус-16 появился свой сайт, на котором пока немного публикаций, зато можно поиграть в игры и поэкспериментировать с DSL в простой IDE. Буду очень рад, если кто-то из читателей напишет свою игру, на этот раз, возможно, даже со звуком!
В завершение выражаю благодарность Петру Николаевичу Советову, автору проекта «Брус-16», за рецензирование данной статьи.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.