Arduino AY player: продолжение

19bc8eb9b9a68e5b8ea6d2601d776755.jpg

Изучаем устройство OLED-экрана SSD1306 и дорабатываем звуковые индикаторы музыкального плеера PSG-файлов на чипе AY-3–8910.

Оглавление

В предыдущей статье я рассказал про свою версию музыкального chiptune-плеера на Arduino Pro Micro, воспроизводящего музыку на чипе AY-3–8910 из файлов в формате PSG, находящихся на SD-карте.

Про формат PSG

PSG-файл содержит последовательный дамп всех данных, которые выдаются в регистры чипа AY примерно 50 раз в секунду. С одной стороны, объем таких данных относительно велик по сравнению c форматами музыкальных редакторов-трекеров. С другой стороны, плеер такого формата очень прост в реализации, не требует больших объемов памяти для декодирования и значительных вычислительных мощностей. Имеется лишь небольшой буфер объемом пару сотен байт, используемый для считывания данных с SD-карты и передачу из него данных в регистры чипа AY без дополнительной обработки. Для преобразования прочих chiptune-файлов в этот формат можно использовать плеер AY_Emul Сергея Бульбы. На его же сайте можно найти огромные архивы с chiptune-музыкой.

Напомню, что за основу мной был взят другой плеер, в него добавлены:

  • кнопки для перехода на одну, 5 или 10 композиций назад/вперед

  • кнопка переключения между последовательным/случайным режимами выбора композиций

  • кнопка включения/выключения режима «демо» (когда играется только начальный фрагмент каждой композиции)

  • статическая память (SRAM) для хранения в ней имен всех файлов на SD-карте, а также списка неповторяющихся случайных чисел

Также расширена информация, показываемая на OLED-дисплее.

Индикатор громкости

Автором исходного плеера был реализован индикатор, который рисуется текстовыми символами:

p8nxw93qoqqm0foutsqgkbzux_w.gif

Отклонение символов > [] < от краев и центра индикатора пропорционально громкости в соответствующем канале A/B/C синтезатора. Выглядит вполне изящно и наглядно. Но мне запомнились индикаторы в различных демосценах на ZX Spectrum, которые показываются столбиками разной высоты:

t1mobbfc9tdg9v5cs1th_qi5bgc.gif

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

Экран SSD1306

Показ индикаторов громкости текстовыми символами выбран, в том числе, из-за особенностей дисплея. Здесь использована I2C-версия OLED-дисплея SSD1306 с разрешением 128×32 точки. Главная особенность таких экранов — данные для отрисовки возможно только передавать в них, но невозможно прочитать, что усложняет отрисовку графики поверх уже имеющегося изображения. Множество библиотек и программ, управляющих выводом данных на такие экраны, формируют изображение в памяти микроконтроллера, а затем копируют его в экран по шине I2C. Это повышает расход оперативной памяти, и передача большого объема данных в экран требует значительного времени.

i9kqidjhe3utcmobhyxeuuyfce0.png

На картинке схематично показан один ряд экрана высотой 8 пикселов и шириной 128 пикселов (столбцов). Экран монохромный, один байт соответствует вертикальному столбцу из 8 точек на экране. Младший бит соответствует верхнему пикселу, старший — нижнему пикселу в ряду. Подробнее устройство экрана рассмотрено, например, здесь и в datasheet.

Объем буфера для экрана 128×32 точки (4 ряда) потребует 512 байт оперативной памяти. Для чтения файлов с SD-карты используется библиотека, сама по себе занимающая в памяти значительный объем, и при общем объеме памяти в 2,5 килобайта свободного места больше почти не остается. Поэтому, здесь используется весьма полезная библиотека SSD1306Ascii. Она выводит только текст, передавая изображения символов из программной флеш-памяти (PROGMEM) напрямую в экран. Используется шрифт с размером букв 5×7 пикселов (в комплекте имеются и другие шрифты). В ряду высотой 8 пикселов как раз помещается буква плюс пустая строка в пиксел высотой для пропуска между строками текста.

