MP3-плеер своими руками. Как собрать и запрограммировать гаджет у себя дома

MP3-плеер своими руками. Как собрать и запрограммировать гаджет у себя дома

Темная Сторона Интернета

Мастерить свои электронные устройства — занятие, может быть, и не очень практичное, но увлекательное и познавательное. В этой статье я расскажу, как я создал собственный музыкальный плеер. В результате получится, конечно, не iPod nano (и даже не mini), но зато мы посмотрим, как на C работать с разным железом — SD-картой, кодеком, экраном и клавиатурой.


Содержание статьи


В прошлой статье я рассказал, как собирал мобильный телефон, и некоторые приемы я позаимствую из этого проекта.

Помню, как в 2004 году у меня появился MP3-плеер и привел меня в полный восторг. Памяти, у него, правда, было всего 128 Мбайт, что по тем временам уже считалось скромным. Кроме того, плеер отличался очень дурной особенностью коверкать записанные на него файлы. Как объяснялось в инструкции, это не баг, а «фича», то есть защита от копирования.

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

Итак, от своего проекта я хотел, чтобы:

  • устройство (очевидно) воспроизводило MP3;
  • поддерживались современные карты SD;
  • в качестве файловой системы использовалась FAT;
  • качество звучания было приемлемым;
  • по возможности было невысокое энергопотребление.

Компоненты

За основу устройства я взял недорогой MP3-кодек VS1011E. На самом деле разумнее было бы выбрать более продвинутые VS1053 или VS1063 или обновленную версию VS1011 — VS1003 (у нее тактовая частота выше), стоят они все примерно одинаково.

Однако вникать в эти тонкости я не стал и остановился на первой попавшейся микросхеме. В качестве контроллера я взял STM32F103C8T6, чтобы можно было сделать макет, используя готовую плату Blue Pill, а уже потом собрать все по-серьезному. Экран я выбрал TFT, разрешение — 128 на 160 (ST7735). У меня для него уже есть написанные ранее библиотеки.

Код, как и в случае с телефоном, мы будем писать на C с использованием библиотек libopencm3 и FatFs.

Устройство будет работать просто: читать данные из файла на флешке и скармливать кодеку, а все остальное кодек сделает сам.

Макет

Прежде чем переходить к коду, есть смысл собрать макет устройства (я вообще поклонник отладки программ на реальном железе). Берем плату Blue Pill и подпаиваем к ней модуль дисплея с картодержателем. Пайка позволяет нам не сталкиваться с проблемой дребезга соединений, которая может доставить много неприятностей на этапе отладки.

Тестовый модуль для VS1011 я собрал на макетке, использовав переходник с QNF48 на DIP, схему которого я посмотрел в даташите. На самом деле так заморачиваться необязательно — можно взять готовый модуль. Но у меня его не было, а ждать не хотелось.

В итоге я все это собрал за несколько часов и был готов переходить к коду.


Шаблон будущей программы

Прежде чем писать основные функции, полезно инициализировать дисплей и клавиатуру. Про дисплей я уже говорил выше, а клавиатура четыре на четыре кнопки у меня осталась от макета телефона.

В исходнике ниже — стандартные заголовочные файлы, функции инициализации периферии, функции инициализации дисплея и клавиатуры и в конце вывод строчки Hello world.

sd.c

#include <libopencm3/stm32/rcc.h>
#include <libopencm3/stm32/gpio.h>
#include <libopencm3/stm32/spi.h>
#include "st7735_128x160.h"
#include "st_printf.h"
#include "4x4key.h"

static void key_init(void){
  // Инициализация клавиатуры четыре на четыре
}
static void spi2_init(void){
  //spi2 - vs1011
  /* Configure GPIOs:
   * 
   * SCK=PB13
   * MOSI=PB15 
   * MISO=PB14
   * 
   * for vs1011e
   * VS_CCS PB6
   * VS_RST PB9
   * VS_DREQ PB8
   * VS_DCS PB5
   * 
   */
  ... 
}

static void spi1_init(void){
  //spi1 - display
  /* Configure GPIOs:
   * 
   * SCK=PA5
   * MOSI=PA7 
   * MISO=PA6
   * 
   * for st7735
   * STCS PA1
   * DC=PA3
   * RST PA2
   * 
   * for SD card
   * SDCS PA0
   */
}

void main(){
  rcc_clock_setup_in_hse_8mhz_out_72mhz();
  spi1_init();
  spi2_init();
  st7735_init();
  st7735_clear(BLACK);
  st7735_set_printf_color(GREEN,BLACK);
  stprintf("Display is OK!\r\n");
  key_init();
  while(1) __asm__("nop");
}

