Хабрахабр

Разработка простого музыкального синтезатора на ATMEGA8

Несколько лет назад я изготовил на микроконтроллере ATmega8 часы с будильником, где реализовал однотональный (одноголосный) простейший синтезатор мелодий. В Интернете немало статей для начинающих, посвящённых этой теме. Как правило, для генерации частоты (нот) применяют 16-разрядный таймер, который конфигурируется определённым образом, заставляя на аппаратном уровне выдавать сигнал в форме меандра на определённом выводе МК. Второй (8-разрядный) таймер применяется для реализации длительности ноты или паузы. Ноты по известным формулам сопоставляются с частотами, а они, в свою очередь, сопоставляются с определёнными 16-битными числами, обратно пропорциональные частотам, которые задают периоды счёта таймера.
В своей конструкции я предусмотрел три мелодии, которые были написаны в одной тональности и гамме. Тем самым, мне пришлось использовать ограниченное и определённое количество нот, что облегчало моделирование. Кроме того, все три мелодии проигрывались с одним темпом. Код ноты и код её длительности легко помещались в один байт. Единственным недостатком такой модели служило отсутствие универсальности, возможности быстрого редактирования, замены или дополнения мелодии. Для того чтобы записать мелодию, я для начала набросал её в нотном редакторе на компьютере, затем переписывал ноты и их длительности, с нумерацией которых я заранее определился, а потом формировал результирующие байты. Последние операции делал с помощью программы Excel.

Была такая задумка, чтобы программа МК читала байты одного из известных нотных форматов. В дальнейшем мне захотелось устранить вышесказанный недостаток, предав конструкции некую универсальность и сократив время на реализацию мелодии. Грамотнее говоря, это не столько формат, сколько целая «наука», о которой можно почитать на просторах Интернета. Самый популярный и распространённый – формат MIDI. Формат midi является музыкально-ориентированным, поэтому находит применение в соответствующей сфере. Спецификация MIDI определяет протокол передачи сообщений реального времени по соответствующему физическому интерфейсу и описывает, как устроены файлы midi, в которых могут храниться эти сообщения. В бытовой сфере формат midi встречался в эпоху начала развития мобильных телефонов. Это синхронное управление звуковой аппаратурой, цветомузыкой, музыкальными синтезаторами и роботами и т.д. В мобильном телефоне, воспроизводящий такой файл, содержится синтезатор, который интерпретирует миди сообщения в этом файле в реальном времени и воспроизводит мелодию. В этом случае в миди файл записываются сообщения о включении или отключении той или иной ноты, информация о музыкальном инструменте, громкости звучания нот и прочее. Со временем появилась так называемая полифония. На самых ранних этапах телефоны были способны воспроизводить только однотональные мелодии.

В таком случае, как минимум, применяется заранее сформированная «волновая таблица» (перечень форм звуковой волны) под каждый музыкальный инструмент, хранящаяся в памяти МК. В Интернете я встречал статьи про реализацию полифонического синтезатора на МК, который читает миди файлы. А в моём конкретном случае речь пойдёт о реализации более простой модели: однотональный (одноголосный) синтезатор.

Поэтому было решено написать простую программу для преобразования миди файла в свой формат. Для начала я тщательно изучил устройство файла миди, придя к выводу, что в нём, кроме нужной информации о нотах, содержится дополнительная избыточная информация. Заранее я определился с организацией хранения множества мелодий в памяти ПЗУ (EEPROM 24XX512). Программа, работая с множеством миди файлов, не только преобразует форматы, но и упорядочивает их определённым образом. В отличие от SD карты (к примеру), понятие сектора к используемому ПЗУ неприменимо, поэтому я выражаюсь условно. Для удобства визуализации в HEX редакторе я сделал так, чтобы каждая мелодия начиналась с начала сектора. А первый сектор ПЗУ отведён под адреса секторов начал каждой мелодии. Размер сектора составляет 512 байт. Предполагается, что мелодия может занять несколько секторов.

Я затрону только самые необходимые и нужные моменты. Полное описание формата миди файла, разумеется, здесь производить не стоит. В нашем случае никакого значения не имеет, что это за инструмент, и необходим только один канал. Миди файл содержит 16 каналов, которым, как правило, чаще всего, соответствует тот или иной музыкальный инструмент. Про последнее я писал ранее в одной из своих статей. Содержимое каждого канала, совместно с заголовком, оформляется в миди файл по принципу, который очень похож на организацию хранения видео и аудио потоков в контейнере AVI. Один из таких параметров – разрешающая способность во времени. Заголовок миди файла представляет собой набор некоторых параметров. Четверть – это временной отрезок, в течение которого звучит четвертная нота. Она выражается в количестве «тиков» (своего рода пиксель) на четверть (PPQN). Следовательно, длительность одного «пикселя» (период дискретизации) зависит от темпа и PPQN. В зависимости от темпа мелодии, длительность четверти может быть различной. Вся информация о времени того или иного события определяется с точностью до этой длительности.

Не вдаваясь в подробности, будем работать с типом 1, числом каналов 2. Кроме того, в заголовке записан тип миди файла (тип 0 или тип 1) и число каналов. Но в файле миди «типа 1» присутствует, кроме основного, ещё один «немузыкальный» канал, в котором записана дополнительная информация, не содержащая нот. Миди файл с однотональной мелодией, по логике, содержит один канал. Здесь также не стоит вдаваться в подробности. Это так называемые метаданные. В дальнейшем будет показано, как воспользоваться данной информацией, совместно с PPQN, для конфигурации таймера МК, отвечающего за темп. Единственная необходимая нам информация, которая там лежит, это информация о темпе, причём в необычном формате: микросекунды на четверть.