cnklweomgdwvn7mky-6jb3ij8fq.png

В данном плеере верхние три строки обновляются только при переходе на следующий файл либо смене режима воспроизведения — перед этим вызывается полная очистка экрана (функция clear). Нижняя строка показывает, сколько процентов файла воспроизведено, и сами индикаторы громкости в каналах синтеза A/B/C. Только она обновляется 50 раз в секунду в той ж процедуре обработки прерывания, которая выдает данные в регистры чипа AY для воспроизведения музыки. Для этого вызывается отдельная процедура — рассмотрим её подробнее.

Обновление индикаторов громкости

Для показа прогресса воспроизведения и индикаторов громкости вызывается процедура displayOLED. Рассмотрим её код:

void displayOLED() {
  if (playbackFinished) return;
  oled.setCursor(0, 24); // <= 1
  oled.print("                      "); // <= 2
  //
  float fprc = (fsize > 0) ? 1000.0 * float(floaded) / float(fsize) : 0;
  int np = int(fprc + 0.5);
  if (np > 1000) np = 1000;
  sprintf(fperc, "%d.%d%%", np / 10, np % 10);
  oled.setCursor(0, 24); // <= 1
  oled.print(fperc);
  //
  oled.setCursor(32 + volumeA / 1.5, 24); // <= 3
  oled.print(">");
  oled.setCursor((122 - volumeC / 1.5), 24); // <= 3
  oled.print("<");
  oled.setCursor((80 + volumeB / 1.5), 24); // <= 3
  oled.print("]");
  oled.setCursor((75 - volumeB / 1.5), 24); // <= 3
  oled.print("[");
}

Сначала обратим внимание на две строки, в которых устанавливается текущая позиция печати на экране: oled.setCursor (0, 24) — помечены в комментариях знаком <= 1. Здесь предыдущий автор явно хотел установить позицию x = 0, y = 24 в пикселах. В заголовочном файле библиотеки SSD1306Ascii.h можно найти описание этой функции:

/**
 * @brief Set the cursor position.
 *
 * @param[in] col The column number in pixels.
 * @param[in] row the row number in eight pixel rows.
 */
void setCursor(uint8_t col, uint8_t row);

Параметр row означает не координату по вертикали в пикселах, а индекс (начиная с нуля) ряда высотой 8-пикселов. То есть, для установки курсора в начало 4-й строки нужно вызвать oled.setCursor (0, 3).

После установки курсора в начало четвертой строки печатается строка из 22 пробелов (помечено в комментариях знаком <= 2). Это делается для очистки строки, т.к. заранее неизвестно, в какой позиции окажутся символы индикаторов громкости. Далее уже вычисляется и печатается прогресс воспроизведения в процентах, а также символы > [] < индикатора громкости в нужных координатах (в этих строках также нужно исправить 24 на 3 в параметрах вызова oled.setCursor – помечены знаком <= 3).

Но функция print при печати каждого символа, даже если это пробел, считывает данные из флеш-памяти, а также передает дополнительные данные, что требует времени. В заголовочном файле библиотеки можно обнаружить функцию clear с дополнительными параметрами:

/**
 * @brief Clear a region of the display.
 *
 * @param[in] c0 Starting column.
 * @param[in] c1 Ending column.
 * @param[in] r0 Starting row;
 * @param[in] r1 Ending row;
 */
 void clear(uint8_t c0, uint8_t c1, uint8_t r0, uint8_t r1);

Из описания понятно, что эта функция очищает прямоугольную область экрана, начиная со столбца c0 и заканчивая столбцом c1, с ряда r0 до ряда r1, используя передачу нулевых байт в экран, без лишнего чтения данных. Здесь также используются именно номера рядов (строк), а не позиция по вертикали в пикселах.

В начале строки показывается прогресс в процентах, под которым не нужно каждый раз очищать область экрана. Звуковой индикатор показывается, начиная с позиции 32 по горизонтали, поэтому достаточно очищать прямоугольную область от этой позиции до конца строки:

oled.clear(32, 127, 3, 3);

