Как стать автором
Обновить

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

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

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

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

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

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

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

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

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

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



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



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



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

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

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

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

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

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

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

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

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

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

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





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

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



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

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



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



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

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



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

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

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

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

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



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





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

Основной файл 1.cpp
#include <stdio.h>
#include <windows.h>
#include <string.h>

#define SPACE 1

HANDLE openInputFile(const char * filename) {
       return CreateFile ( filename,      // Open Two.txt.
            GENERIC_READ,          // Open for writing
            0,                      // Do not share
            NULL,                   // No security
            OPEN_ALWAYS,            // Open or create
            FILE_ATTRIBUTE_NORMAL,  // Normal file
            NULL);                  // No template file       
}

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

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

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



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



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



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

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



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

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

Публикации

Истории

Ближайшие события

PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн
Weekend Offer в AliExpress
Дата 20 – 21 апреля
Время 10:00 – 20:00
Место
Онлайн