...

суббота, 1 июня 2019 г.

[Перевод] Минималистический четырёхголосный MIDI-проигрыватель

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

Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий