Урок 6.1. Динамик. MP3 плеер



  • Цель урока

    Привет! Сегодня мы научимся воспроизводить аудио файлы формата MP3 по средствам встроенного ЦАП. Напишем несложный плеер (рис. 1).

    Рисунок 1. Экран приветствия

    Краткая теория

    Цифро-аналоговый преобразователь (ЦАП) – устройство для преобразования цифрового (обычно двоичного) кода в аналоговый сигнал (ток, напряжение или заряд). Цифро-аналоговые преобразователи являются интерфейсом между дискретным цифровым миром и аналоговыми сигналами. Сигнал с ЦАП без интерполяции на фоне идеального сигнала приведён на рисунке 2.

    Рисунок 2

    В M5STACK выходы ЦАП соответствуют 25 и 26 контактам (рис. 2.1).

    Обратите внимание на то, что к 25 контакту параллельно подключен встроенный динамик. 26 контакт свободен и может быть использован как линейный выход. По-умолчанию оба контакта задействованы, для конфигурации используйте AudioOutputI2S

    Рисунок 2.1

    MP3 – кодек третьего уровня, разработанный командой MPEG, формат файла для хранения аудиоинформации. MP3 является одним из самых распространённых и популярных форматов цифрового кодирования звуковой информации. Он широко используется в файлообменных сетях для оценочного скачивания музыкальных произведений. Формат может проигрываться практически во всех популярных операционных системах, на большинстве портативных аудиоплееров, а также поддерживается всеми современными моделями музыкальных центров и DVD-плееров.

    Более подробная информация на Wiki: https://en.wikipedia.org/wiki/MP3

    Разработкой библиотек под ESP32 и ESP8266 для работы с популярными аудиоформатами, в том числе и MP3, занимается пользователь GitHub earlephilhower https://github.com/earlephilhower, ссылка на саму библиотеку https://github.com/earlephilhower/ESP8266Audio

    Перечень компонентов для урока

    • M5STACK;
    • кабель USB-C.

    Начнём!

    Шаг 1. Нарисуем эскиз

    Нарисуем эскиз нашего будущего плеера (рис. 3). В нижней части экрана будет отображаться название предыдущего, текущего и следующего трека. Название текущего трека сделаем черным цветом стандартным шрифтом размера 3. Боковые треки будут написаны серым цветом стандартным шрифтом размера 2. По центру экрана будет располагаться временная линия серого цвета, по которой будет двигаться красная метка. В правый верхний угол добавим четыре серых столба, имитирующие звуковой спектр. В левом углу будет располагаться обложка альбома.

    Рисунок 3. Эскиз проекта

    Шаг 2. Логотип

    Воспользуемся стандартным графическим редактором для того, чтобы сделать логотип (рис. 3.1), который будет отображаться на экране при включении устройства.

    Рисунок 3.1. Логотип плеера

    Не забудьте конвертировать и подключить:

    extern unsigned char logo[];
    

    Вызовем отрисовку нашего логотипа из функции drawGUI() из setup():

    void drawGUI() {
      M5.Lcd.drawBitmap(0, 0, 320, 150, (uint16_t *)logo);
      M5.Lcd.setTextColor(0x7bef);
      drawTrackList();
      while (true)
      {
        if (m5.BtnB.wasPressed())
        {
          M5.Lcd.fillRect(0, 0, 320, 240, 0x0000);
          M5.Lcd.fillRoundRect(0, 0, 320, 240, 7, 0xffff); 
          drawTrackList();
          break; 
        }
        m5.update();
      }
    }
    

    Обратите внимание, на то, что использую конструкции для работы SD-картой - об этом я рассказывал в 5 уроке.

    void setup(){
      M5.begin();
      WiFi.mode(WIFI_OFF);
      M5.Lcd.fillRoundRect(0, 0, 320, 240, 7, 0xffff);
      M5.Lcd.setTextColor(0x7bef);
      M5.Lcd.setTextSize(2);
      M5.Lcd.drawBitmap(30, 75, 59, 59, (uint16_t *)timer_logo);
      M5.Lcd.setCursor(110, 90);
      M5.Lcd.print("STARTING...");
      M5.Lcd.setCursor(110, 110);
      M5.Lcd.print("WAIT A MOMENT");
      if (!SD.begin())
      {
        M5.Lcd.fillRoundRect(0, 0, 320, 240, 7, 0xffff);
        M5.Lcd.drawBitmap(50, 70, 62, 115, (uint16_t *)insertsd_logo);
        M5.Lcd.setCursor(130, 70);
        M5.Lcd.print("INSERT");
        M5.Lcd.setCursor(130, 90);
        M5.Lcd.print("THE TF-CARD");
        M5.Lcd.setCursor(130, 110);
        M5.Lcd.print("AND TAP");
        M5.Lcd.setCursor(130, 130);
        M5.Lcd.setTextColor(0xe8e4);
        M5.Lcd.print("POWER");
        M5.Lcd.setTextColor(0x7bef);
        M5.Lcd.print(" BUTTON"); 
        while(true);
      }
      if (!createTrackList("/"))
      {
        M5.Lcd.fillRoundRect(0, 0, 320, 240, 7, 0xffff);
        M5.Lcd.drawBitmap(30, 75, 59, 59, (uint16_t *)error_logo);
        M5.Lcd.setCursor(110, 70);
        M5.Lcd.print("ADD MP3 FILES");
        M5.Lcd.setCursor(110, 90);
        M5.Lcd.print("TO THE TF-CARD");
        M5.Lcd.setCursor(110, 110);
        M5.Lcd.print("AND TAP");
        M5.Lcd.setCursor(110, 130);
        M5.Lcd.setTextColor(0xe8e4);
        M5.Lcd.print("POWER");
        M5.Lcd.setTextColor(0x7bef);
        M5.Lcd.print(" BUTTON");
        while(true);
      }
      drawGUI();
      play('m');
    }
    

    Шаг 3. Добавляем библиотеки

    Для того, чтобы использовать сторонние библиотеки необходимо их добавить. Скачать можете в соответствующем пункте в разделе Скачать -> Библиотеки. Для того, чтобы добавить библиотеку необходимо запустить Arduino IDE выбрать раздел меню Sketch -> Include Library -> Add .ZIP Library... (рис. 4, 4.1).

    Рисунок 4. Добавление библиотеки в Arduino IDE

    Рисунок 4.1. Необходимые библиотеки в ZIP-архивах

    После того, как библиотеки добавлены их можно подключать к новому проекту:

    #include <M5Stack.h>
    #include <WiFi.h>
    #include "AudioFileSourceSD.h"
    #include "AudioFileSourceID3.h"
    #include "AudioGeneratorMP3.h"
    #include "AudioOutputI2S.h"
    

    Шаг 4. Используем движок. Дело за MP3

    Не спрашивайте "для чего это надо?" - дальше посмотрим вместе:

    AudioGeneratorMP3 *mp3;
    AudioFileSourceSD *file;
    AudioOutputI2S *out;
    AudioFileSourceID3 *id3; 
    bool playing = true;
    

    Шаг 5. Сделаем плейлист

    Сделаем структуру, содержащую в себе поля: label (путь к mp3-файлу, он же название трека), timePos - время (участок памяти) на котором приостановлен трек, указатели на соседние треки left и right:

    struct Track
    {
      String label;
      int timePos;
      Track *left;
      Track *right;
    };
    

    Объявим динамический список, собственно, наш плейлист:

    Track *trackList;
    

    Для формирования плейлиста напишем несложную функцию, которая принимает в качестве аргумента путь, по которому расположены MP3-файлы. Если ничего найдено не будет, то функция вернёт false:

    bool createTrackList(String dir) {
      int i = 0;
      File root = SD.open(strToChar(dir));
      if (root)
      {
        while (true)
        {
          File entry =  root.openNextFile();
          if (!entry) break;
          if (!entry.isDirectory())
          {
            String ext = parseString(cntChar(entry.name(), '.'), '.', entry.name());
            if (ext == "mp3") 
            {
              i++;
              Track *tmp = new Track;
              tmp->label = entry.name();
              tmp->timePos = 0;
              tmp->right = tmp; 
              if (trackList == NULL)
              {
                tmp->left = tmp;
                trackList = tmp;
              }
              else
              {
                tmp->left = trackList;
                trackList->right = tmp;
                trackList = trackList->right;
              }
            }
          }
          entry.close();
        }
        if (i > 1)
        {
          do
          {
            trackList = trackList->left;
          } while(trackList != trackList->left);
        }
        root.close();
      }
      if (i > 0)
        return true;
      return false;
    }
    

    Обратите внимание на то, что крайний левый и крайний правый трек замкнуты на себя, а не на NULL

    Шаг 6. Отрисовка треклиста - легко!

    String labelCut(int from, int to, String str) {    
      String tmp = str.substring(1, posChar(str, '.'));
      if (str.length() > to)
        tmp = tmp.substring(from, to);
      return tmp;
    }
    
    void drawTrackList() {
      M5.Lcd.fillRect(0, 130, 320, 75, 0xffff);
    
      if (trackList->left != trackList)
      {
        M5.Lcd.setTextSize(2);
        M5.Lcd.setTextColor(0x7bef);
        M5.Lcd.setCursor(10, 130);
        M5.Lcd.print(labelCut(0, 22, (trackList->left)->label));
      }
     
      M5.Lcd.setTextSize(3); 
      M5.Lcd.setTextColor(0x0000);
      M5.Lcd.setCursor(10, 155);
      M5.Lcd.print(labelCut(0, 16, (trackList->label)));
    
      if (trackList->right != trackList)
      {
        M5.Lcd.setTextSize(2);
        M5.Lcd.setTextColor(0x7bef);
        M5.Lcd.setCursor(10, 185);
        M5.Lcd.print(labelCut(0, 22, (trackList->right)->label));
      }
    }
    

    Шаг 7. Временная линия

    unsigned long drawTimeline_previousMillis = 0;
    void drawTimeline() {
      currentMillis = millis();
      if (currentMillis - drawTimeline_previousMillis > 250)
      {
        int x = 30;
        int y = 110;
        int width = 260;
        int heightLine = 2;
        int heightMark = 20;
        int widthMark = 2;
        int yClear = y - (heightMark / 2);
        int wClear = width + (widthMark / 2);
        
        drawTimeline_previousMillis = currentMillis;
        M5.Lcd.fillRect(x, yClear, wClear, heightMark, 0xffff);
        M5.Lcd.fillRect(x, y, width, heightLine, 0x7bef);
        int size_ = id3->getSize();
        int pos_ = id3->getPos();
        int xPos = x + ((pos_ * (width - (widthMark / 2))) / size_);
        M5.Lcd.fillRect(xPos, yClear, widthMark, heightMark, 0xe8e4);
      }
    }
    

    Шаг 8. Эмулятор спектра

    unsigned long genSpectrum_previousMillis = 0;
    void genSpectrum() {
      currentMillis = millis();
      if (currentMillis - genSpectrum_previousMillis > 100)
      {
        genSpectrum_previousMillis = currentMillis;
        drawSpectrum(random(0,101), random(0,101), random(0,101), random(0,101));
      }
    }
    
    void drawSpectrum(int a, int b, int c, int d) { // %
      int x = 195;
      int y = 30;
      int padding = 10;
      int height = 30;
      int width = 15;
    
      int aH = ((a * height) / 100);
      int aY = y + (height - aH);
      M5.Lcd.fillRect(x, y, width, height, 0xffff);
      M5.Lcd.fillRect(x, aY, width, aH, 0x7bef); //0xe8e4
      
      int bH = ((b * height) / 100);
      int bY = y + (height - bH);
      int bX = x + width + padding;
      M5.Lcd.fillRect(bX, y, width, height, 0xffff);
      M5.Lcd.fillRect(bX, bY, width, bH, 0x7bef); //0xff80
    
      int cH = ((c * height) / 100);
      int cY = y + (height - cH);
      int cX = bX + width + padding;
      M5.Lcd.fillRect(cX, y, width, height, 0xffff);
      M5.Lcd.fillRect(cX, cY, width, cH, 0x7bef);//0x2589
    
      int dH = ((d * height) / 100);
      int dY = y + (height - dH);
      int dX = cX + width + padding;;
      M5.Lcd.fillRect(dX, y, width, height, 0xffff);
      M5.Lcd.fillRect(dX, dY, width, dH, 0x7bef);//0x051d
    }
    

    Шаг 9. Работа с движком MP3

    Для того, чтобы воспроизвести MP3 из плейлиста напишем функцию Play(char), которая в качестве аргумента принимает указание. Если аргумент принимает значение 'l', то указатель в динамическом списке будет смещён влево и начнётся воспроизведение трека, соответственно, слева. Аналогично для трека справа. Если аргумент примет значение 'm', то это означает воспроизвести тишину. Если передать любой другой аргумент, то это будет означать 't' (this) - играть текущий, т.е. тот, на который указывает указатель.

    bool play(char dir) {
      switch (dir)
      {
        case 'r':
          if (trackList == trackList->right) return false;
          trackList->timePos = 0;
          trackList = trackList->right;       
        break;
        case 'l':
        if (trackList == trackList->left) return false;
          trackList->timePos = 0;
          trackList = trackList->left;
        break;
        case 'm': // mute
          delete file;
          delete out;
          delete mp3;
          mp3 = NULL;
          file = NULL;
          out = NULL;
          file = new AudioFileSourceSD("/");
          id3 = new AudioFileSourceID3(file);
          out = new AudioOutputI2S(0, 1);
          out->SetOutputModeMono(true);
          mp3 = new AudioGeneratorMP3();
          mp3->begin(id3, out);
          playing = false;
        return true;
        default:
          if (playing)
          {
            trackList->timePos = id3->getPos();
            play('m');
            return true;
          }
        break;
      }
      drawCover();
      mp3->stop();
      delete file;
      delete out;
      delete mp3;
      mp3 = NULL;
      file = NULL;
      out = NULL;
      file = new AudioFileSourceSD(strToChar(trackList->label));
      id3 = new AudioFileSourceID3(file);
      id3->seek(trackList->timePos, 1);
      out = new AudioOutputI2S(0, 1);
      out->SetOutputModeMono(true);
      mp3 = new AudioGeneratorMP3();
      mp3->begin(id3, out);
      playing = true;
      return true;
    }
    

    Шаг 10. Loop

    void loop(){ 
      if (m5.BtnA.wasPressed())
      {
        play('l');
        drawTrackList();
      }
    
      if (m5.BtnB.wasPressed())
      {
        play('t');
      }
    
      if (m5.BtnC.wasPressed())
      {
        play('r');
        drawTrackList();
      }
    

    конструкция приведенная ниже мало подвергается каким-либо изменениям. Мы добавим в неё воспроизведение по порядку play('r'), контроль паузы playing, отрисову динамических данных genSpectrum() и drawTimeline():

      if (playing)
      {
        if (mp3->isRunning())
        {
          if (!mp3->loop())
          {
            mp3->stop();
            playing = false;
            play('r');
            drawTrackList();
          }
        }
        else
        {
          delay(1000);
        }
        genSpectrum();
        drawTimeline();
      }
      m5.update();
    }
    

    Шаг 11. ЗАПУСК!

    Всё работает и выглядит достаточно хорошо, единственное - треск при переключении песен. Причину установить пока не удалось.

    Рисунок 5. Экран воспроизведения

    Домашнее задание

    • Задание 1 уровня сложности: добавьте контроль существования JPG-файлов обложек альбомов на TF-карте. Если обложка альбома отсутствует, то вместо неё добавьте черновик (скачать можете в разделе Скачать -> Изображения -> HomeWork1);

    • Задание 2 уровня сложности: добавьте бегущую строку для длинных названий треков.

    • Задание 3 уровня сложности: при помощи ID3 извлеките из MP3-файла обложку альбома, чтобы не использовать внешний JPG-файлы;

    • Задание 4 уровня сложности: вместо эмуляции спектра реализуйте быстрое преобразование Фурье. Первый столб 100 Гц, второй - 600 Гц, третий - 1500 Гц, четвертый 3000 Гц.

    Скачать