У события включения ноты имеется два параметра: номер и громкость ноты. В блоке основного канала с нотами нас интересует только информация о событиях включения и отключения нот. Нас интересует только первый параметр, ибо не важно, какая громкость у ноты: все ноты при воспроизведении мелодии МК будут звучать с одинаковой громкостью. Всего предусмотрено 128 нот и 128 уровней громкости. Код события взятия (включения) ноты – 0x90. И, конечно же, в мелодии не должно быть нот «с наложением», то есть, в любой момент времени не должно звучать более одной ноты одновременно. Однако, по крайней мере, редактор «Cakewalk Pro Audio 9» при экспорте композиции в миди формат не использует событие с кодом 0x80. Код события выключения ноты – 0x80. То есть, событие «отключить ноту» эквивалентно событию «включить ноту с нулевой громкостью». Вместо этого действует событие 0x90 на протяжении всей нотной партии, а признаком отключения ноты служит её нулевая громкость. Согласно спецификации, код события можно повторно не писать, если данное событие повторяется. Возможно, это сделано из соображения экономии. Это целочисленные значения количества «тиков», о которых говорилось выше. Между событиями записывается информация о временном промежутке в формате переменной длины. Если два события следуют одно за другим, то между ними временной промежуток, очевидно, равен нулю. Чаще всего для записи промежутка времени хватает одного байта. Это, к примеру, отключение первой и включение следующей за ней второй ноты, если между ними отсутствует пауза (пробел).

Существует множество редакторов, но я остановился на первом попавшимся. Попробуем с помощью программы «Cakewalk Pro Audio 9» написать последовательность нот.

В данном редакторе можно задать разрешающую способность во времени (PPQN). Для начала нужно настроить параметры проекта. Слишком большое значение выбирать бессмысленно, так как придётся работать с большими числами, превосходящими по размеру 1 байт. Я выбираю минимальное значение, равное 48. В практически каждой мелодии не встречаются ноты короче, чем 1/32. А вот минимальное значение 48 вполне устраивает. То есть, имеется теоретическая возможность нацело поделить 1/32 ноту на 2, и даже на 3. А если количество «тиков» на четверть составляет 48, то нота или пауза 1/32 будет иметь продолжительность в 48/(32/4)=6 «тиков». Остальные параметры в окне свойства проекта оставляем по умолчанию.

На свой вкус выбираем патч, который соответствует музыкальному инструменту при воспроизведении мелодии в редакторе. Далее открываем свойство первого трека и присваиваем ему номер канала, равный 1. На конечный результат номер патча, разумеется, не будет сказываться.

По умолчанию значение темпа составляет 100 bpm. Темп мелодии задаётся в количестве четвертей в минуту на панели инструментов редактора.

Было решено, что интервал времени между соседними срабатываниями (прерываниями) такого таймера будет соответствовать интервалу одного «тика». В микроконтроллере имеется 8-разрядный таймер, который, как уже говорилось, будет использоваться для регулирования длительности звучащих нот и пауз. Я решил использовать прерывания таймера по переполнению. В зависимости от темпа мелодии значение данного интервала времени будет разное. Теперь перейдём к расчётам. А в зависимости от параметра начальной инициализации таймера есть возможность регулировать этот самый интервал времени, который зависит от темпа мелодии.

Уже было сказано, что темп в миди файле задаётся микросекундами на четверть. Как правило, на практике, в среднем, темп композиций лежит в диапазоне порядка от 50 до 200. Так как, согласно проекту, в четверти содержится 48 тиков, то длина тика для минимально темпа составит 1200000/48=25000 мкс. Для темпа 50 это значение составляет 60000000/50=1200000, а для темпа 250 это составит 240000. Для МК с частотой кварца 8 мГц и максимальным предварительным делителем таймера, равным 1024, получаем следующее. А для максимального темпа, если посчитать аналогично, – 5000 мкс. Результат округлён до ближайшего целого значения, погрешность округления практически не отражается на результате. Для минимального темпа таймеру нужно посчитать 25000/(1024/8)=195 раза. Здесь погрешность округления не сказывается тем более, так как округлённое значение 39 получается и для соседних значений темпов от 248 до 253. Для максимального темпа – 5000/(1024/8)=39. Минимальный темп, при котором будет обеспечена работа с таймером в текущей конфигурации МК, составляет 39 bpm. Соответственно, таймер нужно инициализировать инверсным значением: для минимального темпа – (256-195)=61, а для максимального – (256-39)=217. А при значении 38 – уже 257, что выходит за пределы разрядности таймера. При этом значении таймеру необходимо считать 250 раз. Я решил взять в расчётах за минимальный темп значение в 40 bpm, а за максимальный – 240.

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

Согласно спецификации миди, всего предусмотрено 128 нот. Для реализации воспроизведения нот используется второй, 16-битный таймер. Более того, ноты самых нижних (с частотами около 50 Гц) и самых верхних (с частотами около 8 кГц) октав будут воспроизводиться микроконтроллером не совсем благозвучно. Но на практике их используется гораздо меньше. Но я выбрал в качестве начала ноту с номером 37 (её код 36, так как кодировка идёт от нуля). Но при всём при этом 16-битный таймер с фиксированным делителем охватывает почти весь диапазон нот, предусмотренный миди, а именно, без первых 35-ти. Именно ей соответствует частота 65. Это сделано для удобства, так как этому номеру соответствует нота «C», как первая нота в традиционном звукоряде. 4/2=0. 4 Гц, а полупериод составляет – 1/65. Этот период времени при частоте МК 8 мГц и делителе 1 (то есть без делителя) таймер отсчитает приблизительно в целом за 0. 00764 сек. Для 35-й ноты, если подсчитать, данное значение составит 68645, что выходит за диапазон счёта 16-разрядного таймера. 00764/(1/8000000)= 61156 раза. Но практической необходимости в этом нет, как нет её даже и для воспроизведения самых верхних нот. Но, даже если бы была необходимость воспроизводить ноты, ниже 36-й, можно ввести первый доступный делитель таймера, равный 8. 85 Гц, значения таймера составляет, если посчитать аналогично, 319. Тем не менее, для самой верхней 128-й ноты, ноты «G» с частотой 12543. Специфика всех приведённых расчётов обусловлена определённой конфигурацией режима таймера, что будет показано позже.