Такая очистка производится гораздо быстрее, чем печать множества пробелов. В результате, отображение прогресса воспроизведения и индикаторов громкости теперь производится практически без мерцания.

Другой индикатор

Если изучить исходный код библиотеки, можно найти функцию, с помощью которой, в конечном итоге, вертикальные столбцы букв высотой в 8 пикселов (и объемом в один байт) передаются в экран для отрисовки:

/**
 * @brief Write a byte to RAM in the display controller.
 *
 * @param[in] c The data byte.
 * @note The byte will immediately be sent to the controller.
 */
 void ssd1306WriteRam(uint8_t c);

С помощью этой функции мы можем отображать что-то, отличное от букв. Например, если передать байт 0xFF, на экране будет показана вертикальная черта высотой 8 пикселов и толщиной в один пиксел. Экран вытянут по горизонтали, поэтому попробуем нарисовать индикаторы громкости, расположенные не вертикально, а горизонтально слева направо. Как мы помним, индикаторы показываются, начиная с координаты 32 по горизонтали, до конца строки мы имеем 96 пикселов на 3 индикатора. Значит, каждый из них может иметь ширину до 32 пикселов при отображении максимальной громкости. Чтобы было похоже на то, что показано в начале статьи, попробуем рисовать вертикальные черточки через одну. При этом, пусть ширина индикатора будет тем больше, чем больше громкость в канале. Индикатор для одного канала при отображении максимальной громкости будет выглядеть примерно так:

aaqgmyy93sth72li8ynwxiclubw.png

Чтобы реализовать дополнительный метод отрисовки, я добавил класс SSD1306TextVol, унаследованный от класса SSD1306AsciiAvrI2c из библиотеки. В нем я добавил переменные для хранения текущего значения громкости в каждом из каналов A/B/C, а также метод drawVol для отрисовки громкости в трех каналах. Для отрисовки каждого из них вызывается внутренний метод drawMeter, а для сброса индикатора (например, при переходе к следующему файлу) — метод vreset.

При отрисовке индикатора в каждом канале сначала проверяется, изменилась ли громкость. Если не изменилась, то ничего и не делается. А если изменилась, то индикатор не перерисовывается весь, а в нем либо стираются лишние вертикальные черточки, либо дорисовываются недостающие.

mevjpxgwvx06dzrhluozrjwf6fy.gif

Переключение режима индикатора

Я не стал убирать старый режим отрисовки индикаторов громкости, а добавил глобальную переменную nVolumeMode, значение которой определяет, какой режим сейчас включен. Теперь нужно добавить возможность переключения между режимами. Чтобы не изменять схему, я решил добавить для этого обработку одновременного нажатия кнопок 7 и 8. Отдельно нажатые кнопки переключают режимы по-старому («демо» и случайное/последовательное воспроизведение), а одновременно нажатые переключают режим индикатора.

vatb8ppbingrppr1oqtluhprk6g.jpeg

Поскольку состояние нажатия всех восьми кнопок сразу я получаю из сдвигового регистра 74HC165, я могу обрабатывать их нажатие как независимо, так и одновременно. Для каждой из кнопок у меня имеется экземпляр класса CBtn, «привязанного» к соответствующему биту байта, приходящего из сдвигового регистра. Можно попробовать обрабатывать кнопки 7 и 8 примерно так:

byte inBtn = in_165_byte(); // сюда считывается состояние кнопок из входного сдвигового регистра
CBtn btn7(6, &inBtn), btn8(7, &inBtn); // кнопки 7 и 8 «привязаны» к битам 6 и 7
bool bPressed7 = btn7.pressed(), bPressed8 = btn8.pressed();
if (bPressed7 && bPressed8) {
  // переключить режим индикатора громкости
}
else if (!bPressed7 && bPressed8) {
  // переключить режим «демо»
}
else if (bPressed7 && !bPressed8) {
  // переключить режим случайного/последовательного воспроизведения
}