Также в мейкфайле нужно добавить директорию с исходниками библиотек и сами библиотеки. Ниже — фрагмент Makefile:

...
SHARED_DIR = ./inc ./fatfs
CFILES = sd.c
CFILES += st7735_128x160.c st_printf.c
CFILES += 4x4key.c
...

Драйвер карты SD

Без драйвера работать с карточками SD никак не получится, поэтому начнем с него. Чтение и запись дисков SDHC происходит блоками по 512 байт. Наш драйвер должен уметь: записывать блок на диск, читать блок с диска и инициализировать диск.

WWW

Найти документацию по работе с картами SD по SPI — не проблема, она есть в том числе на русском.

Тем не менее тут есть несколько важных и не очень очевидных моментов, знание которых сильно ускорит написание и отладку драйвера. Во-первых, если вместе с SD на шине SPI сидят другие устройства, то инициализировать SD нужно первой, иначе она не заведется.

Во-вторых, инициализацию надо производить на достаточно низкой частоте шины (около 500 кГц), иначе SD не отвечает. Уже потом можно выкручивать частоту на максимум (у меня стоит 36 МГц, это около 4 Мбит/с).

В-третьих, карты SD бывают нескольких типов и инициализация у каждого типа своя. Я ориентировался на наиболее современные и распространенные сейчас карты SDHC, и мой вариант функции инициализации написан именно для них.

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

Обрати внимание, что перед отправкой команд инициализации по шине нужно передавать 80 тактовых импульсов при высоком уровне на контакте CS карты. Это нужно для переключения SD в режим SPI (обычный режим карты — SDIO). После этого CS опускают и начинают инициализацию, на которой я останавливаться не буду.

sdcard.c

uint8_t sd_init(){
  uint8_t n, cmd, ty, ocr[4];
  uint16_t i;
  for(n=10; n; n--) spi_xfer(SDSPI,0xff); // 80 dummy clocks
  ty = 0;

  SDCS_DOWN();
  // Enter Idle state
  send_cmd(CMD0, 0);
  // SDHC

  if (send_cmd(CMD8, 0x1AA) == 1){
    // Get trailing return value of R7 response
    for (n = 0; n < 4; n++) ocr[n] = spi_xfer(SDSPI,0xff);
    // The card can work at VDD range of 2.7-3.6V
    if (ocr[2] == 0x01 && ocr[3] == 0xAA){
      // Wait for leaving idle state (ACMD41 with HCS bit)
      i=0xfff;
      while (--i && send_cmd(ACMD41, 1UL << 30));
      if (i && send_cmd(CMD58, 0) == 0){
        // Check CCS bit in the OCR
        for (n = 0; n < 4; n++) ocr[n] = spi_xfer(SDSPI,0xff);
        ty = (ocr[0] & 0x40) ? CT_SD2 | CT_BLOCK : CT_SD2;
      }
    }
  }
  SDCS_UP();
  return ty;
}

У карточек SD есть неприятная склонность держать MISO в высоком состоянии еще несколько тактов CLK после подачи низкого уровня на CS. Это лечится передачей байта 0xFF по шине при высоком уровне на CS. Впрочем, в моем случае это не критично.

Ниже — read и write из файла sdcard.c.

uint8_t sd_read_block(uint8_t *buf, uint32_t lba){
  uint8_t result;
  uint16_t cnt=0xffff;
  SDCS_DOWN();  
  result=send_cmd(CMD17, lba); // CMD17 даташит с. 50 и 96
  if(result){SDCS_UP(); return 5;} // Выйти, если результат не 0x00

  spi_xfer(SDSPI,0xff);
  cnt=0;
  do result=spi_xfer(SDSPI,0xff); while ((result!=0xFE)&&--cnt);
  if(!cnt){SDCS_UP(); return 5;}

  for(cnt=0;cnt<512;cnt++) *buf++=spi_xfer(SDSPI,0xff); 
  // Получаем байты блока из шины в буфер
  spi_xfer(SDSPI,0xff); // Пропускаем контрольную сумму
  spi_xfer(SDSPI,0xff);
  SDCS_UP();
  spi_xfer(SDSPI,0xff);
  return 0;
}