Есть известная формула для расчёта частоты ноты по её номеру. Теперь у меня возник не менее важный вопрос: как получить зависимость между номером ноты и кодом для таймера? Но в формуле зависимости частоты от ноты фигурирует корень 12-й степени, и вообще, не хотелось бы загружать контроллер такими вычислительными процедурами. А код таймера для известной частоты вычисляется легко, как это было показано выше на примерах. И я решил поступить следующим образом, выбрав золотую середину. С другой стороны, создавать массив кодов таймера для всех нот тоже не рационально. А ноты следующих октав получать последовательным умножением частот нот первой октавы на 2. Достаточно создать массив кодов таймера для самых первых 12-ти нот, которые составляют одну октаву. Ещё одно удобство заключается в том, что номер октавы служит, по совпадению, аргументом в операции побитового сдвига вправо (»), которая будет применяться в качестве операции деления на степени двойки. Или, то же самое, последовательным делением значений кодов таймера на 2. А это и есть номер октавы. Я выбрал этот оператор не случайно, так как его аргумент отражает показатель степени двойки делителя (количество деления на 2). Нота в миди файле кодируется одним байтом, точнее, 7-ю битами. Для применяемого мною набора нот задействовано в целом 8 октав (последняя октава неполная). Данная операция осуществляется на этапе преобразования миди файла в упрощённый формат. Для того чтобы воспроизвести ноты в МК, согласно вышесказанной идее, необходимо в первую очередь вычислить по коду ноты номер октавы и номер ноты в октаве. Итого получается, что нота кодируется теми же семью битами, как и в миди файле, но только в другом представлении, удобном для МК. Восемь октав, как раз, можно закодировать тремя битами, а 12 нот в октаве – четырьмя. Из-за того, что 4-мя битами можно закодировать 16 комбинаций, а нот в октаве 12, имеются незадействованные байты.

В случае с МК, ввиду одноголосности мелодии, информация об отключаемой ноте будет являться избыточной. Последний восьмой бит можно использовать, как маркер включения или отключения ноты. А в случае наступления паузы происходит «включение тишины», для чего можно выделить специальный байт из множества незадействованных байтов, а информацию об отключении ноты и вовсе не использовать. При непосредственной смене ноты в мелодии происходит не «отключение-включение», а «переключение» ноты. Я не последовал этой идее, так как памяти и без того предостаточно. Такая идея хороша тем, что экономит размер получившейся мелодии после преобразования, но в целом усложняет модель.

В преобразованном формате применяется точно такой же принцип. Информация о нотах мелодии в миди файле хранится в блоке соответствующего канала в представлении «интервал-событие-интервал-событие…». Первый бит (самый старший бит 7) кодирует тип события. Для записи события (включения или отключения ноты) используется, как уже говорилось выше, один байт. Следующие три бита кодируют номер октавы, а самые младшие четыре бита – номер ноты в октаве. Значение «1» — включение ноты, а значение «0» — отключение. В оригинальном же формате миди для этого применяется формат переменной длины. Для записи интервала времени также используется один байт. То есть, одним байтом, фактически, можно закодировать интервал до 128 тиков. Его небольшой недостаток заключается в том, что только 7 бит кодируют интервал времени (количество «тиков»), а восьмой бит служит признаком продолжения. Именно он кодирует интервал времени до 256 тиков. Но так как интервалы времени между событиями в реальных и простых мелодиях иногда превосходят 128, но почти никогда не превосходят 256, я отказался от формата переменной длины и обошёлся одним байтом. Так как по проекту применяется 48 тиков на четверть, или же, 48*4=192 тика на такт, то одним байтом можно закодировать интервал, длительностью в 256/192=1.(3) (одну целую и одну треть) такта, что вполне достаточно.

Первые 14 байт содержат название мелодии. В собственном формате, в который преобразуется миди файл, я также применил небольшой заголовок, размером в 16 байт. Затем следует нулевой пробел. Естественно, длина названия не должна превосходить 14 символов. Это значение вычисляется на этапе преобразования и служит для инициализации таймера МК, отвечающего за темп. Следующий последний байт отражает темп мелодии в представлении, удобном для МК. О том, как оно вычисляется, говорилось несколько абзацев выше.

Каждый нечётный байт соответствует интервалу времени, а каждый чётный – событию (ноте). Начиная с 17-ого байта, следует содержимое мелодии. Признаком конца мелодии служит метка из двух байтов 0xFF. Первый байт будет нулевой, если мелодия начинается с ноты, от начала миди файла, без предварительной паузы. Для того чтобы мелодия в цикле звучала благозвучно с точки зрения ритмики, её необходимо зациклить грамотно. Задача предусматривает циклическое воспроизведение мелодии микроконтроллером. А для этого нужно отвести соответствующее событие. Для этого, по необходимости, нужно выдержать после последней ноты паузу определённой длины, как правило, до заполнения последнего такта. Он соответствует отключению 16-ой ноты в первой октаве, что является абсурдом, так как нот в октаве всего 12. Я задействовал байт 0x0F, который не используется в кодировании ноты. Таким образом, данный байт кодирует «беззвучную ноту», старший бит которого также может служить признаком включения или отключения, несмотря на избыточность информации и в этом случае. О незадействованных байтах уже говорилось выше. Напомню, что в модели не используются первые 36 нот. Для задания этой ноты в миди редакторе я отвёл первую или вторую ноту (любую из них). Таким образом, первая (или вторая) нота используется по необходимости для правильного завершения мелодии, чтобы не нарушалась ритмичность при воспроизведении её в цикле.

На рисунках ниже изображены ноты мелодии, которые я переписал с одной из картинок в Интернете. Продолжая работать в редакторе «Cakewalk Pro Audio 9», составим произвольную мелодию. Первый очень удобен для написания и редактирования мелодии с помощью компьютерной мыши. Изображения нот представлены в двух стилях: в стиле «Piano roll» и в классическом стиле. Именно им я и пользуюсь.

А в самом начале мелодии, ввиду наличия затакта, перед первой нотой есть отступ в одну четверть. Как видно из рисунка, в конце применена самая низкая (первая) нота для признака тишины на нужном временном отрезке, чтобы грамотно организовать цикличность.

В редакторе предусмотрен режим отображения событий в табличном виде.

Если всё же лишние события, не относящиеся к нотам, по каким-либо причинам попали в список, их можно удалить нажатием клавиши «Del». Как видно из рисунка, в списке событий нет ничего лишнего, кроме взятия нот, как порой это бывает при лишних манипуляциях над музыкальным проектом. Этой функцией, кстати, я дополнял программу на этапе отладки. Хотя, на этапе преобразования все лишние события игнорируются, а дельта-время «накапливается». То есть, одной строкой в таблице выражены сразу два миди события: включение и отключение ноты. Как можно догадаться, таблица отражает время включения и время длительности каждой ноты совместно с другими свойствами, которые нам не нужны.

Сохраним мелодию в формат «миди 1», как показано на рисунке.

Сразу нужно оговорить, что, в отличие от тех же avi файлов (о чём я раньше писал), байты числовых значений в миди файле представлены не в реверсном порядке, а по старшинству (big endian). Откроем сохранённый файл в HEX редакторе.

Сначала жирной красной рамкой выделены три группы по два байта в каждой. На рисунке я отметил маркерами только нужные байты. Именно такими значениями должны обладать эти три константы для дальнейшей работы программы по преобразованию. Это соответственно, тип миди формата (1), число каналов (2) и число тиков на четверть (48). В первом канале серой рамкой отмечены 6 байт, внутри которой голубой рамкой выделены три байта. Пурпурными дугами отмечены начала каждого из двух каналов. Три байта далее – содержимое события. Эти 6 байт относятся к мета событию (маркер-признак 0xFF) с кодом 0x51 и длиной содержимого в 0x03 байта. Последний младший байт можно смело отбросить, ибо сверхточность не важна. Данное событие задаёт темп мелодии как раз этими тремя байтами в голубой рамке. Во втором треке – в треке с нотами – в синюю рамку обведены значения интервалов времени. Подробное и доскональное описание всех байтов в файле я приводить не буду. Именно предпоследняя нота мелодии (считая лишнюю псевдо ноту концовки) длится три четверти такта, что составляет 48*3=144 тика и превосходит 128. Они, между прочим, в данном конкретном примере, не превзошли одного байта, кроме единственного случая с предпоследней нотой. А для представления интервала времени в преобразованном формате значение 144 легко кодируется одним байтом. И именно для неё приходится задействовать два байта, согласно формату переменной длины. В зелёную рамку обведены ноты, точнее их коды. Этот особый случай я обвёл в двойную синюю рамку. Как уже говорилось, нулевая громкость является признаком отключения (отпускания) ноты, и на протяжении всей композиции действует одно событие: включение ноты. В серую рамку обведены громкости звучания каждой ноты. Я не стал обрисовывать все ноты до конца мелодии. Код этого события, 0x90, помечен жёлтой заливкой. Единственное исключение – двойная синяя рамка для единственного интервала времени, превосходящего порог в 128 тиков.

Рассмотрим фрагмент из этого файла, который относится к содержимому преобразованной мелодии из примера выше. Опять же, как говорилось выше, программа для преобразования миди файла в собственный формат для МК, на самом деле работает с группой из нескольких миди файлов, а на выходе создаёт файл-образ для EEPROM. Каждая новая мелодия начинается с нового сектора. Я его открыл в другом HEX редакторе, чтобы показать образ по секторам и обратить на это внимание.

По расчётам значение 0xC1 (193) приходится на темпы 154, 155 и 156. Последний байт из первой строки (первые 16 байт), обведённый в красную рамку, задаёт темп мелодии. Первые байты (до 14-го), обведённые в голубую рамку, определяют название композиции. Как раз, я в проекте задавал темп мелодии 155 bpm, что было видно на одном из скриншотов ранее. Для МК эта информация лишняя, она нужна только для ориентировки в HEX редакторе. В данном примере – «Classic». Хотя, если делать более сложный проект на МК с применением дисплея, можно пользоваться этой информацией, отображая название воспроизводимой мелодии.

