В своих публикациях я уже неоднократно касался темы «биперных движков», то есть музыкальных процедур для ZX Spectrum и других 8-битных компьютеров прошлого, не оснащённых аппаратными синтезаторами звука. Но каждый раз это было вскользь. Настало время сконцентрировать более тугую струю экспертизы в данном вопросе, расставить такты над логическим «И», и рассказать базу: как же устроены и как создаются подобные процедуры в их самом классическом варианте, без привлечения новомодных ардуин.

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

Краткая история бипера

Совсем без истории, конечно, не обойтись, иначе менее искушённые в вопросе читатели просто не поймут, о чём идёт речь.

Компьютер ENIAC, новейшая разработка 1946 года
Компьютер ENIAC, новейшая разработка 1946 года

Биперная, она же «однобитная» музыка родилась на самой заре компьютеризации. И таких зорь в истории случилось даже две.

Первая заря случилась действительно на заре, в первые годы развития компьютерной техники, когда только появились первые быстродействующие электронные ЭВМ размером с машинный зал. Тогда они совершенно не были способны издавать звуки, но это не могло остановить энтузиастов, и уже в 1949 году тёплый ламповый BINAC проскрипел первую компьютерную мелодию. К сожалению, исторические свидетельства о точной дате и технических подробностях этого события затерялись в веках, но начало было положено. И вскоре, в начале 1950-х, компьютеры типа TX-0 и PDP-1 начали всё бодрее и бодрее насвистывать мелодии.

Синтез звука на этих машинах происходил чисто программно, за счёт очень точного планирования времени выполнения кода. Теоретическую базу для этого заложил сам Алан Тьюринг в 1950 году, чему уделил раздел в документации на компьютер Manchester Mark I.

Алан Тьюринг поясняет за бипер
Алан Тьюринг поясняет за бипер

Компьютеры тех лет не оснащались звуковыми устройствами, и создаваемый ими звук снимался самыми разными способами: радиоприёмником, ловящим наводки от работы компьютера, подключением динамика вместо одной из контрольных ламп, или даже подключением четырёх динамиков для реализации более качественного полифонического звучания. Самые первые музыкальные процедуры были одноголосыми, но были и попытки реализации программной полифонии, например, на компьютере IBM 701 в 1961 году.

Фрагмент музыкальной программы для Ferranti Mark I
Фрагмент музыкальной программы для Ferranti Mark I

Эта эпоха слабо освещена в документах и записях, но до наших дней дошли некоторые программы и записи, а также были созданы реконструкции с помощью реплик компьютеров прошлого. Так, существует описание системы, позволившей воспроизвести мелодию «God Save the King» (импортная альтернатива «Боже, царя храни») на компьютере Ferranti Mark I в сентябре 1951 года.

Вторая заря случилась, когда появились первые коммерческие микропроцессоры, и компьютеры сделали шаг из больших залов в дома обычных пользователей. В середине 1970-х и начале 1980-х годов большинство самых первых домашних компьютеров не оснащались никакими аппаратными средствами для воспроизведения звуков — у машин типа Altair 8800 и его аналогов не было даже экрана.

Только с появлением компьютера Sol-20 оформились черты домашней машины в формате клавиатурного блока «всё-в-одном», в том числе оснащённой встроенным видеотерминалом для вывода информации на обычный телевизор. Одни из первых шагов к многоголосому синтезу звука были сделаны на подобных машинах с помощью комплекса Music System (Software Music Synthesis System), который реализовал программный синтез трёхголосой музыки на различных машинах тех лет, оснащённых микропроцессором Intel 8080.

Ранние ПК, включая первые модели Commodore PET или Sinclair ZX80 и ZX81, не имели даже встроенного динамика, и редкие энтузиасты подключали его снаружи, к портам ввода-вывода. Большой шаг в направлении активного развития звуковых возможностей был сделан добавлением штатного встроенного динамика в популярных домашних компьютерах Apple II и ZX Spectrum. Но аппаратный синтез звука на этих машинах не был предусмотрен, и на них тоже пригодились техники программного синтеза звука с выводом на однобитный динамик.

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