Это даже как-то работает, но очень часто, помимо нажатия сразу двух кнопок, детектируется также нажатие какой-то одной из двух. Для правильной обработки как одновременного, так и независимого нажатия двух кнопок нужно проверять их нажатие с таймаутом. Это я реализовал в классе CBtn2. Работает он примерно так:

  1. Сначала нужно из него вызвать функцию CheckPress. Она определяет состояние обеих кнопок — нажата или нет по соответствующему биту в байте, полученном из сдвигового регистра.

  2. Если первая кнопка не была нажата, а её перевели в состояние «нажата» с таймаутом BTN_TIMEOUT_MS после предыдущего нажатия, бите 0 переменной m_press устанавливается в 1, а время нажатия запоминается в переменной m_nPressTime1. Аналогично для второй кнопки — если её перевели в состояние «нажата», это запоминается в бите 1 переменной m_press, а время нажатия запоминается в переменной m_nPressTime2. Здесь таймаут нужен для борьбы с дребезгом контактов (аналогично было сделано и в старом классе CBtn).

  3. Если же наоборот, ранее кнопка была нажата, а теперь нет, соответствующий бит (0 или 1) в переменной m_press сбрасывается в 0.

  4. После этого проверяется значение ещё одной переменной m_ret — если в ней не 0, а при этом обе кнопки отжаты, то переменной m_ret присваивается 0. Эта переменная нужна для того, чтобы одновременное нажатие кнопок детектировалось однократно (см. ниже).

  5. Далее нужно вызвать функцию Pressed. В ней осуществляется проверка: если в переменной m_ret нулевое значение, и при этом в переменной m_pressed — не 0, это означает, что какая-то из кнопок была нажата. Если же в m_pressed значение 0, то ни одна из кнопок не нажата, и функция возвращает 0.

  6. Если в переменной m_pressed и бит 0, и бит 1 установлены, значит, нажаты обе кнопки — функция возвращает значение 3.

  7. Если в переменной m_pressed бит 0 установлен, а бит 1 нет, но при этом от времени нажатия первой кнопки прошло времени меньше, чем BTN_BOTH_MS (второй таймаут), то функция возвращает 0, как будто ни одна из кнопок не нажата. Если времени прошло больше, то функция возвращает 1 — нажата только первая кнопка.

  8. Если в переменной m_pressed бит 1 установлен, а бит 0 нет, но при этом от времени нажатия второй кнопки прошло времени меньше, чем BTN_BOTH_MS, то функция возвращает 0, как будто ни одна из кнопок не нажата. Если времени прошло больше, то функция возвращает 2 — нажата только вторая кнопка.

  9. Во всех случаях 5 — 8 возвращаемое значение запоминается в переменной m_ret. Это позволяет избежать многократного детектирования нажатия кнопок при повторном вызове Pressed ().

Теперь обработка нажатия кнопок 7 и 8 выглядит так:

#define BTN1_MASK 0x01
#define BTN2_MASK 0x02
#define BTN12_MASK 0x03
CBtn2 btn78(6, 7, &inBtn);

void playNotes() {
  inBtn = in_165_byte();
  btn78.CheckPress();
  const byte n78 = btn78.Pressed();
  if (BTN12_MASK == n78) // нажаты обе кнопки 7 и 8 – переключить режим индикатора
    bSwitchBars = true;
  else if (BTN2_MASK == n78) { // нажата только кнопка 8 – переключить режим «демо»
    demoMode = !demoMode;
    showFile();
  }
  else if (BTN1_MASK == n78) { // нажата только кнопка 7 – переключить режим смены файлов
    randMode = !randMode;
    showFile();
  }
  // далее код не изменяется
}

В результате отдельное нажатие кнопок 7 и 8 обрабатывается не настолько быстро, как ранее, зато их одновременное нажатие обрабатывается надежно. При желании можно подобрать комфортное значение таймаута в константе BTN_BOTH_MS.

Индикатор нот

Здесь очень хотелось бы написать «индикатор спектра» и сделать что-то подобное этому. Для этого нужно было бы оцифровывать звуковой аналоговый выход чипа AY, добавить анализатор спектра на БПФ, но на это в Arduino Pro Micro уже не хватит свободной оперативной памяти.