uint8_t sd_write_block (uint8_t *buf, uint32_t lba){
  uint8_t result;
  uint16_t cnt=0xffff;
  SDCS_DOWN();
  result=send_cmd(CMD24,lba); // CMD24 даташит с. 51 и 97–98
  if(result){SDCS_UP(); return 6;} // Выйти, если результат не 0x00

  spi_xfer(SDSPI,0xff);
  spi_xfer(SDSPI,0xfe); // Начало буфера
  for (cnt=0;cnt<512;cnt++) spi_xfer(SDSPI,buf[cnt]); // Данные
  spi_xfer(SDSPI,0xff);
  spi_xfer(SDSPI,0xff);
  result=spi_xfer(SDSPI,0xff);
  // result=wait_ready();
  if((result&0x05)!=0x05){SDCS_UP(); return 6;} 
  //spi_xfer(SDSPI,0xff);
  WSPI();

  // Выйти, если результат не 0x05 (Даташит с. 111)
  // if(wait_ready()==0xFF){SDCS_UP(); return 6;}
  SDCS_UP();
  spi_xfer(SDSPI,0xff);
  return 0;
}

После того как я пару дней проковырялся с логическим анализатором, эта штука стала инициализироваться, читать и писать.

Теперь надо добавить библиотеку sdcard.c и ее заголовочный файл в проект, а в функцию main() — инициализацию SD-карты. И тут мы вспоминаем, что SPI1 у нас настроен на низкую скорость для успешной инициализации (FCPU/128 ~500 кГц), а с экраном на такой скорости работать неудобно. Поэтому добавляем функцию spi1_forsage(void), которая, по сути, повторно инициализирует SPI1, но с повышенной частотой (FCPU/2 36 МГц).

...
static void spi1_forsage(void){
  ...
}
...
void main(){
  ...
  spi1_init();
  ...
  sd_init();
  spi1_forsage();
  ...
}

SD-карта у нас теперь есть, но для работы нужна еще файловая система.


Несколько слов про отладку

Раньше я для отладки часто использовал вывод по UART, однако когда у устройства есть свой дисплей и на него направлен стандартный вывод, то не нужно даже подключать UART, достаточно пользоваться функцией stprintf(). Именно с ее помощью я анализировал вызовы discio.c.

Ниже — пример отладочных сообщений в discio.h (отладочные команды закомментированы).

DRESULT disk_write(BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count){
  //SDCS_UP();
  //stprintf("d_w(pdrv=%d,sec=%d,cnt=%d\r\n",pdrv,sector,count);
  //SDCS_DOWN();
  uint8_t ret=0;
  //if(count==1){
    //ret=sd_write_block(buff,sector);
    //stprintf("w_ret_code=%d",ret);
    //if(ret==6) return RES_ERROR;
  //} else return RES_ERROR;

  while(count){
    if(sd_write_block(buff,sector)) return RES_ERROR;
    --count;
    ++sector;
    buff+=512;
  }
  //stprintf("WresOK\r\n");
  return RES_OK;
}

В результате при каждом вызове функции disk_write() на экран выводятся передаваемые ей аргументы и возвращаемое значение sd_write_block(). Я долго не мог понять, почему запись на диск через FatFs не получается, хотя логический анализатор говорит, что все идет как надо: и прямой вызов функции sd_write_block(), и последующий вызов sd_read_block() показывали, что запись и чтение работают.

Оказалось, что функция sd_write_block() успешно выполняла запись, но не возвращала 0 и FatFs считала это ошибкой записи. Ошибку я исправил, а отладочные сообщения закомментированы.

Также в отладке крайне полезен логический анализатор Saleae Logic (точнее, его китайский клон) и одноименная программа, которая отлично работает в Linux и очень помогает при отладке протоколов.


FatFs

Чтобы прочитать файл с карточки, его нужно сначала туда как-то записать. И удобнее всего это сделать, когда на карте есть файловая система. Так что подключаем ее к компьютеру, форматируем и копируем нужные файлы.

Писать свой драйвер файловой системы ради плеера — это все же немного слишком даже для меня, но существует драйвер FatFs, написанный на C и легко портируемый на что угодно.

WWW

Скачать исходный код FatFs и ознакомиться с подробным описанием можно на все том же сайте Элма Чана.

Для того чтобы добавить FatFs в проект, надо сделать несколько вещей. Первая из них — внесение изменений в файл ffconf.h.

#define FF_CODE_PAGE  866 // 866 — кириллическая кодовая страница
#define FF_USE_LFN    1   // Поддержка длинных имен
#define FF_MAX_LFN    255 // Максимальная длина имени, памяти у нас все равно полно
#define FF_LFN_UNICODE  2 // Кодировка UTF-8
#define FF_STRF_ENCODE  3 // Кодировка UTF-8
#define FF_FS_NORTC     1 // Заглушка для функции получения реального времени

Этого достаточно. Без поддержки кириллицы нам будет грустно, а кодировку UTF-8 я выбрал, так как использую ее на десктопе и она значительно упрощает операции с файлами.

