
Предлагаемый проигрыватель не требует карты памяти, он хранит MIDI-файл длиной до 6000 байт непосредственно в микроконтроллере ATtiny85 (в отличие от этой классической конструкции, которая проигрывает WAV-файлы, и карту памяти, естественно, требует). Четырёхголосное проигрывание с затуханием при помощи ШИМ реализовано программно. Пример звучания — по ссылке.
Устройство выполнено по схеме:

Электролитический конденсатор между микроконтроллером и динамической головкой не пропустит постоянную составляющую, если в результате программного сбоя на выходе PB4 появится логическая единица. Индуктивное сопротивление головки не пропускает частоту ШИМ. Если вы решите подключить устройство к усилителю, во избежание перегрузки последнего сигналом ШИМ нужно добавить ФНЧ, как здесь.
MIDI-файл необходимо поместить в исходник прошивки в виде массива вида:
const uint8_t Tune[] PROGMEM = { 0x4d, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, 0x00, 0x01, 0x03, 0xc0, 0x4d, 0x54, 0x72, 0x6b, 0x00, 0x00, 0x0a, 0x7e, 0x00, 0xff, ... 0x50, 0xb0, 0x5b, 0x00, 0x00, 0xff, 0x2f, 0x00 };
Для перевода файла в такой формат в UNIX-подобных ОС есть готовое решение — утилита xxd. Берём MIDI-файл и пропускаем через эту утилиту так:
xxd -i musicbox.mid
В консоль будет выведено что-то вроде:
unsigned char musicbox_mid[] = { 0x4d, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, 0x00, 0x01, 0x03, 0xc0, 0x4d, 0x54, 0x72, 0x6b, 0x00, 0x00, 0x0a, 0x7e, 0x00, 0xff, ... 0x50, 0xb0, 0x5b, 0x00, 0x00, 0xff, 0x2f, 0x00 }; unsigned int musicbox_mid_len = 2708;
2708 — это длина в байтах. Получилось меньше 6000 — значит, поместится. Последовательность шестнадцатеричных чисел через буфер обмена переносим в скетч (только не забываем: в консоли — никакого Ctrl+C) вместо массива по умолчанию. Или не проделываем всего этого, если желаем его оставить.
Таймер-счётчик 1 будет работать на частоте в 64 МГц от ФАПЧ:
PLLCSR = 1<<PCKE | 1<<PLLE;
Переведём этот таймер в ШИМ-режим для работы в качестве ЦАП, скважность будет зависеть от значения OCR1B:
TIMSK = 0; // Timer interrupts OFF TCCR1 = 1<<CS10; // 1:1 prescale GTCCR = 1<<PWM1B | 2<<COM1B0; // PWM B, clear on match OCR1B = 128; DDRB = 1<<DDB4; // Enable PWM output on pin 4
Частота прямоугольных импульсов зависит от значения OCR1C, оставим его равным 255 (по умолчанию), тогда частота в 64 МГц будет поделена на 256, и получится 250 кГц.
Таймер-счётчик 0 будет вырабатывать прерывания:
TCCR0A = 3<<WGM00; // Fast PWM TCCR0B = 1<<WGM02 | 2<<CS00; // 1/8 prescale OCR0A = 19; // Divide by 20 TIMSK = 1<<OCIE0A; // Enable compare match, disable overflow
Тактовая частота в 16 МГц делится делителем на 8, а затем на значение OCR0A, равное 19+1, и получается 100 кГц. Проигрыватель четырёхголосный, на каждый голос получается по 25 кГц. По прерыванию происходит вызов процедуры его обработки ISR(TIMER0_COMPA_vect), которая рассчитывает и выводит звуки.
Сторожевой таймер сконфигурирован на выработку прерывания каждые 16 мс, что требуется для получения частот нот:
WDTCR = 1<<WDIE | 0<<WDP0; // Interrupt every 16ms
Для получения колебаний заданной формы применён прямой цифровой синтез. Аппаратного перемножения в ATtiny85 нет, поэтому берём прямоугольные импульсы и умножаем амплитуду огибающей на 1 или -1. Убывает амплитуда линейно, и чтобы рассчитать её в тот или иной момент времени, достаточно линейно же уменьшать показания счётчика.
Для каждого из каналов предусмотрено по три переменных: Freq[] — частота, Acc[] — фазовый аккумулятор, Amp[], значение амплитуды огибающей. Значения Freq[] и Acc[] суммируются. Старший бит Acc[] используется для получения прямоугольных импульсов. Чем больше Freq[], тем больше частота. Готовая форма колебаний перемножается на огибающую Amp[]. Все четыре канала мультиплексируются и поступают на аналоговый выход.
Важной частью программы является процедура обработки прерывания от таймера-счётчика 0, которая выводит колебания на аналоговый выход. Вызов этой процедуры происходит с частотой около 95 кГц. Для текущего канала c она обновляет значения Acc[c] и Amp[c], а также рассчитывает значение текущей ноты. Результат поступает на регистр сравнения OCR1B таймера-счётчика OCR1B для получения аналогового сигнала на выводе 4:
ISR(TIMER0_COMPA_vect) { static uint8_t c; signed char Temp, Mask, Env, Note; Acc[c] = Acc[c] + Freq[c]; Amp[c] = Amp[c] - (Amp[c] != 0); Temp = Acc[c] >> 8; Temp = Temp & Temp<<1; Mask = Temp >> 7; Env = Amp[c] >> Volume; Note = (Env ^ Mask) + (Mask & 1); OCR1B = Note + 128; c = (c + 1) & 3; }
Строка
Acc[c] = Acc[c] + Freq[c];
прибавляет к аккумулятору Acc[c] значение частоты Freq[c]. Чем больше Freq[c], тем быстрее будет меняться значение Acc[c]. Затем строка
Amp[c] = Amp[c] - (Amp[c] != 0);
уменьшает значение амплитуды для данного канала. Фрагмент (Amp[c] != 0) нужен, чтобы после достижения амплитудой нуля она не уменьшалась дальше. Теперь строка
Temp = Acc[c] >> 8;
переносит старшие 9 бит Acc[c] в Temp. И строка
Temp = Temp & Temp<<1;
оставляет старший бит этой переменной равным единице, если единице равны два старших бита, и устанавливает старший бит в нуль, если это не так. Получаются прямоугольные импульсы с соотношением включённого и выключенного состояний 25/75. В одной из предыдущих конструкций автор применил меандр, при новом же способе гармоник получается чуть больше. Строка
Mask = Temp >> 7;
переносит в остальные биты байта значения старшего, например, если старший бит был 0, то получится 0x00, а если 1 — то 0xFF. Строка
Env = Amp[c] >> Volume;
переносит в Env тот бит Amp[c], который задан значением Volume, по умолчанию — старший, так как Volume = 8. Строка
Note = (Env ^ Mask) + (Mask & 1);
всё это объединяет. Если Mask = 0x00 то Note присваивается значение Env. Если Mask = 0xFF, то Note присваивается значение, дополнительное к Env + 1, то есть Env со знаком минуса. Теперь Note содержит текущую форму колебаний, меняющуюся от положительного до отрицательного значений текущей амплитуды. Строка
OCR1B = Note + 128;
прибавляет к Note число 128 и записывает результат в OCR1B. Строка
c = (c + 1) & 3;
выводит четыре канала по соответствующим прерываниям, мультплексируя голоса на выходе.
Двенадцать частот нот заданы в массиве:
unsigned int Scale[] = { 10973, 11626, 12317, 13050, 13826, 14648, 15519, 16442, 17419, 18455, 19552, 20715};
Частоты нот других октав получаются делением на 2n. Например, делим 10973 на 24 и получаем 686. Верхний бит Acc[c] будет переключаться с частотой в 25000/(65536/685) = 261.7 Гц.
На звучание влияют две переменных: Volume — громкость, от 7 до 9 и Decay — затухание, от 12 до 14. Чем значение Decay больше, тем медленнее затухание.
Простейший интерпретатор MIDI обращает внимание только на значения ноты, темпа и коэффициента деления, а прочие данные игнорирует. Подпрограмма readIgnore() пропускает заданное количество байт в массиве, полученном из файла:
void readIgnore (int n) { Ptr = Ptr + n; }
Подпрограмма readNumber() считывает число из заданного количества байт с точностью до 4:
unsigned long readNumber (int n) { long result = 0; for (int i=0; i<n; i++) result = (result<<8) + pgm_read_byte(&Tune[Ptr++]); return result; }
Подпрограмма readVariable() считывает число с принятой в MIDI переменной точностью. Количество байт при этом может быть от одного до четырёх:
unsigned long readVariable () { long result = 0; uint8_t b; do { b = pgm_read_byte(&Tune[Ptr++]); result = (result<<7) + (b & 0x7F); } while (b & 0x80); return result; }
Из каждого байта берётся по семь бит, а восьмой равен единице, если дальше нужно прочитать ещё один байт, или нулю, если нет.
Интерпертатор вызывает подпрограмму noteOn() для проигрывания ноты в следующем доступном канале:
void noteOn (uint8_t number) { uint8_t octave = number/12; uint8_t note = number%12; unsigned int freq = Scale[note]; uint8_t shift = 9-octave; Freq[Chan] = freq>>shift; Amp[Chan] = 1<<Decay; Chan = (Chan + 1) & 3; }
Переменная Ptr указывает на следующий считываемый байт:
void playMidiData () { Ptr = 0; // Begin at start of file
Первый блок в MIDI-файле — это заголовок, указывающий на количество дорожек, темп и коэффициент деления:
// Read header chunk unsigned long type = readNumber(4); if (type != MThd) error(1); unsigned long len = readNumber(4); unsigned int format = readNumber(2); unsigned int tracks = readNumber(2); unsigned int division = readNumber(2); // Ticks per beat TempoDivisor = (long)division*16000/Tempo;
Коэффициент деления обычно равен 960. Теперь считываем заданное количество блоков:
// Read track chunks for (int t=0; t<tracks; t++) { type = readNumber(4); if (type != MTrk) error(2); len = readNumber(4); EndBlock = Ptr + len;
Считываем последовательные события до окончания блока:
// Parse track while (Ptr < EndBlock) { unsigned long delta = readVariable(); uint8_t event = readNumber(1); uint8_t eventType = event & 0xF0; if (delta > 0) Delay(delta/TempoDivisor);
В каждом событии задана delta — задержка в единицах времени, определяемых коэффициентом деления, которая должна произойти перед этим событием. Для событий, которые должны произойти тут де, delta равна нулю.
Метасобытия — это события типа 0xFF:
// Meta event if (event == 0xFF) { uint8_t mtype = readNumber(1); uint8_t mlen = readNumber(1); // Tempo if (mtype == 0x51) { Tempo = readNumber(mlen); TempoDivisor = (long)division*16000/Tempo; // Ignore other meta events } else readIgnore(mlen);
Единственный вид интересующих нас метасобытий — это Tempo, значение темпа в микросекундах. По умолчанию оно равно 500000, то есть, полсекунды, что соответствует 120 ударам в минуту.
Остальные события — это MIDI-события, определяемые первым шестнадцатеричным разрядом своего типа. Нас интересует только 0x90 — Note On, проигрывание ноты на следующем доступном канале:
// Note off - ignored } else if (eventType == 0x80) { uint8_t number = readNumber(1); uint8_t velocity = readNumber(1); // Note on } else if (eventType == 0x90) { uint8_t number = readNumber(1); uint8_t velocity = readNumber(1); noteOn(number); // Polyphonic key pressure } else if (eventType == 0xA0) readIgnore(2); // Controller change else if (eventType == 0xB0) readIgnore(2); // Program change else if (eventType == 0xC0) readIgnore(1); // Channel key pressure else if (eventType == 0xD0) readIgnore(1); // Pitch bend else if (eventType == 0xD0) readIgnore(2); else error(3); } } }
Значение velocity игнорируем, но при желании можно устанавливать по нему начальную амплитуду ноты. Остальные события пропускаем, длина у них может быть различной. При ошибке в MIDI-файле включается светодиод.
Микроконтроллер работает на частоте в 16 МГц, чтобы кварц не требовался, нужно соответствующим образом сконфигурировать встроенный ФАПЧ. Чтобы микроконтроллер стал Arduino-совместимым, применена эта наработка Spence Konde. В меню Board выбираем подменю ATtinyCore, а там — ATtiny25/45/85. В последующих меню выбираем: Timer 1 Clock: CPU, B.O.D. Disabled, ATtiny85, 16 MHz (PLL). Затем выбираем Burn Bootloader, потом заливаем программу. Программатор применён типа Tiny AVR Programmer Board фирмы SparkFun.
Прошивка под CC-BY 4.0, в которой уже есть фуга Баха в ре миноре, находится здесь, оригинальный MIDI-файл взят здесь.
