Биперные музыкальные движки на ассемблере Z80

от автора

В своих публикациях я уже неоднократно касался темы «биперных движков», то есть музыкальных процедур для 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   ;48-битный счётчик длительности из-за нехватки регистров     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 ООО «МТ ФИНАНС»


ссылка на оригинал статьи https://habr.com/ru/articles/926140/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *