![](https://habrastorage.org/webt/in/9q/3g/in9q3gpf6leh2fgwtf8rx4rthc8.jpeg)
Любимый отечественными энтузиастами компьютерной ретро-техники компьютер «Синклер» ZX Spectrum родом из начала 1980-х годов — восьмибитный. Любимый самодельщиками нынешнего тысячелетия Arduino родом из середины 2000-х годов — тоже восьмибитный. Они похожи, но такие разные. Сегодня попробуем навести мостик между этими мирами, преодолеть пропасть в два десятка лет, и заставить два разных устройства проиграть одни и те же мелодии.
В этой статье сплетаются сразу три темы: как устроены некоторые музыкальные полифонические процедуры на ZX Spectrum, как воспроизвести результат их работы в совершенно иной реализации на Arduino, а заодно немного электроники для начинающих — рассуждения на тему, как можно правильно или неправильно подключить динамик для вывода звука к этой самой Ардуине.
▍ Это база
Для начала повторим базу — каким способом можно извлечь звук на классическом ZX Spectrum 48K и на голом Arduino, не оснащённых никакими специальными звуковыми и музыкальными устройствами.
![](https://habrastorage.org/webt/jx/y0/y-/jxy0y-s2ucggepvaygjnm_ryz8q.jpeg)
На классическом Спектруме из звуковых устройств есть только динамическая головка, подключённая к однобитному порту вывода — так называемый «бипер». Создавать звуки разной частоты можно только программно, включая и выключая выходной бит порта, точно выдерживая нужные интервалы времени между переключениями.
Так как вычислительные ресурсы у Спектрума довольно скромные, код в большинстве случаев пишут на ассемблере, и особенно это актуально для звуковых процедур — скорости процессора для них хватает едва-едва, и ни на что другое времени уже не остаётся. Перефразируя известную демку Lyra II, «when beeper is playing, scrolling must stop».
Спектрум не обладает никакими таймерами, кроме кадрового прерывания, и единственный способ организовать точные временные задержки на этом компьютере — само выполнение кода. Каждая операция процессора выполняется некоторое количество периодов тактовой частоты, и написав код на ассемблере определённым образом, можно обеспечить необходимые интервалы.
Разнообразие времени выполнения операций у процессора Z80 довольно большое, 4-19 тактов на операцию, но каждый опытный программист помнит весь список наизусть, и написание кода с точным расчётом по тактам на ZX Spectrum является вполне обычной практикой.
В зависимости от устройства звуковой процедуры, меняется набор возможностей в синтезе звука — количество каналов, управление тембром или псевдо-громкостью, наличие перкуссии. Звуковая процедура с определённым набором возможностей называется «движком» (музыкальным или биперным). По сути это программная альтернатива звуковым чипам прошлого, или прадедушка программных VST-синтезаторов. Талантливо написанная процедура может выдавать довольно интересное и уникальное звучание, учитывая скромность местных ресурсов.
![](https://habrastorage.org/webt/re/d3/e2/red3e2qbgszchff7i8hwy3wovws.jpeg)
Arduino — прибор из другой эпохи. Микроконтроллер ATmega, на котором он построен, обладает примерно в 30 раз большей вычислительной мощностью, чем Z80 на Спектруме: считается, что производительность Z80 равна примерно 0.15 MIPS на мегагерц, тогда как у ATmega это 1 MIPS на мегагерц.
Здесь тоже можно писать на ассемблере и выдерживать точные временные интервалы за счёт времени выполнения операций. Делать это даже проще, так как разнообразие времени выполнения меньше: большая часть опкодов выполняется за 1 или 2 такта. Но прибегают к ассемблеру на Ардуино редко. Тут принято писать код на C с библиотеками и не опускаться до высчитывания тактов операций. Впрочем, случаются и исключения.
Зато ATmega обладает набором из трёх аппаратных таймеров с гибкой конфигурацией и весьма неплохим разрешением. Благодаря значительно более высокой скорости выполнения кода и наличию таймеров можно реализовать те же самые алгоритмы синтеза в коде на C. К тому же, за счёт применения таймеров у контроллера остаётся немало свободного времени и для одновременного выполнения других задач.
Зачастую, если дело касается звукового оформления программ, на Ардуино играют очень простые звуки и одноголосные мелодии, используя функцию tone из стандартной библиотеки. Но его ресурсов без проблем хватит и на звучание на уровне лучших музыкальных образцов на ZX Spectrum, и не только. На большее пока замахиваться не будем, сегодня перенесём базу.
▍ Переносим базу
Далее будем заниматься переносом звуковых процедур, изначально работающих на ZX Spectrum, на классический Arduino Uno или Nano с микроконтроллером ATmega328P, и проигрывать там и там одни и те же мелодии.
Выглядеть это будет следующим образом. Мы возьмём экспортированный из соответствующего музыкального редактора или конвертера блок данных, преобразуем в заголовочный файл (*.h), и включим в нашу программу для Ардуино. В ней же реализуем эквивалентный код звукового движка, и в итоге проиграем мелодию, которая будет звучать очень похоже на её оригинальный вариант.
![](https://habrastorage.org/webt/j8/l-/m0/j8l-m0layulv7f6mmhuzmnr2jzy.png)
Для самостоятельной проверки результата нужно взять Ардуино и подключить динамик к пину 7. Я использую Uno, но подойдёт и Nano. Далее просто загружаем скетчи.
Переносить звуковые движки будем частично простым переписыванием логики кода с ассемблера на C, а частично — написанием нового кода, который делает то же, что и старый, но другими средствами: таймерами, а не задержками в коде. Для того чтобы это осуществить, нужно понять, как работают движки, что они выдают на выходе, и как можно воспроизвести такой же результат.
Тему способов синтеза звука на ZX Spectrum я уже поднимал в статье «Секреты Тима Фоллина». Но только описанными в ней подходами дело не ограничивается, и сегодня мы разберём алгоритмы ещё трёх биперных движков моего авторства. Все они относительно «новодельные», из начала 2010-х годов, но успели хорошо себя зарекомендовать за прошедшее время.
Биперно-музыкальные движки на Спектруме традиционно состоят из трёх функциональных частей.
Первая часть — парсер нотного текста. Он знает, что такое музыка: разбирает данные закодированной определённым образом мелодии и готовит данные для синтезатора, например, значения высоты тона и (псевдо-) громкости каналов. Вызывается относительно редко, например, раз в 1/50 секунды, или реже.
Вторая часть — основной цикл, собственно синтезатор звука, генерирующий звуки разных частот и каким-то образом смешивающий результат одновременной работы нескольких генераторов, создавая полифонию. Именно в ней критически важно выдерживать точные тайминги, и в ней движок проводит основную часть времени. Пока работает цикл синтеза, воспроизводится звук. Эта часть ничего не знает про музыку, она просто играет несколько каналов тона с заданными параметрами, которые ей передал парсер.
Третья часть — код проигрывания перкуссии, шумовых эффектов и имитации ударных инструментов. Он не является частью основного цикла, так как на постоянное поддержание синтеза ещё и шума у ZX Spectrum не хватает мощности. Поэтому в подавляющем большинстве местных музыкальных движков перкуссионные звуки прерывают звучание каналов тона, что не очень заметно на слух из-за очень небольшой продолжительности этих звуков.
У ZX Spectrum отсутствуют таймеры, за исключением кадрового прерывания, поэтому на время выполнения первой части, парсера, приходится останавливать цикл генерации, что создаёт перерывы в звуке.
Одни способы синтеза не очень чувствительны к этим перерывам, что позволяет проводить довольно много времени вне цикла генерации и использовать более компактные форматы хранения данных, требующие больше времени на парсинг.
Другие способы, напротив, очень чувствительны к паузам, они вносят в звучание паразитный тон и прочие неприятные артефакты (гудение, щелчки). В этом случае приходится максимально оптимизировать код парсера и использовать другие форматы, выигрывая в скорости парсинга ценой увеличения объёма хранимых данных.
Так как Ардуино может выполнять парсинг значительно быстрее, и делать это, не прерывая генерацию звука, мы никак не будем менять формат хранения данных музыкального трека, и при этом качество звучания будет несколько лучше — исчезнут артефакты, связанные с перерывами в генерации звука.
К счастью, и форматы, и код синтеза в биперных движках содержательно достаточно просты, и удивительным образом их реализации как на ассемблере Z80, так и на C для Ардуино, укладываются в несколько сотен строк кода. Разбирать их все мы, конечно же, не будем, только самую суть.
Переходим к конкретным движкам. Разбирать их будем не в историческом порядке, а согласно возрастанию сложности реализации на Ардуино.
▍ Octode
Движок Octode обладает наибольшей полифонией из выбранных мной для статьи — аж восемь каналов. Вдохновлён он гораздо более ранним восьмиканальным же ZX-7 (Jan Deak, Словакия, 1990), и обладает очень похожим звучанием при кардинально иной реализации, позволившей значительно снизить расход ОЗУ.
Несмотря на выдающиеся по меркам ZX Spectrum количественные показатели, сам движок весьма прост: восемь каналов тона с 8-битными делителями, без громкости и других дополнительных возможностей, плюс максимально примитивная перкуссия. В нотном тексте хранятся сразу делители для счётчиков, парсить его очень легко.
![](https://habrastorage.org/webt/bb/ee/mh/bbeemh-euz4xwaugp5glwnliiia.png)
Для начала нужно получить музыкальные данные. Первое время Octode не имел поддержки в музыкальных редакторах, и значительная часть музыки для него была написана с помощью конвертера для командной строки, получающего на входе созданный по особым правилам стандартный XM-модуль. В какой-то момент я переписал этот конвертер на Python, а когда портировал движок на Arduino, просто модифицировал его, чтобы он сразу же выдавал заголовочный файл вместо ассемблерного листинга.
В тесте будем использовать XM-модуль автора, имя которого я, к сожалению, забыл — дело было давно, а в самом модуле он не подписался. В версии для ZX Spectrum он звучит так:
Формат данных максимально прост и структурно соответствует исходному XM-модулю: есть паттерны, каждые сохраняемый в отдельный массив, и есть список позиций, сохраняемый в виде массива указателей на данные паттернов.
Паттерн начинается с двух байт темпа проигрывания, который не меняется на протяжении паттерна. Далее следует по восемь байт нот, это непосредственно 8-битные делители, такие же, как в версии для ZX Spectrum, рассчитанные для такой же частоты работы главного цикла, примерно 12700 Гц. В пустых позициях используется нулевой байт. В этом движке каждая нота звучит одну строку, если нужно протяжённое звучание — ноты повторяются столько раз, сколько нужно.
Строка с нот может предваряться ещё одним байтом, со значениями от 0xf0 и выше — это флаг перкуссионного инструмента, который должен прозвучать перед строкой нот. Делителей с такими значениями в нотном тексте не бывает, поэтому байт перкуссии легко отличить от ноты.
Основной звуковой цикл в оригинале на ассемблере Z80 в сокращённом до одного канала виде выглядит так:
soundLoop
xor a ;4
dec b ;4
jr z,.la0 ;7/12
nop ;4
jr .lb0 ;12
.la0
.frq0=$+1
ld b,0 ;7
.off0=$
scf ;4
.lb0
;...
.lb7
exx ;4
sbc a,a ;4
and 16 ;7
out (#fe),a ;11
dec l ;4
jp nz,soundLoop ;10=275t
dec h ;4
jp nz,soundLoop ;10
Выглядит это достаточно запутано, потому что требуется выдерживать стабильное время выполнение всех веток условий. Также в целях ускорения применяется самомодифицирующийся код, с его помощью перезагружается счётчик (подставляется фактическое значение в операцию ld b,0) и заглушается канал, когда он не нужен (операция scf заменяется на nop).
В псевдокоде это выглядит значительно проще. В начале цикла сбрасывается «выходной бит», в оригинале хранимый во флаге переноса. Далее любой звучащий канал может его установить.
Для каждого канала присутствует одинаковый кусок:
счётчик_канала_1 -= 1
если счётчик_канала_1 == 0:
счётчик_канала_1 = делитель_канала_1
если канал не заглушён:
установить выходной бит
И в итоге, после обработки счётчиков восьми каналов:
вывести выходной бит в порт бипера
переход на цикл
Идея здесь в том, что счётчик каждого канала при переполнении генерирует короткий импульс-иголку длительностью в одну итерацию звукового цикла. Если переполняется одновременно несколько счётчиков, всё равно генерируется только один импульс.
Такой способ синтеза звука обладает весьма специфическими артефактами. Во-первых, он даёт очень «тонкое» и довольно тихое звучание, так как скважность генерируемого сигнала получается в доли процента. Во-вторых, низкие ноты обладают меньшей, а не большей энергией, из-за чего высокие ноты ощущаются сильно громче низких. В третьих, в отличие от традиционных методов смешивания источников звука, здесь громкости не складываются: два одновременно звучащих в унисон канала не дают увеличения громкости.
Из-за этих особенностей аккорды и прочие созвучия обладают специфическими призвуками, и некоторые созвучия работают лучше других. Для имитации громкости композитор может использовать пачку из нескольких каналов, дублируя в них одинаковые ноты с небольшой расстройкой, что вносит в звучание эффект «фазера».
Хотя все перечисленные качества фактически являются (серьёзными) недостатками, они придают звучанию музыки на этом движке довольно уникальную окраску, которую слушатели описывают как «стена звука» или «рой пчёл внутри пылесоса». В принципе это неплохо.
Что касается перкуссии, в этом движке она крайне примитивна и представляет из себя простые короткие щелчки. Поэтому я не воспроизводил её алгоритм в точности, а просто сделал нечто похожее по звучанию более простым способом.
Реализация описанного принципа синтеза на Arduino также не отличается сложностью. Используется один таймер, работающий на частоте оригинального цикла генерации звука, около 12700 герц, и вызывающий регулярное прерывание, в обработчике которого и реализован синтезатор звука (приводится с сокращением, два канала из восьми):
ISR(TIMER2_COMPA_vect)
{
SPEAKER_PORT = output_state;
if (!parser_sync)
{
--tempo_counter;
if (!tempo_counter) parser_sync = 1;
}
output_state = 0;
if (!click_drum_len)
{
if (cnt_load[0]) {
--cnt_value[0];
if (!cnt_value[0]) {
output_state = SPEAKER_BIT;
cnt_value[0] = cnt_load[0];
}
}
//...
if (cnt_load[7]) {
--cnt_value[7];
if (!cnt_value[7]) {
output_state = SPEAKER_BIT;
cnt_value[7] = cnt_load[7];
}
}
}
else
{
if (((click_drum_cnt_1 ^ click_drum_cnt_2 ^ click_drum_cnt_3) >> click_drum) & 1) output_state = SPEAKER_BIT;
click_drum_cnt_1 += 3;
if (click_drum_len & 1) click_drum_cnt_2 += 18;
if (!(click_drum_len & 3)) click_drum_cnt_3 += 1;
--click_drum_len;
}
}
В самом начале обработчика в выходной порт выводится бит, подготовленный и сохранённый в переменную в предыдущем вызове обработчика. Это нужно, чтобы время вывода бита всегда отставало на фиксированное время от начала обработчика. На высоких частотах дискретизации это помогает избежать эффекта «джиттера» (фазового дрожания), выражающегося в потере чистоты звучания.
Далее сбрасывается новый выходной бит, и выполняется одна из веток, в зависимости от текущего режима синтеза: тональные каналы или эффект перкуссии.
В ветке тональных каналов находится развёрнутый цикл обновления счётчиков всех восьми каналов. Код практически полностью соответствует псевдокоду, приведённому выше, отличается только способ заглушения канала для пустых нотных позиций. Можно сократить код и свернуть цикл перебора каналов, но это внесёт накладные расходы и увеличит расход времени процессора.
В ветке перкуссии генерируется шумовой эффект, занимающий заданное количество прерываний. Счётчик длительности шумового эффекта каждый раз декрементируется, и когда он достигает нуля, снова начинают работать тональные каналы.
Синтезатор звука работает в обработчике прерывания таймера всё время, а парсер нотного текста находится в основном потоке и синхронизируется по флагу в прерывании. Это нужно, чтобы время выполнения обработчика не превышало периода вызова прерывания в момент обновления позиции в нотном тексте.
Код парсера разбирать не будем, он достаточно очевиден из описания формата, приведённого выше. Цикл синхронизации в основном потоке выглядит так:
while (1)
{
if (parser_sync)
{
song_update_row();
parser_sync = 0;
}
delay(0);
}
Важный момент, который стоит отметить — способ синхронизации. Счётчик прерываний в коде 16-разрядный, так как одна строка нотного текста может звучать дольше 256 прерываний. Но на микроконтроллере ATmega, который по своей природе 8-разрядный, операции с 16-разрядными переменными не являются атомарными, они обрабатываются по одному байту за раз. Между обработкой этих байт в основном потоке может прийти прерывание, и если оно изменяет эту же переменную, результат работы кода будет некорректным.
Эту проблему можно обойти фигурным запретом и разрешением прерываний в нужные моменты времени. Но лучше просто использовать для общения между основным потоком и обработчиком прерывания гарантировано атомарный восьмибитный флаг, а всю 16-битную математику выполнять только внутри обработчика прерывания.
Слушаем итоговый результат:
Архив с оригинальным кодом для ZX Spectrum
Архив с кодом порта на Arduino
▍ Phaser1
Движок Phaser1 не обладает такой большой полифонией, как Octode — всего два канала. Его сильная сторона в разнообразных динамичных тембрах, местами напоминающих звучание SID на Commodore 64. Он вдохновлён классическим одноканальным движком Plip Plop (звучит в игре Ping Pong и множестве игр Ocean) за авторством Джонатана Смита, идеи которого успешно развили испанские разработчики из Topo Soft (игры Stardust, Score 3020).
По сравнению с играми из прошлого, в Phaser1 помимо одного канала с эффектом модуляции есть второй, простой квадратный, пригодный для одновременного ведения басовых партий. Также он снабжён более мощной перкуссией на основе однобитных сэмплов. Эта комбинация возможностей оказалась удивительно удачной, до сих пор появляются новые композиции, написанные под этот движок. Впоследствии было создано множество улучшенных, значительно более мощных и менее известных версий этого движка (Phaser2,3,4,X), но об этом как-нибудь в другой раз.
![](https://habrastorage.org/webt/71/qb/iw/71qbiwbag3cy8ae5cxtxaucqblg.png)
Phaser1 изначально был опубликован вместе с собственным музыкальным редактором. Однако, вскоре появился музыкальный кросс-редактор Beepola для Windows, позволяющий создавать музыку для ZX Spectrum 48K с применением разных биперных движков, и в том числе в него был добавлен Phaser1. Основная масса музыки написана с применением этого редактора.
Beepola позволяет экспортировать код движка на ассемблере Z80 (в текстовом исходнике или бинарный), блок данных с музыкой (также в исходнике или бинарный), или образ кассеты с запускаемой программой. Воспользуемся идущим в комплекте с редактором музыкальным примером от Mister BEEP, экспортируем бинарный блок данных, сконвертируем в заголовочный файл и применим его в порте на Arduino.
Алгоритм синтеза в Phaser1 радикально отличается от Octode. Здесь смешивание двух звучащих каналов производится их чередованием во времени. Генераторы тона используют 16-битные аккумуляторы, а не счётчики с декрементом, и выдают нормальный меандр со скважностью 50%, а не узкие импульсы. Это даёт громкий чистый звук.
Также у одного из каналов здесь не один, а два генератора. Их выходы смешиваются по XOR, что и позволяет создавать эффекты фазового смещения, окрашивающие звук. Это может быть октавное удвоение, или плавно изменяющаяся из-за интерференции расстроенных генераторов скважность.
Код синтезатора в оригинальной версии гораздо короче, чем в Octode, но разобрать его сложнее:
soundLoop
exx ;4
exa ;4
add hl,bc ;11
out (#fe),a ;11
jr c,$+4 ;7/12
jr $+4 ;7/12
xor 16 ;7
add ix,de ;15
jr c,$+4 ;7/12
jr $+4 ;7/12
xor 16 ;7
exa ;4
out (#fe),a ;11
exx ;4
add hl,bc ;11
jr c,$+4 ;7/12
jr $+4 ;7/12
xor 16 ;7
dec e ;4
nop ;4
jr nz,soundLoop ;7/12=152, aligned to 8t
В псевдокоде логика становится понятнее:
аккумулятор_А_канала_1 += слагаемое_А_канала_1
если произошло переполнение аккумулятор_А_канала_1:
инвертировать выходной_бит_канала_1
аккумулятор_Б_канала_1 += слагаемое_Б_канала_1
если произошло переполнение аккумулятор_Б_канала_1:
инвертировать выходной_бит_канала_1
вывести выходной_бит_канала_1 в порт бипера
аккумулятор_канала_2 += слагаемое_канала_2
если произошло переполнение аккумулятор_канала_2:
инвертировать выходной_бит_канала_2
вывести выходной_бит_канала_2 в порт бипера
переход на цикл
Теперь видно три одинаковых генератора меандра. У первого канала два генератора по переполнению 16-битных аккумуляторов меняют один и тот же выходной бит, после чего он выводится в порт. У второго канала такой генератор только один, его выходной бит сразу выводится в порт.
Возможности каналов отличаются, потому что на два одинаковых канала просто не хватило скорости. Метод смешивания каналов чередованием требует, чтобы частота дискретизации (общее время цикла) была за границей слышимого диапазона. В противном случае становится слышна несущая в форме постоянного неприятного высокочастотного свиста.
Перкуссия в движке использует короткие однобитные сэмплы с частотой дискретизации около 24 кГц. Из-за небольшой продолжительности их звучания данные восьми сэмплов перкуссии укладываются в 1024 байта. Разные ударные инструменты хранятся в разных битах одного байта, чтобы упростить их выборку. Сами исходные сэмплы были созданы на звуковом чипе AY-3-8910, и это придало им характерное чиптюновое звучание.
Что касается парсера нотного текста, здесь он значительно сложнее, так как изначально музыкальным данным требовалось умещаться в памяти оригинального Спектрума вместе с кодом редактора. Одна строка нотного текста может быть закодирована набором байт, среди которых есть специальные маркеры для глушения каналов, смены «инструмента» (сочетания параметров для генераторов первого канала), завершения паттерна и так далее. Ноты хранятся не в виде делителей, а в 8-битных индексах, выбирающих фактические 16-битные делители из таблицы нот. Разбирать в тексте мы всё это не будем, главное, что код есть, и он работает.
На Ардуино реализация Phaser1 использует тот же принцип, что и Octode: один таймер, работающий на частоте оригинального цикла генерации звука. Но теперь это 48 кГц, так как в оригинале за один цикл выводится два бита, и в порте также реализована схема чередования каналов для аутентичности звучания.
ISR(TIMER2_COMPA_vect)
{
SPEAKER_PORT = output_state ? SPEAKER_BIT_A : SPEAKER_BIT_B;
if (!parser_sync)
{
--tempo_counter;
if (!tempo_counter) parser_sync = 1;
}
if (!drum_sample)
{
if (channel_active)
{
unsigned int prev_acc = channel_1a_acc;
channel_1a_acc += channel_1a_add;
if (channel_1a_acc < prev_acc) channel_1_out ^= SPEAKER_BIT_A;
prev_acc = channel_1b_acc;
channel_1b_acc += channel_1b_add;
if (channel_1b_acc < prev_acc) channel_1_out ^= SPEAKER_BIT_A;
output_state = channel_1_out;
}
else
{
unsigned int prev_acc = channel_2_acc;
channel_2_acc += channel_2_add;
if (channel_2_acc < prev_acc) channel_2_out ^= SPEAKER_BIT_A;
output_state = channel_2_out;
}
channel_active ^= 1;
}
else
{
if (pgm_read_byte(drum_sample_data + (drum_ptr >> 1))&drum_sample) output_state = SPEAKER_BIT_A; else output_state = 0;
++drum_ptr;
if (drum_ptr >= 1024 * 2) drum_sample = 0;
}
}
Структура обработчика также похожа на реализацию для Octode. Такая же система с выводом предыдущего бита, декрементом счётчика темпа и флагом синхронизации, два режима цикла — генерация тональных каналов или проигрывание однобитных сэмплов ударных. Выборка байт в сэмплах ударных происходит через раз, чтобы компенсировать вдвое более высокую частоту прерываний по сравнению с частотой дискретизации самих сэмплов.
Архив с оригинальной версией Phaser1 для ZX Spectrum
Архив порта Phaser1 на Arduino
▍ Tritone
Название движка Tritone намекает, что он трёхканальный. Как и Phaser1, он использует 16-битные аккумуляторы, но вместо эффектов модуляции используется трюк, позволяющий управлять скважностью в традиционных для синтеза звука пределах — от 50% до единиц процентов, всего восемь скважностей.
Второй интересной особенностью движка являются неравные громкости каналов. Складываются каналы, как и в Phaser1, простым чередованием, но время активности каналов различается. Это создаёт ощущение различающейся громкости и открывает возможности к созданию эффектов эха, за счёт повторения партий громкого канала с некоторым отставанием по времени на более тихом канале.
Для создания музыки изначально предлагалось использовать конвертер из XM-модулей, но вскоре была реализована поддержка движка в Beepola, чем мы и воспользуемся — в порте будет проигрываться экспортируемый из редактора блок музыкальных данных.
Код оригинального движка выглядит так:
soundLoop
add hl,bc ;11
ld a,h ;4
exx ;4
.duty0=$+1
cp 128 ;7
sbc a,a ;4
and c ;4
add ix,de ;15
out (#fe),a ;11
ld a,ixh ;8
.duty1=$+1
cp 128 ;7
sbc a,a ;4
and c ;4
out (#fe),a ;11
add hl,sp ;11
ld a,h ;4
.duty2=$+1
cp 128 ;7
sbc a,a ;4
and c ;4
exx ;4
dec e ;4
out (#fe),a ;11
jp nz,soundLoop ;10=153t
dec d ;4
jp nz,soundLoop ;10
Главный трюк в этом коде — получение разных скважностей из одного простого 16-битного аккумулятора. Понять его проще всего следующим образом.
Диапазон значений аккумулятора 0..65535 покрывает длительность одного периода частоты. Традиционно такие генераторы работают по переполнению, то есть выходное состояние меняется, когда результат очередного сложения не помещается в аккумулятор, и старший бит результата отбрасывается. Это даёт скважность 50%.
![](https://habrastorage.org/webt/_s/e0/lh/_se0lhowzp8oa9bl5wjls2teb6k.png)
Но вместо переполнения можно просто взять старший бит аккумулятора. Половину периода он будет 0, вторую половину в 1 — опять получаем скважность 50%. Это эквивалентно сравнению текущего значения аккумулятора с 32768 — половиной диапазона его значений. Далее всё просто: почему бы не сравнить его с четвертью, и не получить скважность 25%? Можно использовать любые значения и таким образом получать различные скважности.
Теперь разберём принцип работы одного канала в псевдокоде:
аккумулятор_канала_1 += слагаемое_канала_1
если старший байт аккумулятор_канала_1 больше значения скважности:
устанавливаем выходной_бит_канала_1
иначе:
сбрасываем выходной_бит_канала_1
Значение скважности здесь может быть от 0x80 до 0xf0 с шагом в 16 (проверяются только старшие четыре бита), что покрывает диапазон скважностей от 50% до 3.125%. На слух это ощущается как изменение от чистого квадрата до довольно «тоненького» звучания.
Портирование этого движка на Ардуино несколько сложнее предыдущих. Дело в том, что в них присутствовал основной цикл с постоянной частотой повторения, который хорошо заменялся периодическим таймером. В Tritone тоже есть основной цикл, с частотой чуть меньше 23 кГц, но один период его работы делится на три неравные по длительности части. Это важная особенность, именно она создаёт разную громкость каналов.
Чтобы повторить этот эффект, таймер в порте Tritone задействуется иначе. Теперь он запускается на отсчёт заданного количества тактов системы, по прошествии которого срабатывает обработчик прерывания. В обработчике сделан указатель-трамплин для вызова функций, содержащих разные ветки обработчика прерываний.
void (*interrupt_handler)(void);
ISR(TIMER2_COMPA_vect)
{
interrupt_handler();
}
Каждая из веток сразу устанавливает новый период ожидания, выводит очередной заранее подготовленный бит в порт динамика и выполняет свою логику, которая укладывается по времени до срабатывания следующего прерывания. В завершение трамплин переводится на следующую ветку установкой другого указателя функции.
Для похожего распределения времени работы каналов были рассчитаны следующие значения: 320, 224 и 152 такта. В сумме это даёт 696 тактов на один период генерации звука, 16000000/696 = частота дискретизации 22988 герц, близкая к оригинальной.
Ветки обработчика прерываний выглядят так. Первая ветка:
void interrupt_handler_tone_1()
{
OCR2A = PULSE_SLOT_1 / 8 - 1;
SPEAKER_PORT = out[2];
acc[0] += add[0];
acc[1] += add[1];
acc[2] += add[2];
if ((acc[0] >> 8) >= duty[0]) out[0] = SPEAKER_BIT; else out[0] = 0;
if ((acc[1] >> 8) >= duty[1]) out[1] = SPEAKER_BIT; else out[1] = 0;
if ((acc[2] >> 8) >= duty[2]) out[2] = SPEAKER_BIT; else out[2] = 0;
interrupt_handler = interrupt_handler_tone_2;
}
Вторая ветка:
void interrupt_handler_tone_2()
{
OCR2A = PULSE_SLOT_2 / 8 - 1;
SPEAKER_PORT = out[1];
if (drum_request)
{
drum_request = 0;
interrupt_handler = interrupt_handler_drum;
return;
}
interrupt_handler = interrupt_handler_tone_3;
}
Третья ветка:
void interrupt_handler_tone_3()
{
OCR2A = PULSE_SLOT_3 / 8 - 1;
SPEAKER_PORT = out[0];
run_parser_sync();
interrupt_handler = interrupt_handler_tone_1;
}
Для простоты реализации перкуссия в порте Tritone реализована в формате сэмплов перкуссии оригинального движка, оцифрованных на частоте 45 кГц. Так как они сэмплы очень короткие, и закодированы в режиме ШИМ, то есть длительностями между сменами состояния, они занимают считанные сотни байт. Для их проигрывания используется отдельная ветка обработчика прерывания таймера, активируемая в одной из веток генерации тона (устанавливается 16-битный указатель на данные сэмпла).
void interrupt_handler_drum()
{
OCR2A = DRUM_SLOT / 8 - 1;
SPEAKER_PORT = drum_output;
if (drum_pulse_length)
{
--drum_pulse_length;
}
else
{
drum_output ^= SPEAKER_BIT;
drum_pulse_length = pgm_read_byte(drum_data);
++drum_data;
if (!drum_pulse_length) interrupt_handler = interrupt_handler_tone_1;
}
if (drum_sync & 1) run_parser_sync(); //drum sample rate is twice higher than tone sample rate, keep sync
++drum_sync;
}
Когда сэмпл перкуссии доиграл, снова активируется генератор тона. Несмотря на довольно головоломную цепочку обработчиков прерывания, всё работает. Слушаем:
Архив оригинальной версии Tritone для ZX Spectrum
Архив порта Tritone для Arduino
▍ Подключаем динамик
После того, как мы провели все предыдущие мероприятия, самое время поднять довольно интересный: а как вообще правильно подключить к Arduino динамик или наушники, чтобы услышать звук?
![](https://habrastorage.org/webt/k9/ex/6k/k9ex6kpnhwtjobd1zxkdrvlpqq4.png)
В сети вы можете найти подобные картинки: динамик подключается одним проводом к выходному пину, а другим к земле либо плюсу питания. Именно так я и сделал в начале статьи, подключив динамик между землёй и пином 7.
И хотя это сработает, и звук будет, на самом деле вопрос значительно сложнее, чем может показаться. Полное объяснение, как же делать правильно, мне никогда не приводилось. Это же Ардуино, тут так не принято: приткнул и работает, сгорело — получил опыт, в следующий раз сделал лучше.
Сварщик я не настоящий, поэтому сильно углубляться в тему не буду — я в ней не настолько разбираюсь. Моя задача обозначить общее направление мысли, о каких моментах в принципе стоило бы задуматься при работе с подобными схемами.
Во-первых, нужно учитывать нагрузочную способность выходов Arduino. Она не бесконечна, и составляет около 20 мА продолжительной нагрузки, до 40 мА в кратковременном пике. Также нужно помнить, что низкий логический уровень ATmega имеет напряжение 0.9 вольт, высокий — 4.3 вольта.
![](https://habrastorage.org/webt/of/mi/ny/ofminyzopau4zlbuajny5wj-ntk.png)
Дальше вспоминаем закон Ома, или применяем какой-нибудь онлайн-калькулятор и понимаем, что сопротивление нагрузки для такого тока и напряжения должно быть равно 220 Ом. Если подключить динамик с сопротивлением 150-250 Ом (17-28 мА), всё будет хорошо.
На практике же чаще встречаются низкоомные динамики и наушники. Например, 4 или 8 ом для больших громкоговорителей, и 16-32 Ом для наушников. Если подключить 4-омный динамик, ток в цепи будет аж целый ампер. В реальности, конечно, всё несколько сложнее, и такой ток вряд ли возникнет, но в любом случае микроконтроллеру подобная нагрузка не понравится, и подключать динамики напрямую не стоит — может повредиться выходной порт.
Если всё-таки нужно по-быстрому, например, для тестов, подключить низкоомные наушники, безопасно это делать через резистор 220 Ом, включённый последовательно с нагрузкой. Это ограничит ток в цепи, но снизит громкость звучания, так как только малая часть выходной мощности попадёт на наушники, а остальная будет рассеиваться в тепло на резисторе.
![](https://habrastorage.org/webt/tg/pd/pq/tgpdpqsrkfuscyksi7bnj6_jopg.png)
Во-вторых, есть так называемая «постоянная составляющая»: некоторое всегда присутствующее напряжение, смещающее диффузор динамика и не дающая ему совершать полный ход. Её нужно отсекать последовательным включением конденсатора. В случае биперной музыки постоянная составляющая незначительна (равна низкому логическому уровню), поэтому и без конденсатора работает приемлемо.
![](https://habrastorage.org/webt/b3/_u/al/b3_ualacoaylqy-8kcgzm7mcf40.png)
![](https://habrastorage.org/webt/xn/zt/2_/xnzt2_piiqol1xynnghklrkq_00.png)
В-третьих, некоторые способы синтеза звука используют ШИМ, имеющий высокочастотную составляющую. Да и в целом неплохая идея использовать фильтр низких частот на выходе звука. Это может быть простейший пассивный фильтр из резистора и конденсатора (RC-цепочка). Рассчитать его можно опять же в онлайн-калькуляторах, выбрав подходящую для задачи частоту среза. Например, для ШИМ это может быть 50 кГц, тогда нужен резистор на 330 Ом и конденсатор на 10 нанофарад. А для портов биперных движков имеет смысл делать срез пониже, на 20 кГц, тогда конденсатор нужен на 22 нанофарада.
![](https://habrastorage.org/webt/oq/bw/lq/oqbwlqwavwdvgulovimnj0mhpvq.png)
Наконец, в-четвёртых. Часто можно встретить совет поставить параллельно динамику шунтирующий диод (так называемый «flyback diode»), аналогично включению электромагнитного реле. Диод защищает схему и динамик от самоиндукции катушки динамика, которая, двигаясь в магнитном поле, сама генерирует электричество. На практике у маленьких динамиков на небольшой громкости этот эффект незначителен, и в реальных устройствах и схемах диод можно встретить нечасто. Но если встретите, не удивляйтесь. Это норма!
▍ Говорите громче
Ещё один интересный вопрос — как сделать звук громче. Ведь напрямую большой динамик подключить нельзя, а маленький высокоомный динамик звучит не сказать, что громко и разборчиво.
Если громкость нужно повысить немного, например, в самоделке с маленьким динамиком, есть трюк, не требующий никаких дополнительных деталей. По научному он называется мостовым (ещё дифференциальным) включением динамика, а по-простому это означает, что вместо одного выходного пина и земли (или плюса) динамик подключается между двумя пинами.
Смысл этого мероприятия в том, что программно можно менять полярность тока, проходящего через динамик, выставляя на один выход логический ноль, а на другой единицу, либо наоборот. Это увеличивает размах хода диффузора и повышает громкость примерно вдвое. Правда, если вы прочли предыдущий раздел, и решили ставить шунтирующие диоды на динамик, в таком включении этого делать нельзя.
Код придётся доработать таким образом, чтобы на второй пин всегда выводилось инверсное состояние первого. Чтобы не возникало фазовых искажений, эти пины должны находиться на одном порте, а их состояние изменяться атомарной операцией.
Например, установка одного и сброс пина обычно выглядят так:
SPEAKER_PORT |= SPEAKER_BIT
SPEAKER_PORT &= ~SPEAKER_BIT
Для дифференциального подключения это будет примерно так:
SPEAKER_PORT = (SPEAKER_PORT | SPEAKER_BIT1) & ~SPEAKER_BIT2
SPEAKER_PORT = (SPEAKER_PORT | SPEAKER_BIT2) & ~SPEAKER_BIT1
Этот способ можно использовать даже для управления громкостью, с двумя градациями: громче и тише. Для тихого режима просто не меняем уровень на любом из пинов.
Если же нужно сделать радикально громче, решение одно: использовать усилитель. Например, это может быть элементарная схемка на одном биполярном транзисторе. Что может быть проще?
![](https://habrastorage.org/webt/dm/x5/g_/dmx5g_ykm95pk4tuxypuz9gimq0.png)
Подобное решение можно встретить во множестве туториалов. Несмотря на предельную визуальную простоту, оно скрывает немало тонкостей. Например, почему номиналы деталей именно такие, детали расположены таким образом, и можно ли поставить какие-то другие.
Теории и расчётам подобных схем посвящены целые страницы учебников по электронике. В двух словах и на пальцах всё это, конечно, не объяснить, но я попробую. В комментариях к статье вы найдёте детальное описание, где я ошибся, и как всё устроено на самом деле.
Все элементы цепи — выход Ардуино, транзистор и динамик — имеют ограничение по максимальному току, который они могут выдержать, не выходя из строя. Резисторы нужны, чтобы установить в цепях допустимые токи. Есть формулы для их расчёта, в которых учитывается напряжение питания и параметры самого транзистора, которые у разных типов транзисторов могут различаться весьма сильно.
Резистор в цепи коллектора ограничивает ток, текущий через открытый транзистор и динамик. Здесь та же проблема, что с прямым подключением низкоомного динамика к выходу Ардуино: ток получается слишком большим. Смотрим в справочнике максимальный ток коллектора и напряжение насыщения коллектор-эмиттер (падение напряжения на открытом транзисторе). Вычитаем последнее из напряжения питания и считаем сопротивление нагрузки по закону Ома.
Например, транзистор 2N2222 с максимальным током коллектора 800 мА при питании от пяти вольт выдержит в нагрузке 8-омный динамик без дополнительного резистора, но 4-омную нагрузку уже не потянет. А вот наш любимый КТ315 выдерживает всего 100 мА, ему при пятивольтовом питании нужна нагрузка с сопротивлением минимум 50 Ом.
Номинал резистора в цепи базы зависит от входного напряжения, тока нагрузки и коэффициента усиления по току (то самое загадочное обозначение h21э). Например, для КТ315 мы выяснили, что нужна нагрузка 50 Ом и ток коллектора будет 100 мА. По справочнику его h21э примерно равен 55. Делим 100 мА на 55, получаем требуемый ток базы 1.81 мА. Максимальное входное напряжение 4.2 вольта. Снова применяем закон Ома, получаем примерно 2.3 кОм.
![](https://habrastorage.org/webt/sy/u0/ar/syu0arqipjrrsry5kasrid3qndi.jpeg)
Также встречаются более правильные варианты усилителей, с увеличенным количеством деталей, со смещением тока базы, разделительными конденсаторами, и так далее. Их преимущество в меньших искажениях и потерях мощности. Расчёт происходит подобным образом, но сложнее, с большим количеством факторов. Поэтому, когда нет понимания, лучше придерживаться номиналов на схеме: есть шансы, что кто-то посчитал их правильно. Если неправильно — вероятно, работать всё равно будет, но с искажениями звука или нагревом транзистора.
![](https://habrastorage.org/webt/jf/wx/v4/jfwxv44okcsxub4ndmymk5xxvn8.jpeg)
Чтобы не заниматься всей этой занимательной математикой и не рисковать прилюдно опозориться, как я в объяснениях выше, лучше оставить выполнение трюков с транзисторами профессионалам, и просто взять готовый современный модуль усилителя. Например, копеечный на микросхеме PAM8403. Там всё просто: питание, сигнал, динамик. Расчётов и наладки не требует, будет орать, как потерпевший.
▍ Заключение
Разумеется, мощности классической Arduino может хватить и на гораздо более интересные звуки: как минимум на эмуляцию классических звуковых чипов или на проигрывание MOD-файлов. Правда, с повышением сложности звучания однобитного выхода уже маловато, нужно прибегать к ШИМ, а лучше — к качественному внешнему ЦАП. Возможно, проделаем это в другой раз.
© 2025 ООО «МТ ФИНАНС»
Telegram-канал со скидками, розыгрышами призов и новостями IT ?
![](https://habrastorage.org/webt/yo/se/km/yosekm4h_f7y7oia-ghbbpc0phi.png)
qw1
Хотел услышать разницу, насколько ATMega играет лучше Z80, но прямого сравнения не получилось. Z80 записан с эмулятора, качественно, а ардуинка - через спикер-микрофон. Вот если бы ролики ардуины писались через линейный вход звуковой карты, можно было бы насладиться звучанием.
shiru8bit Автор
Я записал именно видео, потому что не знаю работающих сервисов для выкладывания встраиваемого аудио. Для видео нужна хоть какая-то картинка, поэтому хотел записать и Z80 с реального ZX, но это оказалось несколько проблематично, сроки поджимали, пришлось переключаться на эмулятор. Записать Ардуину в линию могу, сделаю чуть попозже.
SADKO
Ну, хоть бы динамик какой менее ублюдочный... Помню ЛЭМЗовский спектрум "Дуэт", внешне 1 в 1 "Микроша", и динамик соответствующий, после всяких пьезо пищалей, музыка производила сильное впечатление...
shiru8bit Автор
Тут соображения были следующие: одиночный динамик непосредственно к пинам — это наглядно, как на картинках, для читателя «о, ну это я могу». Для подключения нормального динамика нужен усилитель, и психологически это — «ой, ещё платы какие-то нужны, проводов много, ну его».
Javian
Можно было подключить звуковой трансформатор, а с него в усилитель. Музыка станет интереснее - прямоугольные импульсы станут более синусообразными.
Timick
А в старых компах 80-х тоже была обвязка с усилением на бипер?
qw1
В серийных образцах, конечно нет. Там устройство было спроектировано в режиме максимальной дешевизны. Но пользователи сами подпаивались к биперу и выводили звук - кто на телевизор, кто на усилитель с колонками, кто на магнитофон, включенный в режим записи.
checkpoint
Именно так! Через мафон музыку и слушали в основном, причем не только на ZX. Я подключал мафон к спикеру IBM PC/AT, чтобы записать на кассету как звучит AXELF.S3M. ;)
Timick
да теперь припоминаю - было такое дело.
Javian
там 8 Ом динамик в цепи транзистора. Т.е. ток 60мА - серьезная величина чтобы вешать на выход микросхемы. Безопаснее поставить эмиттерный повторитель на одном транзисторе. На фото возле разъема спикера видны транзисторы и резисторы. Наверняка один из них для бипера.
checkpoint
Что касаемо оригинального ZX Spectrum версии схемы Issue 1, у него buzzer был подключен на ногу ULA через цепочку понижающих диодов, без всякого транзистора. Причем, buzzer это не спикер, это готовый генератор звука (пищалка/бипер) - подача на него лог "1" вызывает формирование тона неприятной частоты. Подозреваю, что "игольчатый" метод формирования звука описанный в статье как раз таки с вязан с этим моментом.
Но к версии ZX Spectrum issue 6 в схеме появился динамик включенный через биполярный транзистор без всяких токоограничивающих резисторов. :) Сколько прошло времени между версиями 1 и 6 сказать затрудняюсь.