Ну а далее мы выясним, как именно это было осуществлено, на примере самого популярного в наших краях ретро-компьютера ZX Spectrum 48K и используемого в нём микропроцессора Z80.

База

Чтобы программно сформировать простейший одноголосый звук, так называемый «меандр» или «квадратную волну» (square wave) на однобитном выходе, нужно менять его состояние с 0 на 1 и обратно, с 1 на 0, через определённые промежутки времени. Чем короче промежутки времени, тем выше частота генерируемого звука (писк), чем длиннее — тем ниже (бас).

В псевдокоде это можно выразить следующим образом:

цикл:
   установить на выходе 1
   подождать N / 2 времени
   установить на выходе 0
   подождать N / 2 времени

Где N для желаемой частоты F можно выразить в секундах: 1 / F. Деление этого значения на два в псевдокоде используется, потому что за один период тона нужно поменять состояние выхода дважды, на 1 и на 0.

«Квадратная» волна и прочие меандры
«Квадратная» волна и прочие меандры

Для квадратной волны, которая на слух звучит как очень чистый, резкий тон, промежутки времени между обеими переключениями выходного бита должны быть равными. Но для генерации «какого-нибудь» звука это не является обязательным условием — неравенство промежутков просто поменяет характер звучания, тембр, но не повлияет на высоту звука. Изменение этого неравенства может использоваться намеренно, для внесения разнообразия в звучание.

В псевдокоде это будет выглядеть следующим образом:

цикл:

   установить на выходе 1
   подождать N времени
   установить на выходе 0
   подождать M времени

   перейти на цикл

Здесь сумма задержек N + M должна быть равна 1 / F секунд.

Считаем такты

Так как у домашних компьютеров начала 1980-х годов нет никаких таймеров, которые могли бы помочь точно отсчитывать столь короткие интервалы времени, нужные тайминги обеспечиваются просто самим временем выполнения кода. Это время необходимо очень точно рассчитать и спланировать вручную.

Время выполнения кода можно посчитать, зная устройство используемого микропроцессора и его тактовую частоту — те самые мегагерцы, подаваемые на его тактовый вход. Каждая операция, выполняемая процессором, занимает определённое количество «тактов», или единичных периодов тактовой частоты. 

Фрагмент таблички тактов Z80 и не только
Фрагмент таблички тактов Z80 и не только

Количество тактов, затрачиваемых на каждую операцию, у микропроцессоров родом из конца 1970-х годов, различается между разными операциями, но одинаково и неизменно в каждом случае использования операции. Так, процессор Zilog Z80 затрачивает на «пустую» операцию NOP четыре такта. Если он работает на частоте 3.5 МГц, то есть 3500000 герц, иначе говоря, тактов в секунду, значит, за одну секунду он выполнит 3500000 / 4 = 875000 операций NOP.

В документации на микропроцессоры всегда есть табличка тактов, «растактовка», где указано затрачиваемое на каждую операцию количество тактов. Иногда одна операция может выполняться разное время, разное количество тактов, в зависимости от входных условий. Обычно это касается операций ветвления, условных переходов — если переход выполняется, то одно время, если нет, то другое.

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

Фрагмент кода с ручным подсчётом тактов
Фрагмент кода с ручным подсчётом тактов

Многие компьютеры, и ZX Spectrum в том числе, используют маскируемые прерывания с некоторой частотой, обычно совпадающей с частотой кадров телевизора, для обработки системных нужд: опроса клавиатуры, счёта времени. На время работы звуковых процедур все прерывания следует запретить, чтобы они не вносили нежелательные задержки — в звуке это проявляется как хрип, треск или гудение с частотой 50 герц.

Порт бипера

Чтобы написать хоть какой-нибудь биперный движок, нужно знать не только используемый в отдельно взятом компьютере микропроцессор, но и особенности устройства самого компьютера, в котором этот процессор установлен.

Мы будем говорить о ZX Spectrum 48K, так как однобитная музыка наиболее популярна на этой платформе. Но аналогичные принципы и моменты есть и на других компьютерах на базе процессора Zilog Z80, и энтузиасты создают биперные музыкальные движки и для них тоже. И первое, что нужно знать — это, конечно, то, как обращаться к порту вывода, к которому подключён динамик.

