Help us understand the problem. What is going on with this article?

ESP32でGoogle Play Musicを再生する

More than 1 year has passed since last update.

はじめに

ESP32でmp3をhttp経由で再生できるライブラリの存在と、スピーカーで音声出力が可能とわかったので
それならばGoogle Play Musicを再生できるポータブルプレイヤー作れるのではと思ったのがきっかけになります。
小型の再生機器はどれもローカルのメモリやSDカードからの再生で、曲を追加するのにPCに持ってきて追加が必要など、曲の更新に重い腰が動かず放置しがちでした。
少し値段をだせばDLNA連携プレイヤーなどありますが、これらは曲の管理やサーバーなどは使い勝手のいいイメージはありません。
GooglePlayMusicはクラウドで曲の管理ができ、プレイリストも自由に作れるため、これを再生できるプレイヤーが作れれば最新の曲を聞く機会が増えそうです。

※電子工作とかマイコンとかArduino触ったことないド素人がESP32安くて高機能ですげーーっという勢いで数ヶ月適当に勉強したぐらいのレベルなので、いろいろ怪しい所があるかもしれません。。
そんな人ででもこのぐらいできちゃうっていうのがすごい時代だなーと思いますし、とにかくESP32すごい。

処理の流れ

キャプチャ.PNG

ESP32 → httpにてGMusicProxy(自宅内サーバーやラズパイなど)へアクセス → Google Play Music上のプレイリスト一覧urlと曲urlを取得 → 曲url(mp3url)をライブラリ(ESP8266Audio)にて再生 → スピーカーへ出力

※GMusicProxyは家庭内サーバーと記載してますが、認証さえ工夫すればVPSなどでも問題ないと思います。

再生してる様子

https://twitter.com/odetarou/status/993152776178884609


上記はDACにてヘッドフォンジャック出力して外部スピーカー(アンプ内蔵)につないでいますが、
下記のようにD級アンプ&スピーカーで電源不要スピーカーにもできます。3Wスピーカーでそれなりに出力でます。


操作方法

ボタン2つで、次の曲、前の曲。
ボタン長押しで次のプレイリスト、前のプレイリストへ。
ひとまずは簡易に上記構成ですが、それぞれボタン用意などが使いやすいかもしれません。
音量調整もAPIはあるため簡単に実装できると思います。

必要なパーツ

パーツ 価格(¥) 備考
ESP32開発ボード 860 買ってないですがこちらのほうが安いかも
PCM5102A DAC Decoder 411 DA変換してヘッドフォン出力端子にしてくれます。24bit 192KHzらしいです
ブレッドボード 280 他に安いのありますがこれは定評らしい
タクトスイッチ 300 10個入りで余ります
抵抗 260 600個入りで余ります
ジャンパ線 オス-オス 90 40本入りで余ります
ジャンパ線 オス-メス 95 40本入りで余ります
ジャンパ線 メス-メス 95 40本入りで余ります

ヘッドフォン出力じゃなく、D級アンプ&スピーカーで電源不要スピーカーにする場合
DACの代わりに下記を購入

パーツ 価格(¥) 備考
3W 4Ω ステレオスピーカーセット 1208 スピーカーは2つだが1つのみ使用。もしくは下記アンプを2個使用すればステレオも可能のようです
MAX98357A搭載 I2S 3W D級アンプボード 981
セラミックコンデンサー 22pF50V 5

はんだ付けは必要?

ブレッドボードを使えば大抵は差し込むだけではんだ付け不要ですが、一部パーツはpinヘッダをハンダで付けるタイプがあります。今回はESP32とDAC、D級アンプがそうでした。秋月などで売られてるESP32はpinヘッダが付いているためハンダは不要です。
ハンダはどうしてもめんどいとかでしたらpinヘッダついているのを探して買うか、
スルホール用テストワイヤを買うとはんだ付け不要で試作にも使えて便利です。
あくまで一時的にで結局ははんだごてセット用意するのがおすすめです。

GMusicProxy

http://gmusicproxy.net

Google Play Musicには公式APIがないため、GMusicProxyというwebアプリを使ってmp3のurlを取得できるようにします。

インストール手順

動作確認済み環境
ubuntu16.04.3(doc見る限りラズパイでも動くようです)
Python 2.7.12(ubuntu14でアップデートできるpython verでは動かなかったためubuntu16にこれを機にアップデートしました)

インストールコマンド

git clone https://github.com/diraimondo/gmusicproxy.git
cd gmusicproxy/
sudo apt-get install build-essential python2.7-dev
sudo apt-get install libffi-dev libssl-dev
sudo apt-get install python-pip
sudo pip install -r requirements.txt