Теперь нужно отредактировать файл diskio.c. Находящиеся в нем функции связывают FatFs с драйвером карты SD, который мы обсуждали выше. Вносим необходимые изменения.

...
#include "sdcard.h"
#include "st_printf.h"
...
DSTATUS disk_initialize(BYTE pdrv){
  return 0;
}

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

DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count){
  //stprintf("d_r(pdrv=%d,sec=%d,cnt=%d\r\n",pdrv,sector,count);
  while(count){
    if (sd_read_block(buff,sector)) return RES_ERROR;
    --count;
    ++sector;
    buff+=512;
  }
  //stprintf("resOK\r\n");
  return RES_OK;
}

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

DRESULT disk_write(
  BYTE pdrv,        // Номер физического диска
  const BYTE *buff, // Данные, которые будут записаны
  LBA_t sector,     // Стартовый сектор в LBA
  UINT count        // Число секторов для записи
){
  ...
  while(count){
    if(sd_write_block(buff,sector)) return RES_ERROR;
    --count;
    ++sector;
    buff+=512;
  }
  //stprintf("WresOK\r\n");
  return RES_OK;
}

И последняя функция, которую нужно подправить, — тоже заглушка.

DRESULT disk_ioctl(
  BYTE pdrv,    // Номер физического диска
  BYTE cmd,     // Управляющий код
  void *buff    // Буфер для отправки и получения управляющего кода
){
  if(cmd == GET_SECTOR_SIZE) {
    *(WORD*)buff = 512;
    return RES_OK;
  }
  return RES_OK;
}

Теперь добавляем заголовочные файлы (ff.h) в проект, а исходный код (ff.cdiskio.c и ffunicode.c) — в Makefile. Готово! У нас теперь есть поддержка файловых систем FAT12, 16 и 32.

WWW

В блоге «Записки программиста» есть неплохая статья про работу с библиотекой FatFs.

Аудиокодек VS1011E

Аудиокодек достаточно прост в обращении. Его интерфейс (SPI) имеет два режима: режим команд (включается низким уровнем на CCS) и режим данных (включается низким уровнем на DCS). То есть со стороны это выглядит как два независимых устройства SPI на шине.

Кроме того, используется еще два вывода DREQ и RST. С RST все понятно — низкий уровень на нем вызывает перезагрузку чипа. DREQ же показывает готовность чипа принять 32 байта данных по шине.

Это семипроводное подключение чипа, которое позволяет посадить его на одну шину SPI с другими устройствами. Однако при сборке и настройке макета оказалось, что держать дисплей, карту SD и VS1011E на одной шине неудобно. Связано это в первую очередь с ограничением скорости шины VS1011. В даташите указано, что максимальная частота шины — FCPU/6, то есть в моем случае 12 * 2/6 = 4 МГц. Для дисплея и карты памяти это слишком медленно, и в результате звук будет лагать, что неприемлемо.

Конечно, можно было динамически менять скорость шины, но я решил просто перенести кодек на второй SPI, благо у моей STM их два.


INFO

О подключении и протоколе обмена данными с VS1011E есть отдельный апноут VS1011e SPI AN, там даже приведены примеры функций обмена данными для разных вариантов подключения. А в написании драйвера VS1011 нам поможет апноут VS1011E Play AN.

Итак, чтобы наш плеер заиграл, на кодек нужно слать данные пачками по 32 байта и учитывать готовность чипа принимать данные. Удобно, что он пропустит заголовки MP3, поэтому файл можно передавать целиком, что упрощает задачу.

Приступим. Вот как начинаются функции чтения и записи в управляющие регистры.

#define VS_CCS_DOWN() gpio_clear(VS_PORT, VS_CCS)
#define VS_CCS_UP() gpio_set(VS_PORT, VS_CCS)
#define DREQ() gpio_get(VS_PORT, VS_DREQ)
#define VS_W_SPI() while(SPI_SR(VS_SPI) & SPI_SR_BSY)

...
// Запись в регистр
void vs_write_sci(uint8_t addr, uint16_t data){
  while (!DREQ());        // Ждем готовности чипа принять данные
  VS_CCS_DOWN();          // Режим команд
  spi_xfer(VS_SPI, 2);    // 2 — команда записи
  spi_xfer(VS_SPI, addr); // Адрес регистра
  spi_xfer(VS_SPI, (uint8_t)(data>>8)); 
  spi_xfer(VS_SPI, (uint8_t)(data&0xff));
  VS_CCS_UP();
}

// Чтение из регистра
uint16_t vs_read_sci(uint8_t addr){
  uint16_t res;
  while (!DREQ());        // Ждем готовности чипа принять данные
  VS_CCS_DOWN();          // Режим команд
  spi_xfer(VS_SPI, 3);    // 3 — команда чтения
  spi_xfer(VS_SPI, addr); // Адрес регистра
  res=spi_xfer(VS_SPI, 0xff);
  res<<=8;
  res|=spi_xfer(VS_SPI, 0xff);
  VS_CCS_UP();
  return res;
}

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

#define VS_DCS_DOWN() gpio_clear(VS_PORT, VS_DCS)
#define VS_DCS_UP() gpio_set(VS_PORT, VS_DCS)
#define DREQ() gpio_get(VS_PORT, VS_DREQ)
#define VS_W_SPI() while(SPI_SR(VS_SPI) & SPI_SR_BSY)

...
uint8_t vs_write_sdi(const uint8_t *data, uint8_t count){
  if(count>32) return 1;
  while(!DREQ());
  VS_DCS_DOWN();
  while(count--) spi_xfer(VS_SPI, *data++);
  VS_W_SPI();
  VS_DCS_UP();
  return 0; 
}

Теперь мы можем инициализировать чип. Для этого ему надо ненадолго опустить RST, после чего установить биты SM_SDINEW и SM_RESET в регистре SCI_MODE. Напоследок нужно выставить корректное значение частоты кварца в регистре SCI_CLOCKF, для чего используется удобный макрос HZ_TO_SCI_CLOCKF(hz). Это важно для корректной скорости воспроизведения.

// Этот макрос для VS1011 автоматически установит
// удвоение частоты, если XTALI < 16 МГц
#define HZ_TO_SCI_CLOCKF(hz) ((((hz)<16000000)?0x8000:0)+((hz)+1000)/2000)
#define SCI_MODE    0x00
#define SCI_CLOCKF  0x03
#define SCI_VOL     0x0B
#define SM_RESET    (1<< 2)

uint8_t vs_init(){
  gpio_clear(VS_PORT,VS_RST); // Опускаем ненадолго ресет
  VS_CCS_UP(); // На всякий случай поднимаем CCS и DCS
  VS_DCS_UP();
  gpio_set(VS_PORT,VS_RST); // Поднимаем ресет

  vs_write_sci(SCI_MODE, SM_SDINEW|SM_RESET); // Устанавливаем 
  // режим обмена данными и делаем софтверный ресет,
  // как рекомендовано в даташите и апнотах,
  // указываем частоту кварца, так как у нас нестандартная 12 МГц
  vs_write_sci(SCI_CLOCKF, HZ_TO_SCI_CLOCKF(12000000));

  // Устанавливаем громкость на 6 дБ ниже максимума
  // Максимум громкости — 0x0000, минимум — 0xfefe,
  // старший и младший байты независимо задают
  // громкость каналов
  vs_write_sci(SCI_VOL, 0x3f3f);
  return 0;
}

Теперь можно перейти непосредственно к проигрыванию файлов.


Реализация плеера

В упомянутом выше апноуте VS1011 AN Play есть пример реализации плеера — на него-то я и ориентировался.

Рассмотрим работу функции play_file(char *name). Открываем MP3-файл функциями FatFs, читаем оттуда 512 байт в буфер и начинаем отдавать данные из буфера в кодек группами по 32 байта по мере готовности чипа их принять. Впрочем, ожидание готовности уже есть в функции vs_write_sdi(), так что тут об этом можно не задумываться.

После отправки нескольких таких пакетов можно опросить клавиатуру и интерфейс (чтобы прибавлялся прогресс-бар, например). Когда буфер опустеет, считываем еще 512 байт и повторяем снова. Если файл закончится раньше, чем заполнится буфер, — не страшно, будем отдавать по 32 байта, пока есть такая возможность, а последний пакет будет короче 32 байт. Для определения таких случаев используем макрофункцию min(a,b).

#define FILE_BUFFER_SIZE 512
#define SDI_MAX_TRANSFER_SIZE 32
#define SDI_END_FILL_BYTES 512 // Здесь может быть любое значение
#define min(a,b) (((a)<(b))?(a):(b))

uint8_t play_file(char *name){
  ...
  FIL file;
  uint8_t playBuf[512];
  uint16_t bytes_in_buffer, bytes_read, t; // Сколько байтов осталось в буфере
  uint32_t pos=0, cnt=0, fsize=0;          // Позиция в файле
  uint32_t nread;
  uint16_t sr,dt,min,sec,hdat0;
  uint8_t key,bar=0,bitrate=8;
  ...
  if(f_open(&file, name, FA_READ)) stprintf("open file error!\r\n");
  ...

  do{
    f_read(&file, playBuf, FILE_BUFFER_SIZE, &bytes_read);
    uint8_t *bufP = playBuf;
    bytes_in_buffer=bytes_read;

    do{
      t = min(SDI_MAX_TRANSFER_SIZE, bytes_in_buffer);
      vs_write_sdi(bufP, t);
      bufP += t;
      bytes_in_buffer -= t;
      pos += t;
    } while (bytes_in_buffer);

    cnt++;
    if(cnt>bitrate){
      cnt=0;
      // Здесь опрашиваем клавиатуру и рисуем интерфейс 
    }
  } while(bytes_read==FILE_BUFFER_SIZE);

  return CODE;
}

Такая вот по сути нехитрая функция, если удалить из нее рюшечки и все, связанное с интерфейсом, но в таком виде функция неудобна, поэтому реализуем интерфейс.


Интерфейс

Про вывод на дисплей 128 на 160 на плате ST7735 я уже писал в статье про телефон. Однако для этого проекта пришлось реализовать поддержку UTF-8, хоть и в урезанном виде. Поддерживаются символы латиницы и кириллицы (без буквы ё). Это упростило переделку с CP866 — я лишь немного переставил символы в таблицах, поправил поиск символа и добавил игнорирование кодов с символами 0xD0 и 0xD1 — префиксов кириллической страницы.

st7735_128x160.c

oid st7735_drawchar(unsigned char x,unsigned char y,char chr,
                    uint16_t color, uint16_t bg_color){
  ...
  // Добавлена поддержка кириллицы UTF-8
  unsigned char c=(chr<0xe0) ? chr - 0x20 : chr - 0x50;
  ...
}

void st7735_string_at(unsigned char x,unsigned char y,
                      unsigned char *chr, uint16_t color,
                      uint16_t bg_color){
  ...
  while(*chr){
#ifdef UTF8ASCII
    if(*chr==0xd0||*chr==0xd1) chr++;
#endif
  }
  ...
}

void st7735_sendchar(char ch){
#ifdef UTF8ASCII
  if(ch==0xd0||ch==0xd1) return; // Игнорировать префиксы
#endif
  ...
}

Таким образом, коды до 0x7F воспринимаются как ASCII, а прочие — как символы кириллической страницы. Решение, конечно, не универсальное, и при встрече с буквой ё мы увидим артефакты, зато это проще всего позволит обеспечить совместимость с локалью на десктопе.

Рисовать прогресс-бар тоже для простоты будем текстовыми символами.

void st7735_progress_bar(uint8_t y,uint8_t value,
                         uint16_t color,uint16_t bgcolor){
  // Выглядит это так: =====>-------
  char bar[27];
  uint8_t i, count=value*26/256;
  for(i=0;i<count;i++)bar[i]='=';
  bar[count]='>';
  for(i=count+1;i<26;i++)bar[i]='-';
  bar[26]=0;
  st7735_string_at(0,y,bar,color,bgcolor);
} 

Помимо этого, в библиотеку stprintf.c я добавил функцию вывода с форматированием на заданную строку, так проще отрисовывать интерфейс.

int stprintf_at(uint8_t x, uint8_t y,uint16_t color,
                uint16_t bgcolor, uint8_t size,
                const char *format, ...){
  va_list arg;
  char buffer[128];
  SPRINTF_buffer=buffer;
  va_start(arg, format);
  stprintf_((&putc_strg), format, arg);
  va_end(arg);
  *SPRINTF_buffer ='\0';
  if(size==1)
    st7735_string_at(x,y,buffer,color,bgcolor);
  else if(size==2)
    st7735_string_x2_at(x,y,buffer,color,bgcolor);
  else if(size==3)
    st7735_string_x2_at(x,y,buffer,color,bgcolor);
  return 0;
}

Экран разделен на три части. Первая часть — верхние 14 текстовых строк, используется для вывода сообщений (название текущего трека, ошибки и так далее). Вторая часть — 15-я строка, где расположен прогресс-бар, и последняя, 16-я строка с информацией о текущем треке.

В нижней строке выводятся следующие данные: «Кбайт прочитано / всего Кбайт, время от начала трека, режим, номер трека, всего треков». В коде это выглядит вот так:

// Глобальные переменные
uint8_t zanuda_mode=0, rand_mode=0;
char mode[3]="  ";