Можно поступить по-другому. Индикатор громкости в предыдущих двух режимах показывает значения, передаваемые в регистры R8-R10, управляющие громкостью в каналах A/B/C чипа AY. Для задания высоты тона в младшие 6 регистров R0-R5 передаются 12-битные значения делителя базовой частоты (она равна тактовой частоте, делённой на 16).

lngip7eyker6lkqnn0nhfz8aeyw.png

Можно по значению делителя определять, к какой ближайшей ноте относится получаемая частота тона, и рисовать на индикаторе вертикальную черточку в соответствующей позиции по горизонтали. Высота черточки при этом пусть соответствует громкости.

При тактовой частоте 1.75 МГц самая низкая частота тона, соответствующая делителю 4095, равна 26.7 Гц, что примерно соответствует ноте «ля» субконтроктавы. Нужно построить таблицу значений делителя, которые являются пограничными между соседними нотами. Как мы помним, полная ширина индикатора составляет 96 пикселов — такое количество значений в таблице нам нужно получить.

Я написал вспомогательную программу, которая для всех значений делителей частоты от 4095 до 1 определяет ближайшую по частоте ноту и строит таблицу из 96 значений делителя, на которых ближайшая к значению делителя нота меняется. Полученные значения помещены в массив note_div в программной флеш-памяти (PROGMEM), чтобы не занимать оперативную память.

Массив делителей

const uint16_t note_div[] PROGMEM = {
4095, //26.7 Hz, A-1
3862, //28.3 Hz, A#-1
3645, //30.0 Hz, B-1
3441, //31.8 Hz, C0
3247, //33.7 Hz, C#0
3065, //35.7 Hz, D0
2893, //37.8 Hz, D#0
2731, //40.0 Hz, E0
2577, //42.4 Hz, F0
2433, //45.0 Hz, F#0
2296, //47.6 Hz, G0
2167, //50.5 Hz, G#0
2046, //53.5 Hz, A0
1931, //56.6 Hz, A#0
1822, //60.0 Hz, B0
1720, //63.6 Hz, C1
1623, //67.4 Hz, C#1
1532, //71.4 Hz, D1
1446, //75.6 Hz, D#1
1365, //80.1 Hz, E1
1288, //84.9 Hz, F1
1216, //89.9 Hz, F#1
1148, //95.3 Hz, G1
1083, //101.0 Hz, G#1
1023, //106.9 Hz, A1
965, //113.3 Hz, A#1
911, //120.1 Hz, B1
860, //127.2 Hz, C2
811, //134.9 Hz, C#2
766, //142.8 Hz, D2
723, //151.3 Hz, D#2
682, //160.4 Hz, E2
644, //169.8 Hz, F2
608, //179.9 Hz, F#2
574, //190.5 Hz, G2
541, //202.2 Hz, G#2
511, //214.0 Hz, A2
482, //226.9 Hz, A#2
455, //240.4 Hz, B2
430, //254.4 Hz, C3
405, //270.1 Hz, C#3
383, //285.6 Hz, D3
361, //303.0 Hz, D#3
341, //320.7 Hz, E3
322, //339.7 Hz, F3
304, //359.8 Hz, F#3
287, //381.1 Hz, G3
270, //405.1 Hz, G#3
255, //428.9 Hz, A3
241, //453.8 Hz, A#3
227, //481.8 Hz, B3
215, //508.7 Hz, C4
202, //541.5 Hz, C#4
191, //572.6 Hz, D4
180, //607.6 Hz, D#4
170, //643.4 Hz, E4
161, //679.3 Hz, F4
152, //719.6 Hz, F#4
143, //764.9 Hz, G4
135, //810.2 Hz, G#4
127, //861.2 Hz, A4
120, //911.5 Hz, A#4
113, //967.9 Hz, B4
107, //1022.2 Hz, C5
101, //1082.9 Hz, C#5
95, //1151.3 Hz, D5
90, //1215.3 Hz, D#5
85, //1286.8 Hz, E5
80, //1367.2 Hz, F5
76, //1439.1 Hz, F#5
71, //1540.5 Hz, G5
67, //1632.5 Hz, G#5
63, //1736.1 Hz, A5
60, //1822.9 Hz, A#5
56, //1953.1 Hz, B5
53, //2063.7 Hz, C6
50, //2187.5 Hz, C#6
47, //2327.1 Hz, D6
45, //2430.6 Hz, D#6
42, //2604.2 Hz, E6
40, //2734.4 Hz, F6
38, //2878.3 Hz, F#6
35, //3125.0 Hz, G6
33, //3314.4 Hz, G#6
31, //3528.2 Hz, A6
30, //3645.8 Hz, A#6
28, //3906.2 Hz, B6
26, //4206.7 Hz, C7
25, //4375.0 Hz, C#7
23, //4755.4 Hz, D7
22, //4971.6 Hz, D#7
21, //5208.3 Hz, E7
20, //5468.8 Hz, F7
19, //5756.6 Hz, F#7
17, //6433.8 Hz, G7
16, //6835.9 Hz, G#7
};

