Предлагаемый проигрыватель не требует карты памяти, он хранит 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-файл взят здесь.