Как и в случае с оригинальным файлом миди, я не стал раскрашивать все ноты, а раскрасил лишь часть. Со второй строки (с 17-го байта) начинается содержимое мелодии. Чётные байты, выделенные зелёной рамкой, являются нотами совместно с признаками их включения/отключения. Нечётные байты, выделенные синей рамкой, являются интервалами времени. В байте 0xB4 (0b10110100) старший бит равняется единице, что является признаком включения ноты, а в байте 0x34 (0b00110100) старший бит равняется нулю, что является признаком отключения ноты. К примеру, первые два «зелёных» байта, 0xB4 и 0x34, относятся к одной и той же ноте с кодом 0x34, а байты отличаются только одним старшим битом. Или же, в десятичном виде, 3 и 4 соответственно. Байтом 0x34 закодирована нота с такими параметрами: код октавы 0b011, а код ноты в октаве – 0b0100. Нумерация октав здесь выбрана произвольно без учёта стандартных нумераций. Если считать не от нуля, то получается, что первая нота в мелодии принадлежит четвёртой октаве и является в ней пятой по счёту. Так оно и есть: композиция начинается именно с этой ноты. Оговоренная нота, согласно моей расчётной вспомогательной таблице Excel, является нотой с кодом 76 (0x4C) для миди формата, то есть нотой E6 (нота «ми» 6-ой миди октавы).

В нашем примере все соседние ноты, которые без пауз, разные. Нельзя не отметить особый случай в нотной последовательности, когда одна и та же нота повторяется без паузы. То есть, интервал времени между отключением одной и включением следующей за ней точно такой же ноты равен нулю. Но встречаются мелодии, где нота без паузы повторяется. Но в случае с МК это будет звучать настолько слитно, что на слух будет сложно различить границу между двумя одинаковыми нотами. Ввиду особенности сложного синтеза музыки подобная последовательность будет звучать привычно на любом синтезаторе. Для таких особых случаев программа на этапе преобразования, натыкаясь на такую комбинацию, вводит паузу между нотами, длиной в 1 тик и уменьшает длительность стоящей левее ноты на этот же интервал времени. На практике, конечно же, чёткого слияния не будет из-за промежуточных вычислений, происходящих в МК, но всё равно, данный интервал времени вероятнее всего будет гораздо меньше длительности даже одного тика. Минимального «зазора» в 1 тик вполне достаточно, как показала практика.

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

Как уже я писал, он служит списком адресов секторов начал мелодий. Рассмотрим самый первый сектор выходного файла-образа EEPROM. Значение количества мелодий записывается в последний 512-й байт сектора. Программа успешно просканировала 8 мелодий без ошибок (на момент написания статьи я записал 8 мелодий). Для первой мелодии адрес равен 0x01, что соответствует второму сектору (первому, если считать с нуля). А с самого начала сектора записываются адреса. Поэтому в последовательности адресов наблюдаются пропуски. Третья и четвёртая мелодия (две из восьми) оказались длинноватыми и не поместились в один сектор. На память, размером 64кБ, если посчитать, можно записать не более 127 мелодий, поэтому одного сектора для адресации вполне достаточно.

Ниже на рисунках приведены скриншоты получившихся таблиц (в двухоконном режиме). Все предварительные оценки и расчёты, отражённые в статье, я проводил в Excel.

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

Основной файл 1.cpp