Для поиска индекса ближайшей ноты по значению делителя в класс SSD1306TextVol я добавил метод nearestNote. Поскольку массив note_div отсортирован, для быстрого поиска в нем ближайшего значения используется двоичный (или бинарный) поиск. Для чтения 16-битных значений делителя из массива в памяти PROGMEM вызывается pgm_read_word. Функция возвращает индекс найденной ноты в диапазоне от 0 до 95 — это и есть смещение по горизонтали от левого края индикатора.

Поскольку в каналах A/B/C могут звучать до трех нот одновременно, на индикаторе могут показываться три вертикальных черточки с разными смещениями по горизонтали. При их отрисовке проверяется, какие ноты с ненулевой громкостью были отрисованы до этого. Если среди текущих нот с ненулевой громкостью ранее отрисованных нет, они стираются с экрана записью нуля в нужную ячейку вызовом ssd1306WriteRam. Если какие-то две ноты в разных каналах оказались одинаковыми, отрисовывается максимум из их громкостей. Всё это реализовано в методе drawFreq, и результат его работы выглядит примерно так:

tgeylacxu--nq4mblrwopgb0nrk.gif

Добавляем плавности

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

Самый простой метод — хранить в памяти массив громкостей для всех 96 нот, и после того, как очередные 3 поступивших ноты отрисованы, запоминать их в массиве, а по всем остальным «пробегаться» и уменьшать их «громкость» на экране, пока она не достигнет нуля. Но это затратно и по памяти, и по лишним вычислениям. Чтобы сэкономить память и не проверять постоянно все ноты, в большинстве которых почти всегда нулевые значения, я придумал примерно такой алгоритм:

  1. Начало алгоритма такое же, как в предыдущем варианте: при поступлении трех нот в каналах A/B/C выбираются только несовпадающие, а если какие-то ноты совпадают, берется максимум громкости из двух. Это производится в методе getNotes — он возвращает количество несовпадающих нот с ненулевой громкостью, а также сами индексы нот и их громкости.

  2. Имеется очередь некоторой максимальной глубины (задается в QUEUE_DEPTH) из индексов нот m_Qnote и соответствующих им громкостей m_Qvol. Также имеются переменные m_nQueueLen с текущей длиной очереди и m_nQueuePos с позицией в массиве, в которую будет записываться очередная поступившая нота. При старте в них нули (а также после сброса функцией vreset).

  3. Для каждой из поступивших нот в каналах A/B/C проверяется, нет ли их уже в очереди — то есть, не были ли они ранее нарисованы на экране. Если какая-то из нот есть, и новая поступившая громкость больше, чем уже была, то значение громкости обновляется в очереди и на экране. Если же такой ноты в очереди нет, она добавляется в очередь вместе со значением громкости, и нота также отображается на экране.

  4. При добавлении ноты в очередь делается проверка — не достигла ли длина очереди максимального значения QUEUE_DEPTH. Если достигла, с экрана стирается самый «старый» элемент.

  5. По всей длине очереди для нот, не добавленных в очередь на текущем шаге, громкость уменьшается либо на 1, либо в 2 раза (задается с помощью #define SLOW_NOTE_FALL). Если громкость ноты не достигла нуля, она перерисовывается на экране, а если достигла, то нота с экрана стирается.

  6. Если оказалось, что в результате уменьшения громкости нот длина очереди уменьшилась, значение переменной m_nQueueLen обновляется.

Всё это реализовано в методе drawFreq2. Вызовем его вместо drawFreq и посмотрим, что получилось:

e1o2duyfpy1bopcuqreqltlw6w0.gif

На мой взгляд, это выглядит приятнее для глаз. Но на таком маленьком экране индикатор высотой 8 пикселов рассмотреть всё равно сложновато. Я добавил в коде #define FREQ_TWO_ROWS — если эту строку закомментировать, то индикатор частот показывается в одну строку, а если нет, то в две строки, т.е. высотой 16 пикселов. Во втором случае не показывается размер файла в килобайтах (не очень-то важная информация), а номер текущего файла показывается слева в третьем ряду экрана.

Ещё один индикатор

Дальше у меня возникла идея полноэкранного индикатора — разбить экран на 96 знакомест (4 ряда по 24 знака), в каждом из которых показывать громкость в виде каких-нибудь символов. Например, чем выше громкость, тем больше заполнено знакоместо. Индекс знакоместа (от 0 до 95), как и в предыдущем случае, соответствует играемой ноте.

Возможные значения громкости в канале чипа AY — от 0 до 15. Придумаем «шрифт», состоящий из 16 символов (первый символ — пробел, соответствующий нулевой громкости, здесь не показан):

gfzxymb4sj3xrhsusn4codgc0qo.png

Также допустимо значение 16, что означает включение огибающей громкости. Для него предусмотрим отдельный символ (показан справа внизу). Все символы поместим в массив из 17 байт vol_char в программной флеш-памяти (PROGMEM).

Массив символов

const byte vol_char[] PROGMEM = {
0×00, 0×00, 0×00, 0×00, 0×00,
0×00, 0×00, 0×08, 0×00, 0×00,
0×00, 0×08, 0×00, 0×08, 0×00,
0×00, 0×08, 0×08, 0×08, 0×00,
0×00, 0×08, 0×14, 0×08, 0×00,
0×00, 0×08, 0×1C, 0×08, 0×00,
0×00, 0×1C, 0×14, 0×1C, 0×00,
0×00, 0×1C, 0×1C, 0×1C, 0×00,
0×00, 0×1C, 0×36, 0×1C, 0×00,
0×00, 0×1C, 0×3E, 0×1C, 0×00,
0×08, 0×1C, 0×3E, 0×1C, 0×08,
0×08, 0×3E, 0×3E, 0×3E, 0×08,
0×1C, 0×3E, 0×3E, 0×3E, 0×1C,
0×1C, 0×3E, 0×7F, 0×3E, 0×1C,
0×3E, 0×3E, 0×7F, 0×3E, 0×3E,
0×3E, 0×7F, 0×7F, 0×7F, 0×3E,
0×55, 0xAA, 0×55, 0xAA, 0×55,
};

В процедуре drawFreq2 вызываются ещё две: drawZero (byte note) для стирания с экрана ноты с индексом note и drawVal (byte note, byte vol) для отображения ноты note с громкостью vol. В предыдущем случае каждая из них вызывает ssd1306WriteRam для передачи в экран либо нулевого значения, либо соответствующего ненулевого для отрисовки вертикальной черты в соответствии с громкостью. Я добавил во все три функции параметр bool bFullScreen — если в нем передается значение true, то индикатор отрисовывается на полный экран.

Примеры расположения знакомест для разных нот на экране

Примеры расположения знакомест для разных нот на экране

В таком случае нужно индекс ноты пересчитать в координаты по горизонтали и вертикали. Со вторым всё просто: чтобы получить номер ряда (row), нужно разделить note на 4. Для получения индекса знакоместа по горизонтали (col) нужно взять остаток отделения note на 4, а для получения координаты X умножить на 5 (ширина одного символа) и прибавить 4. Последнее нужно для центрирования индикатора: полная ширина экрана 128, а 24 знакоместа по 5 пикселов шириной занимают только 120 пикселей.

Для отрисовки каждого символа из массива vol_char нужно передать в экран 5 байт. Сначала я рисовал их по одному, устанавливая позицию на экране с помощью setCursor, а затем передавая байт символа с помощью ssd1306WriteRam. Но это оказалось слишком медленно — при перерисовке индикатора 50 раз в секунду иногда возникали ситуации, когда воспроизведение мелодии начинало «притормаживать». Если вспомнить описание функции, в комментариях к ней написано: «The byte will immediately be sent to the controller». Это означает, что каждый байт немедленно отправляется в экран. Однако, есть способ отправки нескольких байт за раз.

Изучив исходный код библиотеки, можно найти еще одну функцию ssd1306WriteRamBuf:

/**
 * @brief Write a byte to RAM in the display controller.
 *
 * @param[in] c The data byte.
 * @note The byte may be buffered until a call to ssd1306WriteCmd
 *       or ssd1306WriteRam.
 */
 void ssd1306WriteRamBuf(uint8_t c);

Она используется при печати текста в функции write самой библиотеки. Все байты символов накапливаются в буфере в памяти, не отображаясь сразу на экране. Если после этого вызвать функцию setRow или setCol, внутри будет вызвана функция ssd1306WriteCmd, которая и завершит отображение накопленных в буфере данных на экране. Этот подход я использовал при отрисовке символов из массива vol_char в функции drawVal:

#define FS_LEFT 4
void drawVal(byte note, byte vol, bool bFullScreen) {
  byte val = vol;
  if (val > 16) val = 16;
  if (bFullScreen) {
    val *= 5;
    setCursor(FS_LEFT + (note % 24) * 5, note / 24);
    for (byte i = 0; i < 5; i++) {
      ssd1306WriteRamBuf(pgm_read_byte(&vol_char[val + i]));
      m_col++;
    }
    setCol(m_col);
    return;
  }
  // …
}

Отрисовка явно ускорилась — теперь музыка при воспроизведении не «тормозит».

3vn7gjdq23nvow5em7nznhqgdka.gif

Работу плеера со всеми режимами индикатора можно увидеть в видеоролике:

Заключение

В результате мы выяснили, что с помощью буквально трех функций библиотеки SSD1306Ascii можно быстро выводить в нужные места на экране практически любую информацию — главное, правильно сформировать нужные байты для изображения и задать позицию вывода.

При сборке проекта Arduino IDE выдает следующий результат:

Скетч использует 27454 байт (95%) памяти устройства. Всего доступно 28672 байт.
Глобальные переменные используют 1694 байт (66%) динамической памяти, оставляя 866 байт для локальных переменных. Максимум: 2560 байт.

Как видно, память устройства практически заполнена. В какой-то момент при разработке пришлось размер буфера для чтения с SD-карты уменьшить с 300 до 200 байт, иначе прошивка в устройство повторно отказывалась загружаться (видимо, портится загрузчик), и его пришлось сбрасывать в исходное состояние с пустым скетчем. Можно перенести буфер чтения во внешнюю память SRAM, либо перейти на Arduino Mega, если захочется как-то дальше развивать проект (например, есть мысль поставить экран побольше). Но это, как говорится, уже другая история.

Чтобы не загромождать основной код скетча, новый класс, «шрифт» полноэкранного индикатора и таблицы констант note_div я вынес в отдельный файл aymeter.h. Ещё я добавил запоминание в EEPROM текущего режима индикатора и режима выбора файлов (случайный/последовательный), а также состояние режима «демо» — после включения устройства все режимы восстанавливаются. Итоговый код обновлён в репозитории.

Ссылки по теме

© Habrahabr.ru