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

Комментарии (10)


  1. Sun-ami
    02.06.2019 20:30
    +1

    Не понимаю, зачем в 2019 году делать такие вещи на ATtiny85, если за те же деньги можно купить STM32F072C8T6 с 64KB flash и USB-bootloadert'ом c завода, или STM32F100C8T6B с 12-битным ЦАП и 64KB flash, оба потребляющие почти столько же на той же частоте в активном режиме, и вдвое меньше — в standby с включённым таймером, и главное — с умножителем и DMA, а потом писать про ограничение на размер MIDI-файлов в 6 КБайт? Это что, такой вид спорта?


    1. u_235
      02.06.2019 20:45
      +1

      • Простая архитектура.
      • Проще развести и сделать плату.
      • Сила привычки.
      • ATtiny85 дешевле STM32F072C8T6.


      1. Sun-ami
        02.06.2019 21:26
        +1

        Хм, а у нас в Харькове ATtiny85 дороже STM32F072C8T6. Но STM32F030C8T6 с 64кб Flash то точно дешевле (правда USB нет, но есть бутлоадер на UART), и STM32F070F6P6 с USB и шагом выводов 0,8мм, но 32К флеша.
        Разводить плату под них не сложнее — выводов используется столько же, разве что ещё USB нужно вести дифпарой, если его использовать. Простая архитектура приводит к посредственному звучанию.


        1. u_235
          02.06.2019 22:12

          У меня программирование МК — это хобби. И пока мне хватает ресурсов AVR, я не пересяду на другое. Не вижу смысла покупать адаптер для заливки/отладки и оценочную плату для экспериментов. Тратить время на раскуривание документации тоже не очень хочется.


  1. AVI-crak
    02.06.2019 21:32
    +1

    Дык вот насчёт спорта. Есть кучка фанатиков, что пишут музыку для шарманок. В шарманку с барабаном или лентой, современный миди файл закодировать не получится — ноты будут явно не на своём месте. А вот у них получается.
    И всё-же, когда делал визитку с музыкой для клиента — он одобрил мелодию из советских часов электроника. Tех самых, из девяностых.


    1. Tomasina
      02.06.2019 10:52

      А статейку про «делал визитку с музыкой» можно ожидать?


      1. AVI-crak
        02.06.2019 11:52

        Наверное нет, это получится прямая реклама китайской полуподвальной фирмы, которая делает масочные чипы буквально там-же в подвале. Без вот этих миллиардов долларов вложений как у нас.


  1. sterr
    01.06.2019 23:16

    Чисто по звучанию этот велосипед применялся в 90-х в часах Montana, там даже по моему приятнее звучание было. Сейчас при наличии разнообразных велосипедов различной крутости, эта поделка выглядит не очень… Но тем не менее интересный дверной звонок.


    1. clawham
      03.06.2019 22:33
      +1

      Это не про дверной звонок. это про аудиооткрытки и всякие музыкальные шкатулки. тиню можно взять в микрокорпусе, динамик — пищалка из компьютера, батарейка CR2032 и один кондер — вуаля.


  1. dlinyj
    03.06.2019 12:09
    +1

    Забавная поделка, жаль без видео и ещё больше жаль, что перевод.