#include <stdio.h>
#include <windows.h>
#include <string.h> #define SPACE 1 HANDLE openInputFile(const char * filename) HANDLE openOutputFile(const char * filename) { return CreateFile ( filename, // Open Two.txt. GENERIC_WRITE, // Open for writing 0, // Do not share NULL, // No security OPEN_ALWAYS, // Open or create FILE_ATTRIBUTE_NORMAL, // Normal file NULL); // No template file } void filepos(HANDLE f, unsigned int p){ LONG LPos; LPos = p; SetFilePointer (f, LPos, NULL, FILE_BEGIN); //FILE_CURRENT //https://docs.microsoft.com/en-us/windows/desktop/api/fileapi/nf-fileapi-setfilepointer
} DWORD wr;
DWORD ww; unsigned long int read32(HANDLE f){ unsigned char b3,b2,b1,b0; ReadFile(f, &b3, 1, &wr, NULL); ReadFile(f, &b2, 1, &wr, NULL); ReadFile(f, &b1, 1, &wr, NULL); ReadFile(f, &b0, 1, &wr, NULL); return b3<<24|b2<<16|b1<<8|b0;
} unsigned long int read24(HANDLE f){ unsigned char b2,b1,b0; ReadFile(f, &b2, 1, &wr, NULL); ReadFile(f, &b1, 1, &wr, NULL); ReadFile(f, &b0, 1, &wr, NULL); return b2<<16|b1<<8|b0;
} unsigned int read16(HANDLE f){ unsigned char b1,b0; ReadFile(f, &b1, 1, &wr, NULL); ReadFile(f, &b0, 1, &wr, NULL); return b1<<8|b0;
} unsigned char read8(HANDLE f){ unsigned char b0; ReadFile(f, &b0, 1, &wr, NULL); return b0;
} void message(unsigned char e){ printf("Error %d: ",e); switch(e){ case 1: //В мета-треке встретилось не мета-событие; printf("In track0 event is not FF\n"); break; case 2: //Длина мета-события превышает 127 printf("Len of FF >127\n"); break; case 3: //Неподходящий формат миди; printf("Midi is incorrect\n"); break; case 4: //Превышение дельта времени события; printf("Delta>255\n"); break; case 5: //В сообщении контроллера встретились RPN или NRPN; printf("RPN or NRPN is detected\n"); break; case 6: //Нота вне допустимого диапазона; printf("Note in 1...35 range\n"); break; case 7: //Превышение длинны имени миди файла; printf("Long of name of midi file >18\n"); break; } system("PAUSE");
} int main(){ HANDLE in; HANDLE out; unsigned int i,j; unsigned int inpos; unsigned int outpos=0; unsigned char byte; //Просто байт; unsigned char byte1; //Байт данных 1 сообщения канала; unsigned char byte2; //Байт данных 2 сообщения канала; unsigned char status; //Статус-байт (для запоминания); unsigned char sz0; //Длина мета-события; unsigned long int bsz0; //Размер блока трека с мета-данными; unsigned short int format, ntrks, ppqn; //Данные блока заголовка; unsigned long int bsz1; //Размер блока трека с нотами; unsigned long int bpm; //Темп (в микросек. на четверть); unsigned long int time=0; //Продолжительность мелодии в тиках (для статистики); unsigned char scale; //Выходной байт для таймера МК, задающий темп; unsigned char oct; //Номер ноты в пределах октавы; unsigned char nt; //Номер октавы; unsigned char outnote; //Выходная нота в моём формате для МК; unsigned char prnote=0; //Запоминание предыдущей ноты; unsigned char tdt; //Байт (часть) величины переменной длины; unsigned int dt; //Расчитанная величина переменной длины (в тиках); unsigned int outdelta=0; //Длительность ноты или тишины (в тиках); unsigned char prdelta=0; //Запоминание предыдущего ноты; char fullname[30]; //Имя входного файла с директорией; char name[16]; //Название мелодии; WIN32_FIND_DATA fld; //Структура с файлом mid; HANDLE hf; unsigned short int csz; //Размер текущей мелодии; unsigned char nfile=0; //Число мелодий; unsigned char adr[128]; //Буфер с адресами начал мелодий; out=openOutputFile("IMAGE.out"); outpos=512; //Отсюда начнётся первая мелодия; filepos(out,outpos); hf=FindFirstFile(".\\midi\\*.mid",&fld); do{ printf("\n***** %s *****\n",fld.cFileName); if(strlen(fld.cFileName)>18){ //Контроль длины имени файла; message(7); } sprintf(name,"%s",fld.cFileName); name[strlen(fld.cFileName)-4]=0; //Обрезка расширения; sprintf(fullname,".\\midi\\%s",fld.cFileName); //Формируем полное имя с подкаталогом; WriteFile(out, name, strlen(name), &ww, NULL); //Записываем название мелодии в заголовок; in=openInputFile(fullname); //Открываем миди файл на обработку; #include "process.cpp" //Основная часть программы в другом файле; outpos+=((csz/512)+1)*512; //Переходим на ближайший новый сектор; adr[nfile]=(outpos/512)-((csz/512)+1); //Номер сектора (адрес) для обработанной мелодии; filepos(out,outpos); CloseHandle(in); nfile+=1; }while(FindNextFile(hf,&fld)); //Переход на следующий файл, пока они не кончатся; FindClose(hf); WriteFile(out, &outnote, 1, &ww, NULL); outpos=0; //Здесь адреса начал мелодий; filepos(out,outpos); WriteFile(out, adr, nfile, &ww, NULL); outpos=511; //Здесь количество мелодий; filepos(out,outpos); WriteFile(out, &nfile, 1, &ww, NULL); CloseHandle(out); system("PAUSE"); return 0;
}

Вложенный файл process.cpp

time=0;
inpos=8;
//Обработка блока заголовка;
filepos(in,inpos);
format=read16(in);
ntrks=read16(in);
ppqn=read16(in);
if(format!=1 || ntrks!=2 || ppqn!=48){ message(3);
}
inpos+=10;
filepos(in,inpos); //Обработка блока трека с мета-данными;
bsz0=read32(in);
inpos+=4;
while(inpos<22+bsz0){ //Пока не будут обработаны все байты блока; tdt=read8(in); inpos+=1; //Обработка формата переменной длины; dt=(unsigned int)(tdt&0x7F); while(tdt&0x80){ tdt=read8(in); inpos+=1; dt=(dt<<7)|(tdt&0x7F); } byte=read8(in); inpos+=1; if(byte==0xFF){ //Расчитываю на то, что мета-трек состоит только из мета-событий; byte=read8(in); //Считываем тип мета-события; sz0=read8(in); //Считываем его длину, надеясь, что оно не длиннее 127 (для простоты); if(sz0&0x80){ message(2); } inpos+=2; switch(byte){ case 0x51: //Меня интересует только "Set Tempo"; bpm=read24(in); scale=256-(bpm/(ppqn*128)); printf("scale=%d\n",scale); filepos(out,outpos+15); //Записываем темп; WriteFile(out, &scale, 1, &ww, NULL); csz=16; break; default: break; } inpos+=sz0; filepos(in,inpos); //Это обязательно, если не попаду на 0x51; }else{ message(1); }
} //Обработка блока трека с нотами;
outdelta=0;
inpos+=4;
filepos(in,inpos);
bsz1=read32(in);
inpos+=4;
while(inpos<30+bsz0+bsz1){ tdt=read8(in); inpos+=1; //Обработка формата переменной длины; dt=(unsigned int)(tdt&0x7F); while(tdt&0x80){ tdt=read8(in); inpos+=1; dt=(dt<<7)|(tdt&0x7F); } outdelta+=dt; //Накапливаем время события; //Накопление актуально, если посреди нотных событий встречаются другие, ненотные; time+=dt; //Накапливаем общее время; byte=read8(in); //Это может быть и статус, и данные; inpos+=1; if(byte&0x80){ //Если это статус; status=byte; //Обновляем статус; if(byte==0xFF){ //Если вдруг это мета-данные; byte=read8(in); //То пропускаем всё их содержимое, они нас не интересуют; sz0=read8(in); inpos+=(2+sz0); filepos(in,inpos); }else{ //Иначе считываем первый байт данных; byte1=read8(in); inpos+=1; } }else{ //А если это не статус, то это первый байт данных для предыдущего статуса; byte1=byte; } switch(status&0xF0){ //Анализируем статус, не обращая внимания на номер канала; case 0xF0: //Это уже перехвачено ранее, как мета-событие; break; case 0x80: //Отключение ноты; byte2=read8(in); //Считываем второй ненужный байт данных (динамика ноты); inpos+=1; //А первый байт содержит номер ноты, с ним и работаем; if(byte1>1&&byte1<36){ //Ноты не должны быть из этого диапазона по моему проекту; message(6); } if(byte1>1){ //Обычная нота; oct=((byte1-36)/12); //Расчёт номера октавы; nt=(byte1-36)%12; //Расчёт номера ноты в октаве; }else{ //Псевдо нота для выравнивания; oct=0; nt=15; } outnote=(oct<<4)|nt; //Формирование выходного байта; prnote=outnote; prdelta=outdelta; if(outdelta>255){ //Длительность события не должна превышать 255 (по моей идее); message(4); } WriteFile(out, &outdelta, 1, &ww, NULL); WriteFile(out, &outnote, 1, &ww, NULL); csz+=2; outdelta=0; //Инициализируем длительность заново; break; case 0x90: //Включение и отключение ноты; byte2=read8(in); //Считываем второй байт данных (динамика ноты); inpos+=1; //А первый байт содержит номер ноты, с ним и работаем; if(byte1>1&&byte1<36){ //Ноты не должны быть из этого диапазона по моему проекту; message(6); } if(byte1>1){ //Обычная нота; oct=((byte1-36)/12); //Расчёт номера октавы; nt=(byte1-36)%12; //Расчёт номера ноты в октаве; }else{ //Псевдо нота для выравнивания; oct=0; nt=15; } if(byte2){ //Если динамика ненулевая, это включение ноты; outnote=0x80|(oct<<4)|nt; //Старший бит = 1; //Устранение слияния одинаковых нот; if(!outdelta && (outnote&0x7F)==prnote){ //Если нет паузы и ноты совпадают; prdelta-=SPACE; //Корректируем дельта-время; filepos(out,outpos+csz-2); //Становимся на две позиции назад; WriteFile(out, &prdelta, 1, &ww, NULL); //Записываем коррекцию; filepos(out,outpos+csz); outdelta=SPACE; //Вместо нуля - величина коррекции; } }else{ //Если динамика нулевая, это эквивалент отключения ноты; outnote=(oct<<4)|nt; prnote=outnote; //Запоминание текущей ноты; prdelta=outdelta; //Запоминание текущего дельта-времени; } if(outdelta>255){ //По моему проекту дельта-время должно умещаться в байт; message(4); } WriteFile(out, &outdelta, 1, &ww, NULL); WriteFile(out, &outnote, 1, &ww, NULL); csz+=2; outdelta=0; //Переинициализация дельта-времени для очередного накопления; break; //Все остальные статусы (команды) игнорируем; case 0xA0: //Сообщение послекасания; byte2=read8(in); inpos+=1; break; case 0xB0: //Номер и значение контроллера; if(byte1>=98&&byte1>=101){ //Если вдруг встретятся вложенные контроллеры NRPN и RPN; message(5); //Предупредить об этом; } byte2=read8(in); inpos+=1; break; case 0xC0: //Номер программы (муз. инструмент канала); //Эта команда, например, не имеет второго байта; break; case 0xD0: //Давление; break; case 0xE0: //Звуковысотное колесо; byte2=read8(in); inpos+=1; break; default: //Всё остальное (оно не должно встретиться); break; }
}
//Записываем в конец файла метку 0xFFFF, как признак конца мелодии;
outdelta=255;
outnote=255;
WriteFile(out, &outdelta, 1, &ww, NULL);
WriteFile(out, &outnote, 1, &ww, NULL);
csz+=2;
//Вывод продолжительности в тиках, полных тактах и оставшихся тиках;
printf("Length: %i (%i:%02i)\n",time,time/192,time%192);

Базовая часть программы для МК, на самом деле, весьма простая. Рассмотрим один из вариантов её реализации, точнее, её основную часть.

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

#define ENT1 TCCR1B=0x09;TCCR1A=0x40
#define DIST1 TCCR1B=0x00;TCCR1A=0x00;PORTB.1=0

Перед включением таймера нужно присвоить регистру OCR1A 16-битное значение, которое будет соответствовать воспроизводимой частоте. Это будет показано далее. При включении таймера регистру TCCR1B присваивается режим «Waveform Generation Mode» c делителем таймера, равным 1, а регистру TCCR1A – «Toggle OC1A on Compare Match». При этом сигнал снимается со специально отведённого вывода МК «OC1A». В ATmega8 в SMD корпусе это вывод с номером 13, он же совпадает с PORTB.1. При отключении таймера оба регистра обнуляются, а вывод PORTB.1 принудительно становится в ноль. Это нужно для того, чтобы предотвратить во время тишины выход постоянного напряжения, который будет нежелательным для входа УНЧ. Хотя, можно поставить в цепи конденсатор, но можно и программно отключать вывод. Постоянное напряжение может возникнуть на этом выводе в том случае, если нота будет отключена в момент соответствующей фазы сигнала, а это в 50% случаев.

Данные значения были рассчитаны заранее. Создадим массив значений таймера для 12-ти нот самой первой октавы.

freq[]={61156,57724,54484,51426,48540,45815,43244,40817,38526,36364,34323,32396};

Значения нот других октав, как я уже говорил, будут получаться методом деления на степени двойки.

Он работает постоянно, с прерыванием по переполнению, каждый раз инициализируясь заново тем значением, который соответствует темпу мелодии. Конфигурация таймера 0 ещё проще. На базе этого таймера создан виртуальный таймер, который отсчитывает тики (отрезки времени) в мелодии. Делитель таймера равен 5: TCCR0=0x05. Обработка реакции срабатывания этого таймера помещена в основной цикл программы.

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

interrupt [TIM0_OVF] void timer0_ovf_isr(void){ if(ent01){ vt01+=1; } TCNT0=top0;
}

Здесь переменная ent01 отвечает за активирование виртуального таймера. По этой переменной его можно включить или отключить при необходимости. Переменная vt01 – счётная основная переменная виртуального таймера. Строка TCNT0=top0 обозначает инициализацию таймера 0 на нужное значение top0, которое считывается из заголовка мелодии перед её воспроизведением.

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

if(alm){ //Как только номер мелодии не ноль; adr=eepr(alm-1)<<9; //Узнаём адрес по номеру мелодии (<<9 это умножение на 512); adr+=15; //Позиционируемся на то место, где прописана информация о темпе мелодии; top0=eepr(adr); //Считываем это значение; adr+=1; //Позиционируемся на начало самих нот мелодии; adr0=adr; //Запоминаем этот адрес во временную переменную (нужно для зацикливания); top01=eepr(adr); //Считываем первое значение количества тиков в "вершину счёта" виртуального таймера; adr+=1; //Переходим на первую ноту; note=eepr(adr); //Считываем ноту; adr+=1; //Переходим на второе значение дельта-времени; vt01=0; //Подготавливаем виртуальный таймер к работе; ent01=1; //Запускаем виртуальный таймер; TCNT0=0; //Запускаем базовый таймер; alm=0; //Чтобы в цикле повторно не попасть в этот блок, обнуляем номер мелодии;
}

Дальнейшие переключения с ноты на ноту осуществляется в блоке обработки виртуального таймера, который также помещён в основной цикл.

if(vt01>=top01){ //Как только сработает ВТ, отсчитав нужное количество тиков; vt01=0; //Инициализируем ВТ заново; if(note&0x80){ //Если считанная нота с меткой "включить"; nt=note&15; //Вычисляем номер ноты в октаве; oct=(note&0x7F)>>4; //Вычисляем номер октавы; if(nt!=15){ //Если номер ноты в октаве не равен 15, это обычная нота; OCR1A=freq[nt]>>oct; //Конфигурируем нотный таймер на нужную частоту; //Подробное описание идей и операций этой краткой записи было ранее; ENT1; //Включаем ноту; }else{ //Иначе это "волшебная нота" для выравнивания концовки; DIST1; //Включаем тишину; } }else{ //Иначе это нота с меткой "выключить"; DIST1; //Включаем тишину; } top01=eepr(adr); //Считываем следующее количество тиков в переменную "вершина ВТ"; adr+=1; //Переходим на следующую ноту; note=eepr(adr); //И также её считываем; adr+=1; //Идём дальше; if(note==255 && top01==255){ //Анализируем считанные значения на предмет конца мелодии; top01=eepr(adr0); //Переходим на начальный адрес, который заранее запомнили; note=eepr(adr0+1); //Считываем начальные параметры аналогично; adr=adr0+2; //Продвигаемся на следующую пару; }
}

Из комментариев в тексте программы всё должно быть достаточно ясно и понятно.

Для остановки мелодии применяется следующая вставка основного цикла.

if(stop){ //Флаг остановки мелодии; DIST1; //Отключаем нотный таймер; ent01=0; //Отключаем виртуальный таймер; vt01=0; //Сбрасываем виртуальный таймер;
}

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

Теперь перейдём к тестированию программы.

К МК подключена EEPROM по I2C шине, работа с которой реализована на программном уровне. Помимо вышеприведённых фрагментов кода, в программу МК я добавил функции обработки кнопок, с помощью которых я управляю включением или отключением той или иной мелодии. Выход МК с вывода 13 подаю на звуковую карту ПК через делитель и записываю звук мелодии в звуковом редакторе. Проект делал при помощи «CodeVisionAVR» совместно с «CodeWizardAVR». Из-за того, что не все байты файла-образа являются полезными, прошивку памяти можно осуществлять только по полезным байтам (до маркеров конца мелодий) с целью экономии времени записи и ресурса чипа. Память EEPROM я прошивал с помощью программно-аппаратных средств, о которых я писал в одной из предыдущих статей. Для этого можно сделать отдельную программу, или же, осуществлять запись байтов в чип непосредственно во время преобразования, дополнив основную программу.

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

Одна из тестовых мелодий – последовательность нот с первой по последней при длительности одной ноты в одну четверть и темпом мелодии 40 bpm.

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

Это и есть то время, в течение которого МК проделывает необходимые вычисления. Анализируя промежутки времени, отчётливо видно, что реальная пауза между подряд идущими нотами составляет в среднем примерно 145 семплов (при частоте дискретизации аудиозаписи 44100 Гц), что составляет порядка 3 мс. Я специально написал значение в семплах, так как эта информация оригинальнее и точнее, хотя, это не особо принципиально. Данные вставки присутствуют регулярно перед каждой нотой.

Отсюда следует, что, в принципе, можно было бы не вводить ту самую поправку в 1 тик, когда две одинаковые ноты идут одна за другой без паузы. А длина одного тика при среднем темпе мелодии 120 bpm составляет порядка 10 мс. При прослушивании мелодии данные регулярные вставки вообще не заметны, и мелодии звучат ровно. Думаю, что регулярной вставки в 3 мс между нотами вполне бы было достаточно. Поэтому нет особой необходимости выполнять расчёт значения таймера для следующей ноты во время звучания текущей.

В этом случае после обработки при воспроизведении между ними присутствует пауза в 1 тик, что составляет при данном быстром темпе 310 семплов (около 6 мс) записанного сигнала. Другая тестовая мелодия с темпом 200 bpm содержит подряд идущие одинаковые 1/32 ноты из среднего диапазона без паузы.

А её звучание напоминает трель. Длина данной паузы, кстати, сравнима с периодом сигнала, что говорит о высоком темпе мелодии.

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

Теги
Показать больше

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

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

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

Кнопка «Наверх»
Закрыть