В случае с процессором Z80 в разных компьютерах это может быть или специальная ячейка памяти, в которую можно записывать данные обычными командами записи памяти, или порт в отдельном пространстве ввода-вывода. В подобный порт данные выводятся специальными командами. На Z80 чаще всего используется команда OUT (NN),A, хотя предусмотрено и несколько других, в том числе для блочного вывода.

На ZX Spectrum бипер доступен по второму варианту, через порт ввода-вывода с адресом #FE (а на самом деле любой чётный адрес), в котором один из битов, D4, отвечает за выходной уровень напряжения на встроенном в компьютер динамике.

Нюансы одного бита

Хотя всё кажется простым и очевидным — есть один бит порта, в него выводится 0 или 1 — практически каждый компьютер таит собственные подводные камни. В случае с ZX Spectrum нужно учитывать сразу несколько тонких моментов.

Краткое описание порта #FE
Краткое описание порта #FE

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

Выводить звук можно как через собственно бит бипера, D4 (маска #10), так и через бит магнитофона, D3 (маска #08). При этом звук будет слышен во внутреннем динамике в любом случае, так как «чипсет» компьютера, так называемая ULA, физически имеет один общий вывод для бипера и вывода на магнитофон. Некоторые движки намеренно выводят звук сразу в оба бита.

Фрагмент схемы ZX Spectrum, подключение бипера через усилитель к выводу 28 ULA
Фрагмент схемы ZX Spectrum, подключение бипера через усилитель к выводу 28 ULA

Между двумя битами, однако, есть разница в уровне напряжения: на классических 48K и 128K моделях бит бипера даёт чуть более высокий уровень, чем бит магнитофона. В теории эта особенность устройства ULA формирует двухбитный ЦАП, но с очень нелинейной характеристикой. На классических моделях разница в уровнях напряжения настолько незначительна, что пока никто не смог придумать практического применения для этой особенности. Но на ZX Spectrum +3 разница гораздо более значительна, и это создаёт некоторые проблемы несовместимости.

Дело в том, что быстродействие Z80 в ZX Spectrum сильно ограничено, и программисты вынуждены экономить каждый такт. В большинстве биперных движков 1980-х годов перед выводом бита в порт присутствует маскировка значения, чтобы изменять только бит бипера D4 и сохранять неизменными прочие биты, в частности, цвет бордюра.

Уровни напряжений на выходе ULA для бипера и магнитофона
Уровни напряжений на выходе ULA для бипера и магнитофона

Разработчики прошлых лет ещё не пытались выжать из имеющихся ресурсов невозможное, и у них оставалось несколько тактов для подобных операций. Однако, когда тактов на сложный алгоритм уже не хватает, зачастую приходится жертвовать цветом бордюра и просто выводить байт в порт #FE как есть, что часто используется в современных, самых продвинутых движках.

Это даёт два побочных эффекта: цвета бордюра очень часто меняются, что формирует разноцветные полоски на экране, а также меняется и уровень громкости бипера. И если на классических моделях эти изменения пренебрежимо малы, на ZX Spectrum +3 непредсказуемо изменяющийся бит D3 начинает сам работать как второй звуковой выход, что приводит к нежелательным призвукам либо каше в звуке. Эту особенность нужно учитывать для обеспечения наиболее полной совместимости.

Торможение

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

Дело в том, что быстродействие процессора не всегда равно его тактовой частоте. Оно дополнительно ограничено быстродействием памяти и внешних устройств. Процессору приходится разделять время доступа к ОЗУ с видеосистемой, которая формирует растр на экране. И так как телевизор в силу своего устройства подождать не может, иногда ждать приходится процессору. В ZX Spectrum это реализовано кратковременным замедлением тактовой частоты — она берётся не напрямую с тактового генератора, а с выхода ULA, которая таким образом притормаживает процессор.

Притормаживание процессора вносит заметный шум в звук, так как теперь точно рассчитанные задержки сбиваются. И хотя это торможение имеет определённый паттерн, который теоретически можно учесть, он весьма хитроумен, и рассчитать правильное быстродействие было бы очень затруднительно. К тому же, схема торможения на разных 128K моделях отличается.

Кое-что про торможение процессора в медленной памяти
Кое-что про торможение процессора в медленной памяти

На ZX Spectrum 16K весь объём ОЗУ подвержен торможению, поэтому музыкальные движки ограничиваются процедурой BEEP в ПЗУ, которое не тормозится. А аппаратно идентичный ZX Spectrum 48K, отличающийся лишь объёмом ОЗУ, открыл возможности для более продвинутого биперного звука за счёт того, что дополнительные 32 килобайта ОЗУ не подвержены торможению. Таким образом, код биперных движков, по крайней мере, их цикл синтеза звука, необходимо располагать в ОЗУ с адреса #8000 и выше. Не очень критичные по времени вещи, такие, как данные мелодии и парсер этих данных, вполне можно разместить и в нижней части памяти, с торможением.

Другой момент — торможение портов ввода-вывода. Логику этого торможения также непросто изложить на пальцах, и я не буду углубляться во множество нюансов этого процесса, но следует учитывать, что оно существует. Для избежания проблем с ухудшением качества звука из-за переменной скорости выполнения команды OUT нужно размещать код, выполняющий такую команду, с адреса #8000 или выше, а также делать время выполнения основного цикла синтеза звука кратным 8 тактам, чтобы момент фактического обращения к порту происходил всегда в одном и том же такте в пределах 8-тактового отрезка.

Простейший тон

Наконец, ознакомившись с целым ворохом нюансов, переходим к кодным процедурам. С этого момента дело пойдёт значительно легче — просто пишем код!

Чтобы получить звук некоторой высоты, достаточно следующего простейшего кода:

   ld a,0			;устанавливаем выходной бит в 0
loop:
   out (#fe),a		;выводим в порт
   nop				;задержка
   xor #10			;инвертируем выходной бит
   jp loop			;переходим на цикл

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

   ld a,0
loop:
   out (#fe),a		;11
   nop				;4*N, где N сколько-то раз
   xor #10			;7
   jp loop			;10

Такой цикл с единственным NOP будет длиться 11+4+7+10 = 32 такта. В секунде у нас 3500000 тактов, делим на 32, и ещё на два, так как нужно два изменения бита для одного периода тона. Получаем частоту звука 54687 герц, далеко за порогом слышимости.

Теперь посчитаем, как мы можем получить желаемую частоту в звуковом диапазоне. Например, классические 440 герц. Делим 3500000 на 440 и ещё на два и получаем количество тактов в одной итерации цикла: 3977 тактов. Из этого числа нужно вычесть 11+7+10 — неизменную часть цикла. Получаем 3949 тактов. Мы можем получить близкую к требуемой частоту, вставив операцию NOP аж 987 раз — одна операция занимает 4 такта.

Пересчитаем фактическую частоту: цикл у нас теперь длится 11+7+10+987*4 = 3976 тактов, а частота равна 3500000 / 3976 / 2 = 440.14 герц. И мы даже выполнили требование по выравниванию времени цикла в тактах на кратное 8, так как 3976 делится на 8 без остатка.

Тон заданной частоты

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

   ld a,0
loop:
   out (#fe),a	;11
   ld b,N	;7
delay:
   dec b	;4
   jp nz,delay	;10
   xor #10	;7
   jp loop	;10

Посчитать количество итераций цикла N для частоты F можно следующим образом:

N = ( ( 3500000 / F / 2 ) - ( 11+7+7+10 ) / ( 4+10 )

Где последние 4+10 в правых скобках — это время одной итерации цикла задержки, а 11+7+7+10 — тело основного цикла помимо вложенного цикла задержки.

Однако, если попытаться сделать такой подсчёт для частоты 440 герц, получится значение N = 281, что больше разрядности 8-битного регистра. Можно решить эту проблему разными способами: либо использовать 16-битный счётчик задержки, либо немного увеличить время цикла задержки вставкой в него NOP. Например, так:

   ld a,0
loop:
   out (#fe),a	;11
   ld b,N	;7
delay:
   dec b	;4
   nop		;4
   jp nz,delay	;10
   xor #10	;7
   jp loop	;10

Теперь формула расчёта N для частоты F меняется:

N = ( ( 3500000 / F / 2 ) - ( 11+7+7+10 ) / ( 4+4+10 )

И для тона 440 герц значение N получится равным 219, что уже укладывается в разрядность 8-битного регистра.

Два тона одновременно

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

Есть несколько принципиально разных способов создания полифонии, и некоторые из них весьма нетривиальны и сложны в понимании. Поэтому для демонстрационных целей мы рассмотрим самый простой подход, реализованный в классическом двухголосом биперном движке из программы The Music Box, который с 1985 года был использован в десятках коммерческих игр.

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

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

   счётчик1 = F1
   счётчик2 = F2
   выход1 = 0
   выход2 = 0

цикл:

   уменьшить счётчик1

   если счётчик1 == 0:
      счётчик1 = F1
      инвертировать выход1
   вывести выход1 в порт бипера

   уменьшить счётчик2

   если счётчик2 == 0:
      счётчик2 = F2
      инвертировать выход2
   вывести выход2 в порт бипера

   перейти на цикл

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

Выглядеть код, выравнивающий время выполнения веток, может следующим образом:

   ld a,0			;выходной бит первого канала
   exa
   ld a,0			;выходной бит второго канала (в альтернативном регистре A)
   exa

   ld h,F1		    ;делитель частоты первого канала
   ld l,F2		    ;делитель частоты второго канала

   ld d,h			;счётчик частоты первого канала, сразу загружаем туда делитель
   ld e,l			;счётчик частоты второго канала

sound_loop:

   ;логика первого канала

   dec e		    ;4	уменьшаем счётчик
   jp nz,timing1   	;10	если не 0, переходим на выравнивающую задержку
   ld e,l   		;4	перезагружаем счётчик заново
   xor #10   		;7	изменяем выходной бит
   jp output1   	;10	переходим дальше

timing1:

   nop   		    ;4	выравнивающая задержка
   or 0   		    ;7	точно такое же количество тактов, как в ветке
   jp output1   	;10	перезагрузки счётчика

output1:

   out (#fe),a   	;11	выводим выходной бит

   exa   			;4	меняем местами основной и альтернативный регистр A

   ;логика второго канала

   dec d   		    ;4	уменьшаем счётчик
   jp nz,timing2   	;10	если не 0, переходим на выравнивающую задержку
   ld d,h   		;4	перезагружаем счётчик заново
   xor #10   		;7	изменяем выходной бит
   jp output2   	;10	переходим дальше

timing2:

   nop   		    ;4	выравнивающая задержка
   or 0   		    ;7
   jp output2   	;10

output2:

   out (#fe),a   	;11	выводим выходной бит

   exa   			;4	меняем местами основной и альтернативный регистр A

   jp sound_loop   	;11	переходим на цикл

Способ расчёта значений F1 и F2 для получения нужной частоты теперь отличается. Для начала нужно выяснить общее время одной итерации цикла. Просто складываем такты по любой из веток от начала до конца цикла и получаем число:

первый канал 4+10+4+7+10+11+4 = 50 тактов +
второй канал 4+10+4+7+10+11+4 = 50 тактов +
переход на цикл 10 тактов

итого время итерации 50+50+10 = 110 тактов

Число 110 не делится на 8 без остатка, поэтому условие по выравниванию времени цикла на 8 тактов пока не выполнено, но в демонстрационных целях мы оставим это как есть — это будет легко исправить впоследствии внесением дополнительной задержки.

Теперь делим тактовую частоту процессора на длительность цикла и получаем «частоту дискретизации», на которой он работает:

3500000 / 110 = 31818 герц

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

Зная частоту дискретизации, теперь достаточно поделить её на желаемую частоту, и получить значение для счётчика-делителя. Например:

31818 / 440 / 2 = 36

Здесь важно обратить внимание, что восьмибитный счётчик-делитель даёт очень низкую точность и ограниченный диапазон. С его помощью невозможно получить любую желаемую частоту, а только с некоторым приближением. Такой проблемой страдает половина музыкальных движков на ZX Spectrum — они воспроизводят ноты лишь очень приблизительно, с сильным нестроем. Это придаёт звучанию местной музыки особый характер.

Существуют и другие способы, более сложные и ресурсоёмкие, но позволяющие получить более точное управление частотой. Например, 16-битные счётчики-аккумуляторы. Я рассказывал о них более подробно в статье про мой биперный движок QChan.

Шумовой эффект

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

Самый простой способ сформировать условно-белый шум на ZX Spectrum — вывести в порт бипера псевдослучайную последовательность бит. А на неё как раз очень кстати смахивает содержимое ПЗУ объёмом 16 килобайт. Конечно, характер шума будет зависеть от данных в ПЗУ, но к счастью, большинство моделей ZX Spectrum и его клонов в режиме 48K используют одинаковое ПЗУ с интерпретатором Бейсика, где неизменность содержимого жизненно необходима для обеспечения совместимости с играми и программами.

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

   ld bc,1000		;количество байт, оно же длительность пшика и указатель в ПЗУ
loop:
   ld a,(bc)		;чтение байта
   and #10		    ;изоляция только нужного для бипера бита
   out (#fe),a		;вывод в порт бипера
   dec bc		    ;декремент 16-разрядного счётчика байт
   ld a,b			;стандартный алгоритм проверки регистровой
   or c			    ;пары на 0, логическое или между старшим и младшим байтами
   jp nz,loop		;переход на цикл

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

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

Музыкальный движок

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

Так как многопоточность на ZX Spectrum толком не завезли, а разрешённые кадровые прерывания внесут нежелательный треск в звук, решается проблема совмещения парсера и синтезатора звука просто: работает или одно, или другое. Некоторое продолжительное время, например, секунду, синтезатор играет звук с ранее установленными параметрами. Потом краткое время, долю секунды, работает парсер, устанавливая новые параметры, и процесс повторяется по кругу.

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

Важный вопрос, который должен решить парсер — это длительность нот. Конечно, вы можете вспомнить, что существуют традиционные музыкальные длительности: целая нота, половинная, четвёртая и так далее. Это легко реализовать, если у нас всего один канал звука, но для двухканального звука проще принять следующее допущение: все ноты всегда имеют длительность в одну шестнадцатую, а более длинные ноты получаются просто многократным повторением шестнадцатых нот. Такой подход применяется в очень многих биперных движках, в том числе в упомянутом ранее The Music Box.

Создание парсера проще начинать с конца: с музыкального формата. В моём примере будет реализован максимально простой формат:

  • Если считанный байт равен 254, это вызывает срабатывание шумового эффекта и чтение следующих байт.

  • Если считанный байт равен 255, следующие два байта являются абсолютным указателем на точку зацикливания.

  • Иначе это просто по два байта с делителями на каждую позицию (шестнадцатую ноту). При этом 0 в байте означает паузу вместо ноты.

Общий темп композиции задан в коде и не изменяется во время проигрывания мелодии. Выход из проигрывания не предусмотрен.

Исходя из определённого формата, написать парсер и оформить законченный движок становится делом техники. Приведу сразу полный его код с комментариями:

music_routine:

    di			    ;прерывания запрещены
    
    ld ix,music_data	;регистровая пара IX указывает на музыкальные данные
    
    ld hl,0   		;в HL всегда значения делителей, обнуляем на старте
    ld de,0   		;в DE всегда счётчики-делители, обнуляем на старте
    
    ld a,0   		;обнуляем выходные биты каналов
    exa
    ld a,0
    
    ld iy,0   		;обнуляем маски, используются для заглушения каналов
    
parse_row:

    push af		    ;сохраняем регистр A на время работы парсера
    
    ld a,(ix)		;читаем первый байт музыкальных данных
    inc ix		    ;и продвигаем указатель
    
    cp 255		    ;проверяем на маркер цикла
    jp nz,no_loop	;это не маркер цикла, пропускаем следующий код

    pop af		    ;восстанавливаем A
    ld c,(ix+0)		;читаем следующую пару байт в регистровую пару BC
    ld b,(ix+1)
    push bc		    ;переносим значение из регистровой пары BC
    pop ix		    ;в регистр IX
    jp parse_row	;снова читаем байт данных
    
no_loop:

    cp 254		    ;проверяем на маркер шумового эффекта
    jp nz,parse_notes	;это не шумовой эффект, пропускаем следующий код
    
play_noise:

    ld bc,1000		;количество байт, оно же длительность пшика и указатель в ПЗУ
noise_loop:
    ld a,(bc)		;чтение байта
    and #10		    ;изоляция только нужного для бипера бита
    out (#fe),a		;вывод в порт бипера
    dec bc		    ;декремент 16-разрядного счётчика байт
    ld a,b			;стандартный алгоритм проверки регистровой
    or c			;пары на 0, логическое или между старшим и младшим байтами
    jp nz,noise_loop;переход на цикл
    
    jp parse_row	;переходим к чтению следующего байта
    
parse_notes:

parse_ch_1:
			        ;теперь в A байт, являющийся делителем для первого канала тона
    or a			;проверяем на 0	
    jp z,mute_ch_1	;если 0, заглушаем звук канала
    
    ld e,a		    ;загружаем делитель в счётчик
    ld l,a			;и значение для перезагрузки счётчика
    ld iyl,#10		;разрешаем звук канала
    jp parse_ch_2	;переходим ко второму каналу
    
mute_ch_1:

    ld iyl,0		;запрещаем звук канала
    
parse_ch_2:

    ld a,(ix)		;читаем второй делитель
    inc ix		    ;продвигаем указатель
    
    or a 			;проверяем на 0
    jp z,mute_ch_2	;если 0, заглушаем звук канала
    
    ld d,a		    ;загружаем делитель в счётчик
    ld h,a		    ;и значение для перезагрузки счётчика
    ld iyh,#10		;разрешаем звук канала
    jp play_row		;переходим к проигрыванию строки музыкального текста
    
mute_ch_2:

    ld iyh,0		;запрещаем звук канала

play_row:

    pop af		    ;восстанавливаем регистр A
    
    ld bc,10   		;темп композиции в "строках" в регистре C, длина строки 256 итераций
    
tone_loop:		    ;цикл синтеза двух каналов тона, работает как описано ранее

    dec e   		;4
    jp nz,timing1   ;10
    ld e,l   		;4
    xor iyl   		;8
    jp output1   	;10
    
timing1:

    nop   		    ;4
    nop   		    ;4
    nop   		    ;4
    jp output1   	;10
    
output1:

    out (#fe),a   	;11
    
    exa   		    ;4
    
    dec d   		;4
    jp nz,timing2   ;10
    ld d,h   		;4
    xor iyh   		;8
    jp output2   	;10
    
timing2:

    nop   		    ;4
    nop   		    ;4
    nop   		    ;4
    jp output2   	;10
    
output2:

    out (#fe),a   	;11
    
    exa   		    ;4    
    
    nop   		    ;4	дополнительная задержка для выравнивания на 8 тактов
    dec b   		;4	8-битный счётчик длительности из-за нехватки регистров
    jp nz,tone_loop ;10=120t
    
    dec c   		;4	второй 8-битный счётчик длительности
    jr nz,tone_loop ;12
    
    jp parse_row

Музыка

Процедура готова. Но как создавать мелодию для неё? Есть разные способы решения этой проблемы.

Есть более сложные, но предоставляющие больше удобства подходы — это написание специального музыкального редактора под процедуру, либо плагина для универсального редактора типа моего 1tracker, о котором я рассказывал на Хабре, либо хотя бы простейшего конвертора из одного из популярных форматов, например, для XM-модулей. Но это довольно трудоёмкие, объёмные задачи, описание которых не уложится в рамки статьи.

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

sample_rate=(3500000.0/120.0)

note_frequency=[2093.0,2217.4,2349.2,2489.0,2637.0,2793.8,2960.0,3136.0,3322.4,3520.0,3729.2,3951.0]

note_names=["C_","Ch","D_","Dh","E_","F_","Fh","G_","Gh","A_","Ah","B_"];
    
print('EOF\t\tequ 255')
print('DRUM\tequ 254')

note_min=1*12+9
note_max=6*12

for notes in range(note_min,note_max+1):

    note=int(notes%12)
    octave=int(notes/12)
    div=float(32>>octave)

    if div<1:
   	 step=0
    else:
   	 step=sample_rate*2.0/(note_frequency[note]/div)
    
    if step>=253:
   	 step=253
   	 
    print('%s%i\t\tequ %i' % (note_names[note], octave, int(step)))

print('R__\t\tequ 0')

Этот скрипт можно настроить и на другие движки с 8-битными делителями, отредактировав первую строчку, в которой указана тактовая частота процессора и длительность цикла генерации звука.

Скрипт создаст такую табличку удобных, человекочитаемых определений с названиями нот:

EOF   	 equ 255
DRUM     equ 254
A_1   	 equ 253
Ah1   	 equ 250
B_1   	 equ 236
C_2   	 equ 222
Ch2   	 equ 210
D_2   	 equ 198
Dh2   	 equ 187
E_2   	 equ 176
F_2   	 equ 167
Fh2   	 equ 157
G_2   	 equ 148
Gh2   	 equ 140
A_2   	 equ 132
Ah2   	 equ 125
B_2   	 equ 118
C_3   	 equ 111
Ch3   	 equ 105
D_3   	 equ 99
Dh3   	 equ 93
E_3   	 equ 88
F_3   	 equ 83
Fh3   	 equ 78
G_3   	 equ 74
Gh3   	 equ 70
A_3   	 equ 66
Ah3   	 equ 62
B_3   	 equ 59
C_4   	 equ 55
Ch4   	 equ 52
D_4   	 equ 49
Dh4   	 equ 46
E_4   	 equ 44
F_4   	 equ 41
Fh4   	 equ 39
G_4   	 equ 37
Gh4   	 equ 35
A_4   	 equ 33
Ah4   	 equ 31
B_4   	 equ 29
C_5   	 equ 27
Ch5   	 equ 26
D_5   	 equ 24
Dh5   	 equ 23
E_5   	 equ 22
F_5   	 equ 20
Fh5   	 equ 19
G_5   	 equ 18
Gh5   	 equ 17
A_5   	 equ 16
Ah5   	 equ 15
B_5   	 equ 14
C_6   	 equ 0
R__   	 equ 0

Теперь с их помощью можно легко записать в ассемблерном исходнике простую мелодию, почти как в трекере:

music_data:

    db A_2,C_4
    db R__,R__
    db A_2,R__
    db R__,R__
    db DRUM
    db A_2,C_4
    db R__,R__
    db A_2,R__
    db R__,R__
    db A_2,C_4
    db R__,R__
    db A_2,R__
    db R__,R__
    db DRUM
    db A_2,C_4
    db R__,R__
    db A_2,B_3
    db R__,R__
    
    db G_2,R__
    db R__,R__
    db G_2,R__
    db R__,R__
    db DRUM
    db G_2,C_4
    db R__,R__
    db G_2,R__
    db R__,R__
    db G_2,C_4
    db R__,R__
    db G_2,R__
    db R__,R__
    db DRUM
    db G_2,C_4
    db R__,R__
    db G_2,B_3
    db R__,R__
    
    db F_2,R__
    db R__,R__
    db F_2,R__
    db R__,R__
    db DRUM
    db F_2,C_4
    db R__,R__
    db F_2,R__
    db R__,R__
    db F_2,C_4
    db R__,R__
    db F_2,R__
    db R__,R__
    db DRUM
    db F_2,C_4
    db R__,R__
    db F_2,B_3
    db R__,R__
    
    db D_2,R__
    db R__,R__
    db D_2,A_3
    db R__,R__
    db DRUM
    db D_2,R__
    db R__,R__
    db D_2,C_4
    db R__,R__
    db D_2,R__
    db R__,R__
    db D_2,D_4
    db R__,R__
    db DRUM
    db D_2,R__
    db R__,R__
    db DRUM
    db D_2,R__
    db R__,R__
    
    db EOF

А звучит она вот так:

Заключение

И вот, преодолев тридцать с лишним тысяч знаков текста, в итоге мы таки получили простейший, но вполне работоспособный музыкальный движок, написанный на ассемблере Z80. Однако, это лишь вершина айсберга, самая основа. Тема биперной музыки бурно развивалась энтузиастами в 1980-х и в недавнее десятилетие, и скрывает ещё множество интересных трюков, творческих находок и более сложных идей. А значит, повод для возможного продолжения всегда найдётся.

© 2025 ООО «МТ ФИНАНС»

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