# うちの環境だけで不要かも
sudo pip install --upgrade protobuf

config設定

GMusicProxy --list-devices
を実行し、表示されるスマホ用device_idをメモしてconfigに記載する。複数台ある場合はどれか1つ。

vi ~/.config/gmusicproxy.cfg
---
email = xxxxx@gmail.com
password = xxxxx
device-id = xxxxx

※googleで2段階認証をしている場合は下記を参考にアプリパスワードを生成してそちらをpassword欄に記載する必要があります。
https://www.ajaxtower.jp/googleaccount/2step-verify/index6.html

サーバー起動コマンド

GMusicProxy

API利用

サーバーが起動したのでAPIを利用してみます

curl http://192.168.xxx.xxx:9999/get_all_playlists

下記のようにプレイリスト一覧のm3uファイルが出力されます。

#EXTINF:-1,テクノポップ
http://192.168.xxx.xxx:9999/get_playlist?id=xxx
#EXTINF:-1,ジブリ
http://192.168.xxx.xxx:9999/get_playlist?id=xxx

1つピックアップしてプレイリストの曲一覧を見てみます

curl http://192.168.xxx.xxx:9999/get_playlist?id=xxx

#EXTM3U
#EXTINF:300,HMOとかの中の人。 - 東風
http://192.168.xxx.xxx:9999/get_song?id=xxx
#EXTINF:259,HMOとかの中の人。 - ライディーン
http://192.168.xxx.xxx:9999/get_song?id=xxx
#EXTINF:119,HMOとかの中の人。 - テクノポリス
http://192.168.xxx.xxx:9999/get_song?id=xxx

曲位一覧がでたので1つurlをピックアップしてブラウザに貼り付けてみます。
http://192.168.xxx.xxx:9999/get_song?id=xxx
mp3ファイルが取得でき、ブラウザ上で再生できます!

その他のAPIはAPI一覧を見ていじってみましょう。曲検索などもありますが有料プランの曲がhitしたりで無料プランで自分のライブラリのみの検索はできなさそうなきがしています(謎)
http://gmusicproxy.net/#gmusicproxy-google-play-music-proxy-usage-url-based-interface

その他memo

GMusicProxyの内部の仕組みとしては下記のPython用のGoogle Play Music APIを利用しているようです。
https://github.com/simon-weber/gmusicapi

上記configやgmusicapiライブラリのドキュメントを見ると推測できるのですがスマホのGoogle Play Musicアプリ内で使われているAPIを利用していると思われます。

ESP32

mp3の再生にはESP8266Audioというライブラリを使用します。
サンプルを見るとわかりますが、使い方が簡単でわかりやすいです。
https://github.com/earlephilhower/ESP8266Audio/blob/master/examples/StreamMP3FromHTTP/StreamMP3FromHTTP.ino

動作確認済み環境
arduino-esp32
commit 3a4ec66d41615cbb1c3e830cb6e761cdc4cca9d3
Date: Fri Mar 9 07:16:18 2018 -0300
最初は2017/12月ぐらいの古いverで試したら不安定でしたので、確認したverを記載しておきます。

配線図

DACでヘッドフォンや外部スピーカーの場合

配線図_ブレッドボード.png

※配線はラズパイ用に利用していた方のページを参考にしました。

DACのピンはESP32と下記のように配線します。
LCK(LRCK)→ESP32のGPIO25(DAC1)
BCK(BCLK)→ESP32のGPIO26(DAC2)
DIN(DATA)→ESP32のGPIO22
VCC→ESP32の5V
FMT、SCL、DMP、FLT→10kΩを間にいれてESP32のGNDへ
GND→ESP32のGND
XMT→10kΩを間にいれてDAC上の3.3Vへ(これのみDAC間で接続)

D級アンプ&スピーカーの場合

配線は下記ページが参考になりました。
https://qiita.com/Tw_Mhage/items/7473d694a31ca6bd9592

ソース

Arudiono IDE用になります。
wifiのssidなどのconfig系の部分は適時書き換えて下さい。

Arudiono IDEのESP32向け環境構築はこちらのページが参考になります。

下記2つのライブラリは別途インストールしてください。
https://github.com/JChristensen/Button
https://github.com/earlephilhower/ESP8266Audio/
ライブラリのインストールはこちらのページが参考になります。
gitコマンドで入れるのがversionの更新も楽でおすすめです。

#include <WiFi.h>
#include <HTTPClient.h>
#include <iostream>
#include <sstream>
#include <vector>
#include <time.h>

// 下記の2つのurlのライブラリは別途インストールする

// https://github.com/earlephilhower/ESP8266Audio/
#include "AudioFileSourceHTTPStream.h"
#include "AudioFileSourceBuffer.h"
#include "AudioFileSourceID3.h"
#include "AudioGeneratorMP3.h"
#include "AudioGeneratorMP3a.h"
#include "AudioOutputI2S.h"

#include <Button.h>        //https://github.com/JChristensen/Button

// config系
#define WIFI_SSID "xxx"
#define WIFI_PASS "xxx"
#define PLAY_LISTS_URL "http://192.168.xxx.xxx:9999/get_all_playlists"
#define OUTPUT_GAIN 50 // max 100

#define BUTTON_PIN 0       //Connect a tactile button switch (or something similar)
#define PULLUP true        //To keep things simple, we use the Arduino's internal pullup resistor.
#define INVERT true        //Since the pullup resistor will keep the pin high unless the
//switch is closed, this is negative logic, i.e. a high state
//means the button is NOT pressed. (Assuming a normally open switch.)
#define DEBOUNCE_MS 20     //A debounce time of 20 milliseconds usually works well for tactile button switches.
Button myBtn(BUTTON_PIN, PULLUP, INVERT, DEBOUNCE_MS);    //Declare the button
Button myBtn2(16, PULLUP, INVERT, DEBOUNCE_MS);    //Declare the button
Button myBtn3(17, PULLUP, INVERT, DEBOUNCE_MS);    //Declare the button

// C++でgetter/setterをマクロで宣言・定義してしまう
// https://project-flora.net/2015/06/29/post-686/
#define DEFINE_SYNTHESIZED_PROPERTY_READWRITE(type, varName, funcName)\
  protected:\
  type varName;\
  public:\
  virtual type get ## funcName() {return varName;}\
  virtual void set ## funcName(type value) {varName = value;}

// 文字列にstarts_withがないため定義
// http://marycore.jp/prog/cpp/starts-with-prefix-search/#starts_with%E9%96%A2%E6%95%B0
bool starts_with(const std::string& s, const std::string& prefix) {
  auto size = prefix.size();
  if (s.size() < size) return false;
  return std::equal(std::begin(prefix), std::end(prefix), std::begin(s));
}

// 曲データクラス
struct Music
{
  DEFINE_SYNTHESIZED_PROPERTY_READWRITE(String, _name, Name);
  DEFINE_SYNTHESIZED_PROPERTY_READWRITE(String, _url, Url);
};

// 曲リストクラス(playlist以外に、playlistのlistの保持にも使用)
class MusicList
{
  std::vector<Music> list;
  int cursol = -1;

  public:
    void add(Music music) {
      list.push_back(music);
    }

    Music current() {
      return list[cursol];
    }

    void next() {
      cursol++;
      if (cursol >= list.size()) {
        cursol = 0;
      }
    }
    void prev() {
      cursol--;
      if (cursol < 0) {
        cursol = list.size() - 1;
      }
    }

    void parseUrl(String url) {
      cursol = -1;
      list.clear();
      list.shrink_to_fit();

      HTTPClient http;
      http.begin(url);

      int httpCode = http.GET();

      String m3uStr;
      // httpCode will be negative on error
      if (httpCode > 0) {
        // HTTP header has been send and Server response header has been handled
        Serial.printf("[HTTP] GET... code: %d\n", httpCode);

        // file found at server
        if (httpCode == HTTP_CODE_OK) {
          m3uStr = http.getString();
          //Serial.println(m3uStr);
        }
      } else {
        Serial.printf("[HTTP] GET... failed, error: %s\n", http.errorToString(httpCode).c_str());
      }

      http.end();

      // Stringにsplitがないので、std:stringに変換
      std::string line;
      std::string lines = std::string(m3uStr.c_str());

      std::istringstream stream{lines};
      bool isUrl = false;
      Music music;
      while (std::getline(stream, line, '\n')) {
        if (starts_with(line, "#EXTINF")) {
          Serial.println("music :" + String(line.c_str()));
          isUrl = true;
          music.setName(String(line.c_str()));
        } else if (isUrl) {
          Serial.println("url:" + String(line.c_str()));
          isUrl = false;
          music.setUrl(String(line.c_str()));
          add(music);
          music = Music();
        }
      }
    }

};

void p(String msg) {
  time_t t;
  struct tm *tm;
  static const char *wd[7] = {"Sun","Mon","Tue","Wed","Thr","Fri","Sat"};

  t = time(NULL);
  tm = localtime(&t);
  Serial.printf(" %04d/%02d/%02d(%s) %02d:%02d:%02d - %s\n",
        tm->tm_year+1900, tm->tm_mon+1, tm->tm_mday,
        wd[tm->tm_wday],
        tm->tm_hour, tm->tm_min, tm->tm_sec, msg.c_str());
}


//AudioGeneratorMP3 *mp3;
AudioGeneratorMP3a *mp3;
AudioFileSourceHTTPStream *file;
AudioFileSourceBuffer *buff;
AudioOutputI2S *out;
AudioFileSourceID3 *id3;

MusicList musics; // playlist
MusicList playLists; // playlistのlist


// Called when there's a warning or error (like a buffer underflow or decode hiccup)
void StatusCallback(void *cbData, int code, const char *string)
{
  const char *ptr = reinterpret_cast<const char *>(cbData);
  // Note that the string may be in PROGMEM, so copy it to RAM for printf
  char s1[64];
  strncpy_P(s1, string, sizeof(s1));
  s1[sizeof(s1)-1]=0;
  char buf[300];
  sprintf(buf, "STATUS(%s) '%d' = '%s'\n", ptr, code, s1);
  p(buf);
}

void setup() {

  Serial.begin(115200);

  Serial.println("Connecting to WiFi");

  WiFi.begin(WIFI_SSID, WIFI_PASS);

  Serial.print("WiFi connecting");

  while (WiFi.status() != WL_CONNECTED) {
    //Serial.println("...Connecting to WiFi");
    delay(100);
  }
  Serial.println("Connected");

  // http://www.autumn-color.com/archives/839
  configTime(3600* 9, 0, "ntp.nict.jp", "ntp.jst.mfeed.ad.jp");
  p("init ntp");

  playLists.parseUrl(PLAY_LISTS_URL);
  playLists.next();

  musics.parseUrl(playLists.current().getUrl());

  //ESP pin   - I2S signal
  //----------------------
  //GPIO25/DAC1   - LRCK
  //GPIO26/DAC2   - BCLK
  //GPIO22        - DATA
  out = new AudioOutputI2S(0, 0); // Output to builtInDAC
  out->SetGain(((float)OUTPUT_GAIN)/100.0);
  //out->SetOutputModeMono(true);
  mp3 = new AudioGeneratorMP3a();
  //mp3 = new AudioGeneratorMP3();
  mp3->RegisterStatusCB(StatusCallback, (void*)"mp3");

  nextPlay();
}

int lastPlayStartTime;

void stop() {
    if (file != NULL) {
      out->stop();
      mp3->stop();

      buff->close();
      delete buff;
      buff = NULL;

      file->close();
      delete file;
      file = NULL;
    }
}

void play() {
    stop();

    //p("play :" + musics.current().getUrl());
    p("play :" + musics.current().getName());

    file = new AudioFileSourceHTTPStream(musics.current().getUrl().c_str());
    //file->RegisterStatusCB(StatusCallback, (void*)"http");

    buff = new AudioFileSourceBuffer(file, 2048);
    //buff = new AudioFileSourceBuffer(file, 30000); // fillLevelは29999
    //buff = new AudioFileSourceBuffer(file, 65000); // fillLevelは64999
    //buff = new AudioFileSourceBuffer(file, 102400);  // fillLevelは36863 なんで減るの??
    //buff = new AudioFileSourceBuffer(file, 204800); // fillLevelは8191 なんで減るの??
    //buff = new AudioFileSourceBuffer(file, 304800); // fillLevelは42655
    //buff = new AudioFileSourceBuffer(file, 324800); // fillLevelは62655
    //buff = new AudioFileSourceBuffer(file, 344800); // fillLevelは17119
    //buff = new AudioFileSourceBuffer(file, 604800); // fillLevelは14975
    //buff = new AudioFileSourceBuffer(file, 1004800); // fillLevelは21759
    //buff = new AudioFileSourceBuffer(file, 2004800); // fillLevelは38719

    buff->RegisterStatusCB(StatusCallback, (void*)"buffer");

  //  id3 = new AudioFileSourceID3(file); // TODO

    p("mp3 begin");
    mp3->begin(buff, out);
    lastPlayStartTime = time(NULL);
}

void prevPlay() {
    musics.prev();  
    play();
}

void nextPlay() {
    musics.next();
    play();
}

void prevPlayList() {
  stop();
  playLists.prev();
  musics.parseUrl(playLists.current().getUrl());
  nextPlay();
}

void nextPlayList() {
  stop();
  playLists.next();
  musics.parseUrl(playLists.current().getUrl());
  nextPlay();
}

void loop() {
  myBtn.read();                    //Read the button
  myBtn2.read();                    //Read the button
  myBtn3.read();                    //Read the button

  static bool isLongPressed = false;
  if (myBtn.wasReleased()) {
    nextPlay();
  }
  if (myBtn2.wasReleased()) {
    if (isLongPressed) {
      isLongPressed = false;
    } else {
      nextPlay();
    }
  }
  if (myBtn2.pressedFor(1000)) { // 長押し
    nextPlayList();
    isLongPressed = true;
  }
  if (myBtn3.wasReleased()) {
    if (isLongPressed) {
      isLongPressed = false;
    } else {
      prevPlay();
    }
  }
  if (myBtn3.pressedFor(1000)) { // 長押し
    prevPlayList();
    isLongPressed = true;
  }

//  static int lastms = 0;
  if (mp3->isRunning()) {
      // AudioFileSourceBufferのFillLevel確認用
//    if (millis()-lastms > 1000) {
//      lastms = millis();
//      p("fillLevel:"+String(buff->getFillLevel()));
//    }
    if (!mp3->loop()) {
      int playTime = time(NULL) - lastPlayStartTime;
      p("MP3 done. time:"+String(playTime));
      nextPlay();
    }
  }
}

ソース&ESP8266Audioライブラリでのピックアップポイント

バッファ値

AudioFileSourceBuffer(file, 2048);
でバッファの容量を指定できますが2048ですと私の環境では不安定で Buffer underflowが発生し、再生が止まってしまいました。
65000あたりがmaxに近そうで、ここまで指定すれば大分安定して再生できました。2,3時間流して問題なさそうでした。ただし大きいほど再生が遅くなるためバランス調整が必要そうです。

AudioGenerator

AudioGeneratorMP3とAudioGeneratorMP3aの2つがありましたが、後者のAudioGeneratorMP3aを利用したほうが
再生時の初期化時間が短く良さそうでした。AudioGeneratorMP3aの停止処理時にmp3のstopだけではノイズがのり不十分だったためoutのstopとbuffとfileもクローズすることでノイズが低減できました。
AudioGeneratorMP3のほうでは問題ないのとサンプルでも使われているためこちらのほうが推奨されているのかもしれませんが遅いんですよねぇ、、

調査用コード

RegisterStatusCBやfillLevel表示の部分などコメントアウトしてるので解除するとデバッグ時に役立ちます。

c++ソースについて

ソース自体はC++ちゃんと書いたことない人なので怪しいです。。
ArduinoのStringにsplitもなかったりしたので,std::stringに変換したり。
std::stringにはstarts_with相当がないので自分で定義したり、ruby等と比べるときつい世界だなぁーという印象です。
とはいえC++11が使えるので多少はモダンに書けるのがよいですね。ESP-IDFでCのソースばかり書いてある世界よりはましそうです、、
ESP-IDFでもC++使えるはずなのに使わないのはメモリのせいなのでしょうか?とはいえESP32はマイコンにしてはメモリ多いほうなのでC++で書きやすい世界になってほしい気持ちが。
最初はESP-IDF用ライブラリのCで書かれた下記のアプリを改良しようとしたのですが
https://github.com/MrBuddyCasino/ESP32_MP3_Decoder
ESP-IDFで用意されてるCでのhttp通信とか触ろうとしたらひどいソース量で発狂しそうになりました。Arduino用のC++でラップされたHTTPライブラリはかわいい。
ただESP32_MP3_DecoderのほうがxTaskCreatePinnedToCoreでタスクという概念でスレッドのように曲再生の処理をしてるのは良さそうには思いました。ESP8266Audioのloop部分で再生を頑張るのはある意味すごいですが。
Arduinoで書いてる人はxTaskCreatePinnedToCoreとかESP-IDFのcメソッドをもっと使ってってほしいし、ESP-IDFの人はもっとC++でラップされたライブラリ書いていってもらえると、素人にはわかりやすくてよいなーと思いました。勘違いなこと言ってたらすいません。

終わりに

ESP32すごい。時期モデルでたらもっとメモリや速度アップしてるでしょうし夢がありますね。
もっと機能追加していきたいですが、コンパイル&転送に時間かかるから遠い目になってます。。web開発と比べると組み込み系は待ち時間つらいですね。

TODO

  • url一覧をSPIFFSにキャッシュして起動時の高速化&バックグラウンドでキャッシュ更新(2箇所同時通信できるか不明ですが)
  • wifiつながるまでの待ち時間待ちの何か
  • 音量調整ボタン
  • 停止、再生ボタン
  • 液晶などに曲名表示
  • ブレッドボード使わないで基盤で小型化&箱にいれてきれいに&防水加工もできたら風呂場で使えそう
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away