...
cnt++;
if(cnt>bitrate){
  //report
  cnt=0;
  dt = vs_read_sci(SCI_DECODE_TIME); // Время воспроизведения
  hdat0=vs_read_sci(SCI_HDAT0);
  bitrate=(hdat0>>12)&0xF;
  min=dt/60;
  sec=dt%60;
  bar=255*pos/fsize;
  if(zanuda_mode) st7735_progress_bar(112,bar,GREEN,BLACK);
  else st7735_progress_bar(112,bar,MAGENTA,BLACK);
  if(zanuda_mode) mode[1]='Z';
  else mode[1]=' ';
  if(rand_mode) mode[0]='R';
  else mode[0]='S';
  stprintf_at(0, 120,RED,BLACK,1, "%4d/%dK %d:%02d %s %d/%d",
              pos/1024,fsize/1024, min, sec, mode, track,
              files_count);
  ...
}

После отрисовки интерфейса идет обработчик клавиатуры, сама клавиатура собрана на сдвиговом регистре 74HC165D и работает аналогично клавиатуре телефона из прошлой статьи. Опрос регистра выполняется при помощи программной эмуляции протокола SPI. Тут уж никаких тонкостей нет.

uint8_t read_key(void){
  uint8_t data,cnt=0;
  gpio_clear(HC165_PORT, HC165_CS); // Включить тактирование
  gpio_clear(HC165_PORT, HC165_PL); // Записать значение в сдвиговый регистр
  gpio_set(HC165_PORT, HC165_PL);
  for(uint8_t i=0;i<8;i++){
    data<<=1;
    if(gpio_get(HC165_PORT, HC165_Q7)) data|=1;
    gpio_set(HC165_PORT,HC165_CLK);
    gpio_clear(HC165_PORT,HC165_CLK);
  }
  gpio_set(HC165_PORT,HC165_CS);
  data=~data;
  return data;
}

Обработчик нажатий клавиатуры считывает состояние клавиатуры из регистра и, в зависимости от полученного значения, выполняет необходимое действие. На данный момент реализованы следующие функции: тише/громче, следующий трек / предыдущий трек, пауза, рандомное воспроизведение / последовательное воспроизведение, воспроизведение текущего трека (zanuda_mode  ).

Поскольку обработчик клавиатуры находится внутри функции play_file(), а трек выбирается внутри цикла функции main(), возникает необходимость передать команду в цикл функции main(). Это можно сделать с помощью возвращаемых функцией play_file() значений:

  • 0 — следующий трек или следующий рандомный трек;
  • 2 — следующий трек;
  • 1 — предыдущий трек.

Последовательность треков

Описанная выше функция play_file() требует на вход полный путь к файлу. Оперировать с именами файлов не очень удобно, кроме того, на это может потребоваться значительный объем памяти. Поэтому разумно присвоить им какие-то номера.

Получить имена файлов в каталоге позволяет функция f_readdir(&dir, &fileInfo) библиотеки FatFs. Эта фукнция читает директорию, записывая в структуру fileInfo информацию о файле. Ее поле fname и есть имя файла. Используя ее, мы можем, например, вывести список файлов и поддиректорий в директории.

uint8_t ls(char *path){
  DIR dir;
  FILINFO fileInfo;
  if(f_opendir(&dir, path)) return 1;

  stprintf("\a%s\r\n",path);
  for(;;){
    if(f_readdir(&dir, &fileInfo)) return 2;
    if(fileInfo.fname[0] == 0) break;
    if(fileInfo.fattrib & AM_DIR) stprintf("+DIR  %s\r\n", fileInfo.fname);
    else stprintf("+ %s\r\n", fileInfo.fname);
  }
  return 0;
}

Это нужно скорее для отладки. А вот для нашей цели потребуется функция is_mp3(), которая определяет, действительно ли у файла расширение MP3. В случае успеха она возвращает ноль.

Теперь мы можем легко сосчитать MP3-файлы в директории и получить имя файла номер N (функции cnt_mp3_in_dir() и get_name_mp3()).

uint8_t ismp3(char *name){
  uint16_t len;
  len = strlen(name);
  if(!strncmp(name+len-4,".mp3",3)) return 0;
  else return 1;
}

uint16_t cnt_mp3_in_dir(char *path){
  DIR dir;
  FILINFO fileInfo;
  uint16_t count=0;
  if(f_opendir(&dir, path)) return 1;

  //stprintf("\a%s\r\n",path);
  for(;;){
    if (f_readdir(&dir, &fileInfo)) return 2;
    if(fileInfo.fname[0] == 0) break;
    if(!(fileInfo.fattrib & AM_DIR)) 
    if(!ismp3(fileInfo.fname)) count++;
  }
  return count;
}

