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

Прокомментировать Просмотры: 0

Недавно появилась новая учебная игровая приставка «Брус-16». На SE7ENе о ней уже опубликованы две статьи, в которых рассказывается о пути проекта от первоначальной идеи до реализации на 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 Гц.

Синусоидальный осциллятор строится на стандартной формуле гармонических колебаний.

y = A \times \sin(2\pi \times f \times t)

Здесь 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 подобрано опытным путём: оно позволяет сделать затухание плавным и одновременно не требует хранить коэффициент со ста знаками после запятой.

Оценить звучание можно на сайте «Брус-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, нам пришлось обратиться к Википедии. Теперь хотелось бы автоматически преобразовывать ноты в соответствующие частоты. Для этого воспользуемся формулами:

частота~волны=440\times2^{(номер~в~MIDI-69)/12}номер~в~MIDI=номер~ноты~в~октаве+(номер~октавы + 1)\times12

С их помощью несложно представить мелодию как последовательность частот. Останется периодически менять частоту всех осцилляторов.

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

Так возникает идея ведущего и ведомых осцилляторов — 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 бит обеспечит вполне достойный результат, а на Tang Nano 9K займёт всего один блок блочной памяти.

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

Встраивание в систему

Теперь рассмотрим, как встроить синтезатор в общую систему «Брус-16». Как и прежде, его параметры будут храниться в системной области памяти данных. Обновлённая разметка выглядит так:

Адрес

Назначение

0-7727

Данные программы

7728-7791

Данные звукового синтезатора

7792-7807

Данные кнопок

7808-8191

Данные прямоугольников

Всего хранится 64 параметра: по четыре для каждого из 16 осцилляторов:

  • признак абсолютных или относительных параметров — относительно последнего осциллятора с абсолютными параметрами;

  • громкость;

  • коэффициент затухания;

  • частота осциллятора.

Каждый кадр запускается аппаратная версия функции update_voices. Она переносит параметры из памяти данных во внутреннюю память синтезатора и одновременно преобразует относительные значения в абсолютные.

При выбранной частоте дискретизации на кадр приходится 44100 / 60 = 735 семплов. Именно столько раз выполняется аппаратная версия функции get_sample.

Аппаратная реализация

Теперь у нас есть удобная программная модель и достаточно подробное понимание её работы. Следующая задача — воспроизвести это поведение в железе. Сразу возникает несколько вопросов:

  • Как преобразовать код в аппаратную схему?

  • Как наиболее эффективно хранить состояние синтезатора, учитывая постоянный перебор осцилляторов в updatevoices и getsample?

  • Когда и по каким правилам должны работать модули, чтобы оставаться согласованными с остальной системой?

  • Понадобится ли FIFO?

Сразу ответить на все вопросы непросто, поэтому начнём с того, что уже имеется, — с алгоритмов.

Функция генерации семпла

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

Рассмотрим тело цикла функции get_sample:

Красным обозначена память, зелёным — вычислительные операции, а стрелки показывают направление движения данных. На рисунке они движутся слева направо: слева находятся параметры синтезатора до выполнения тела цикла, справа — после.

Для update_voices схема похожа, но в неё добавляется связь с памятью данных, откуда берутся исходные параметры осцилляторов.

На таком представлении проще увидеть параллелизм. Можно выделить четыре ветви, ведущие к параметрам справа. Их вычисления выполняются независимо; если одна ветвь зависит от другой, она ожидает готовности нужного результата.

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

Регистров здесь даже больше, чем необходимо. Например, перед сдвигом их можно не ставить: эта операция выполняется быстро и добавляет лишь небольшую задержку. В показанном варианте тело цикла выполняется за шесть тактов.

Однако это только тело цикла. Для получения одного семпла граф нужно выполнить для каждого из 16 осцилляторов. Вычисления занимают немного времени — шесть тактов, но память может оказаться узким местом. После обработки каждого осциллятора необходимо перезаписать все параметры справа, а параметры слева, кроме acc и is_decay, заменить значениями следующего осциллятора.

В идеале обращения к памяти не должны добавлять задержек. Значит, за шесть тактов необходимо выполнить три действия:

  • Корректно произвести все вычисления.

  • Записать результаты в правые регистры.

  • Обновить левые регистры значениями следующего осциллятора.

Определив требования к модели памяти, перейдём к её устройству.

Модель памяти

Для каждого из 16 осцилляторов нужно хранить пять параметров:

  • 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 обычно хотя бы частично реализован аппаратно. Поэтому при использовании SSRAM регистровые файлы занимают даже немного меньше места, чем новая схема, и для FPGA оказываются предпочтительнее. Тем не менее мне хотелось пойти дальше: кто знает, возможно, когда-нибудь «Брус-16» реализуют в виде СБИС. Поэтому я оставил новый вариант.

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

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

Большая часть функций синтезатора реализована, но без управления схема не заработает. Необходимо согласовать работу её модулей.

Синхронизация

Сейчас у нас есть две схемы: для get_sample и для update_voices. При дальнейшей работе над get_sample удалось сократить выполнение тела цикла до четырёх тактов, то есть генерация семпла занимает 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 тактов фаза возвращается к нулю без накопления ошибки. Чтобы немного упростить суммирование и сэкономить пару разрядов, дробь 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

Единственный блок блочной памяти занимает таблица значений синуса. Три блока DSP используются как умножители; их число можно было бы сократить. Но даже у Tang Nano 9K есть 20 таких блоков в режиме 18 × 18, поэтому особенно экономить я не стал.

Выводы

У «Брус-16» наконец появился собственный звук!

В предыдущей статье об аппаратной реализации я упоминал нестабильную работу приставки на Tang Nano 9K. Проблема исчезла, когда вместо совместного использования одного порта памяти данных процессором и DMA я закрепил один порт за процессором, а второй выделил периферийным устройствам — всем DMA. Теперь проект стабильно работает на этой плате, а кроме того, вышло ещё одно крупное обновление!

Там же я писал о планах улучшить переключение игр. Тогда каждая игра для «Брус-16» представляла собой отдельный файл прошивки, и для смены игры приходилось перепрошивать плату. Рад сообщить, что теперь есть простое меню для загрузки игр с SD-карты!

Если коротко, в систему добавлено ещё одно, управляющее ядро. Оно использует ту же архитектуру, но располагает меньшим объёмом памяти: 1024 слова для кода и 512 для данных. Для него написана прошивка на том же DSL, которая считывает игры с SD-карты по SPI и записывает их в память игрового ядра. Теперь приставка наконец обрела необходимую целостность.

Репозиторий эмулятора доступен на GitHub, а репозиторий аппаратной реализации — здесь.

Напомню, что у «Брус-16» появился собственный сайт. Публикаций на нём пока немного, зато там можно опробовать игры и поэкспериментировать с DSL в простой IDE. Буду очень рад, если кто-нибудь из читателей создаст собственную игру — возможно, уже со звуком!

В заключение благодарю Петра Николаевича Советова, автора проекта «Брус-16», за рецензирование статьи.

 

Источник

Похожие статьи

Поиск по играм, новостям и статьям…

Введите не менее двух символов

Введите не менее двух символов