はじめに
ESP32でmp3をhttp経由で再生できるライブラリの存在と、スピーカーで音声出力が可能とわかったので
それならばGoogle Play Musicを再生できるポータブルプレイヤー作れるのではと思ったのがきっかけになります。
小型の再生機器はどれもローカルのメモリやSDカードからの再生で、曲を追加するのにPCに持ってきて追加が必要など、曲の更新に重い腰が動かず放置しがちでした。
少し値段をだせばDLNA連携プレイヤーなどありますが、これらは曲の管理やサーバーなどは使い勝手のいいイメージはありません。
GooglePlayMusicはクラウドで曲の管理ができ、プレイリストも自由に作れるため、これを再生できるプレイヤーが作れれば最新の曲を聞く機会が増えそうです。
※電子工作とかマイコンとかArduino触ったことないド素人がESP32安くて高機能ですげーーっという勢いで数ヶ月適当に勉強したぐらいのレベルなので、いろいろ怪しい所があるかもしれません。。
そんな人ででもこのぐらいできちゃうっていうのがすごい時代だなーと思いますし、とにかくESP32すごい。
処理の流れ
ESP32 → httpにてGMusicProxy(自宅内サーバーやラズパイなど)へアクセス → Google Play Music上のプレイリスト一覧urlと曲urlを取得 → 曲url(mp3url)をライブラリ(ESP8266Audio)にて再生 → スピーカーへ出力
※GMusicProxyは家庭内サーバーと記載してますが、認証さえ工夫すればVPSなどでも問題ないと思います。
再生してる様子
ESP32でGooglePlayMusicのPlayList再生できたー。
— ode (@odetarou) 2018年5月6日
物理ボタンでPlayMusicの曲選択できるのって新鮮。作り込めば便利そう。
費用1500円ぐらい(スピーカー別途)。
ボタン長押しで別のPlayListに切り替えて再生なども可能(3曲目がそれ。m3uをhttpで取得してるので若干時間かかる、キャッシュしたい) pic.twitter.com/vcRexmBEOt
上記はDACにてヘッドフォンジャック出力して外部スピーカー(アンプ内蔵)につないでいますが、
下記のようにD級アンプ&スピーカーで電源不要スピーカーにもできます。3Wスピーカーでそれなりに出力でます。
esp32からGooglePlayMusicの音楽流せた。https://t.co/P2HsiWI3lN
— ode (@odetarou) 2018年2月15日
このサーバーを起動すればhttp://xxx/get_song?id=xxxでmp3が取得できて、
esp32側にはhttps://t.co/UCb2qAmUZV
を入れて、playlistの曲urlに上記urlいれる感じ。
本格的にやるならlist再生をapi使って連携するようにしていけそう。 pic.twitter.com/EAd7Cr6gSa
操作方法
ボタン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
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でヘッドフォンや外部スピーカーの場合
※配線はラズパイ用に利用していた方のページを参考にしました。
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つながるまでの待ち時間待ちの何か
- 音量調整ボタン
- 停止、再生ボタン
- 液晶などに曲名表示
- ブレッドボード使わないで基盤で小型化&箱にいれてきれいに&防水加工もできたら風呂場で使えそう