uint8_t get_name_mp3(char *path, uint16_t n, char *name){
  DIR dir;
  FILINFO fileInfo;
  uint16_t count=0;
  if(f_opendir(&dir, path)) return 1;

  //stprintf("\a%s\r\n",path);
  while(count<n){
    if(f_readdir(&dir, &fileInfo)) return 2;
    if(fileInfo.fname[0] == 0) return 3;
    if(!(fileInfo.fattrib & AM_DIR))
      if(!ismp3(fileInfo.fname))
        count++;
  }
  strcpy(name,fileInfo.fname);
  return 0; 
}

Писать отдельную функцию для проигрывания файла по номеру неразумно, лучше сделать обертку над функцией play_file().

uint8_t play_mp3_n(char *path, uint16_t n){
  char fname[257];
  uint8_t code=0;
  get_name_mp3("/",n,fname);
  code=play_file(fname);
  return code;
}

Отдельного упоминания заслуживает случайное воспроизведение. Получение псевдослучайных чисел — это вообще особая тема, но к таким вещам, как MP3-плеер, она отношения не имеет. Мы же просто воспользуемся функцией rand() из библиотеки stdlib.h, но ей для получения последовательности случайных чисел нужно передать одно случайное число. Для одинаковых сидов последовательность всегда будет одинаковой.

Где на микроконтроллере взять случайное число? Можно взять значение из счетчика часов реального времени, а можно считать сигнал из АЦП. Первый вариант, на мой взгляд, лучше, но часы в этом проекте пока не реализованы. Поэтому остается читать сигнал из АЦП.

Последовательность действий такая: включаем АЦП в самом быстром и неточном режиме и измеряем потенциал на незадействованной ножке контроллера. К ней лучше подсоединить проводник небольшой длины, чтобы он работал антенной и ловил наводки. Но это не обязательно, мы ведь всего лишь перемешиваем треки в плеере.

Затем выключаем АЦП за ненадобностью, а полученное значение передаем в функцию srand(), которая настроит ГПСЧ.

static uint16_t get_random(void){
  // Получение случайного числа из АЦП
  uint8_t channel=4;
  uint16_t adc=0;
  rcc_periph_clock_enable(RCC_GPIOA);
  /* Configure GPIOs:
   * sensor PA1
   */
  gpio_set_mode(GPIOA, GPIO_MODE_INPUT, GPIO_CNF_INPUT_ANALOG, GPIO4);
  rcc_periph_clock_enable(RCC_ADC1);
  rcc_set_adcpre(RCC_CFGR_ADCPRE_PCLK2_DIV2);
  /* Убеждаемся, что АЦП не работает во время настройки */
  adc_power_off(ADC1);
  /* Настраиваем */
  adc_disable_scan_mode(ADC1);
  adc_set_single_conversion_mode(ADC1);
  adc_disable_external_trigger_regular(ADC1);
  adc_set_right_aligned(ADC1);
  /* Мы будем читать датчик температуры, поэтому включаем его */
  //adc_enable_temperature_sensor();
  adc_set_sample_time_on_all_channels(ADC1, ADC_SMPR_SMP_1DOT5CYC);
  adc_power_on(ADC1);
  /* Ждем запуска АЦП */
  for(uint32_t i = 0; i < 800000; i++) __asm__("nop");
  //adc_reset_calibration(ADC1);
  //adc_calibrate(ADC1);

  adc_set_regular_sequence(ADC1, 1, &channel);
  adc_start_conversion_direct(ADC1);
  /* Ждем окончания преобразования */
  while(!(ADC_SR(ADC1) & ADC_SR_EOC));
  adc=ADC_DR(ADC1);
  adc_power_off(ADC1);
  rcc_periph_clock_disable(RCC_ADC1);
  return adc;
}

main(){
  ...
  init_random=get_random();
  stprintf("ADC random is %d\r\n",init_random);
  srand(init_random); // Инициализация ГПСЧ
  ...
}

Законченное устройство

Когда все или почти все, что хотелось, протестировано на макете, можно собрать прототип. Для этого были изготовлены две печатные платы.

В принципе, все детали можно было уместить и на одной двухсторонней плате, но я поленился.

Далее был собран дисплейный модуль и испытан на макете.

Затем была распаяна плата кодека и соединена с платой дисплея.

И в завершение это все было помещено в корпус из оргстекла, который получился великоват.

Звучит плеер вполне прилично, но, к несчастью, потребляет многовато (порядка 60 мА). Впрочем, это не так страшно.

Источник: xakep.ru

привет, я Марк - мой личный блог, будни злого кардера-алкоголика. Спасибо за внимание!