ESP32-S3-Zero で Web レイディオ(カバーアート付き)

最終更新日

Web ラジオにカバーアートを表示したい…

ESP32 開発ボードの I2S インターフェースを利用してウェブラジオレシーバを作成するための参考記事はネット上にそれこそ星の数ほどありますが、折角のフルカラーディスプレイにステーションと曲タイトルを表示するようなものがほとんどで、カバーアートを表示するサンプルのようなものは残念ながら見つかりませんでした。だったら SSD1306 OLED とかでいいじゃんねってゆう
仕方がないので自力でなんとかしてみたという記事です。

参考:基本構成

ハードウェア

I2S オーディオ再生

ディスプレイドライバ

iTunes Search API でカバーアートを取得

SHOUTcastIcecast を受信すると再生中の曲情報が「アーティスト – タイトル」の形で送られて来ます。これを iTunes Search API に送ると、カバーアートの URL を含むデータが取得できます。iTunes Search API は登録なしで利用できますが、1 分あたり約 20 回の呼び出し制限があります。URL は次のようになります。
https://itunes.apple.com/search?country=jp&media=music&entity=musicTrack&limit=1&lang=ja_jp&term=検索文字列
検索文字列は URL エンコードされている必要があります。例えば、Lisa Ekdahl – Daybreak を検索するには次のような URL となります。(”+” は “%20” でも OK)
https://itunes.apple.com/search?country=jp&media=music&entity=musicTrack&limit=1&lang=ja_jp&term=Lisa+Ekdahl+-+Daybreak
レスポンスは JSON 文字列で返ります。

{
 "resultCount":1,
 "results": [
{"wrapperType":"track", "kind":"song", "artistId":13690066, "collectionId":274017660, "trackId":274017668, "artistName":"Lisa Ekdahl", "collectionName":"Lisa Ekdahl Sings Salvadore Poe", "trackName":"Daybreak", "collectionCensoredName":"Lisa Ekdahl Sings Salvadore Poe", "trackCensoredName":"Daybreak", "artistViewUrl":"https://music.apple.com/jp/artist/lisa-ekdahl/13690066?uo=4", "collectionViewUrl":"https://music.apple.com/jp/album/daybreak/274017660?i=274017668&uo=4", "trackViewUrl":"https://music.apple.com/jp/album/daybreak/274017660?i=274017668&uo=4", 
"previewUrl":"https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview125/v4/25/78/1d/25781d50-de7c-3333-b1ad-a114f6b35ed3/mzaf_289916378665900615.plus.aac.p.m4a", "artworkUrl30":"https://is1-ssl.mzstatic.com/image/thumb/Music128/v4/51/8e/cf/518ecf43-63ac-2960-d9f4-d45adf04cd3e/743217968120.jpg/30x30bb.jpg", "artworkUrl60":"https://is1-ssl.mzstatic.com/image/thumb/Music128/v4/51/8e/cf/518ecf43-63ac-2960-d9f4-d45adf04cd3e/743217968120.jpg/60x60bb.jpg", "artworkUrl100":"https://is1-ssl.mzstatic.com/image/thumb/Music128/v4/51/8e/cf/518ecf43-63ac-2960-d9f4-d45adf04cd3e/743217968120.jpg/100x100bb.jpg", "collectionPrice":1833.00, "trackPrice":255.00, "releaseDate":"1900-11-08T12:00:00Z", "collectionExplicitness":"notExplicit", "trackExplicitness":"notExplicit", "discCount":1, "discNumber":1, "trackCount":14, "trackNumber":1, "trackTimeMillis":194933, "country":"JPN", "currency":"JPY", "primaryGenreName":"ジャズ", "isStreamable":true}]
}

“artworkUrlxx”(xx は数字)というキーの値がカバーアートの URL です。xx は1辺の長さです。
曲が Apple のデータベースに無いなどの理由で取得に失敗すると次のようなレスポンスとなります。

{
 "resultCount":0,
 "results": []
}

実装(失敗編)

Web ラジオから送られる曲情報は ESP32-audioI2S のコールバック関数 audio_showstreamtitlechar *info で取得できます。※ コードは抜粋・エラー処理等省略

void audio_showstreamtitle(const char *info){
    String streamtitle = info;
    ...
}

iTunes API へのクエリ送信には標準ライブラリの <HTTPClient.h> を使用します。URL エンコードは外部ライブラリ UrlEncode で楽をします。

#include <HTTPClient.h>
#include <UrlEncode.h>

String query = "https://itunes.apple.com/search?country=jp&media=music&entity=musicTrack&limit=1&lang=ja_jp&term=" + urlEncode(streamtitle);

HTTPClient http;
http.begin(query.c_str());
int httpResponseCode = http.GET();
if (httpResponseCode == HTTP_CODE_OK)
{
  String payload = http.getString();
	...

JSON 文字列のパースには ArduinoJson ライブラリを使います。

#include <ArduinoJson.h>

JsonDocument doc;
deserializeJson(doc, payload);	

キー artworkUrl30 の値を取り出します(60 とか 100 とかでも OK)。”30×30” の部分を “240×240” などディスプレイに合わせた画像サイズに置換します。これがカバーアートの URL となります。

if (doc.containsKey("resultCount") && doc["resultCount"] > 0)
{
	String art_url = doc["results"][0]["artworkUrl30"];
	art_url.replace("30x30", "240x240");

LovyanGFX にはネット上から直接 JPG や PNG を表示する関数があるのでこれを利用します。(クラス “LGFX” は LGFX_AUTODETECT ではなく前述の参考サイト【2】を参考に自前で用意した方が良いかも)

#include <LovyanGFX.hpp>
#include <LGFX_AUTODETECT.hpp>
static LGFX lcd;

lcd.drawJpgUrl(art_url);

LCD にカバーアートが表示できました。だがしかし。

問題発生!

ストリーム再生中に<HTTPClient.h> を使った通信を行うと GET() で再生が途切れることが判明。どんなに少ない通信量でもダメでした。ついでに drawJpgUrl() も内部的に <HTTPClient.h> を使うのでさらにダメ。加えて drawJpgUrl() の2回目以降のコールは何故か機能しないことが分かりトドメを刺されたのでした。使い方が間違っているのだろうか。そういえば drawJpgUrl() って公式のドキュメントに無い…

マルチタスクやってみた

ESP32 は並列処理ができるらしいと聞いた(読んだ)ので試してみました。結果から述べますとよくわからんけど上手くいきました。ストリーム再生に影響を与えず裏でこっそり HTTP 通信が可能です。

試しに iTunes API への問い合わせ部分を別タスク化してみます。

void iTunesSearch_task(void *arg)
{
  String query = "https://itunes.apple.com/search?country=jp&media=music&entity=musicTrack&limit=1&lang=ja_jp&term=" + urlEncode(streamtitle);
  HTTPClient http;
  http.begin(query.c_str());
  int httpResponseCode = http.GET();
  if (httpResponseCode == HTTP_CODE_OK)
  {
    String payload = http.getString();
    Serial.println(payload);
  }
  http.end();
  vTaskDelete(NULL);
}
...
xTaskCreateUniversal(iTunesSearch_task, "iTunesSearch_task", 8192, NULL, 1, NULL, PRO_CPU_NUM);

戻り値のない関数として作成します。作成したタスクは用が済んだら必ず vTaskDelete() で削除します。引数に NULL を指定すると、vTaskDelete() を実行したタスク自身を削除します。
xTaskCreateUniversal() でタスクを作成します。引数については(というか ESP32 の FreeRTOS については)こちらの解説が親切です。

コア指定について、最初 CONFIG_ARDUINO_RUNNING_CORE を指定したら音飛びが発生したので PRO_CPU_NUM に変更したところ、音飛びはなくなりました。優先度 0 でもダメだったので APP 用のコアはデコードで忙しいとかなのでしょうか。

カバーアート取得は前述の通り drawJpgUrl() が上手に使えなかったので <HTTPClient.h> でバイナリを GET して drawJpg() に渡すようにしました。バイナリデータ取得についてはこちらのサンプルコードがそのまま使えました。コピペで使えるレベル。ありがてぇ。

工夫したところといえばデータを受け取る入れ物 *p_buffer を PSRAM に確保するようにしたことくらい。

unsigned long p_len = 64 * 1024;
uint8_t *p_buffer = (uint8_t *)ps_malloc(p_len * sizeof(uint8_t));
doHttpGet(art_url, p_buffer, &p_len);
lcd.drawJpg(p_buffer, p_len);
free(p_buffer);

バッファサイズは iTunes API から 38k バイト超のカバーアート画像が飛んできたこともあったので余裕を持たせて 64k にしています。

まとめ

2cm 四方程度の大きさのチビボードでカバーアート表示付き Web ラジオレシーバを作成できました。ハーフサイズのブレッドボードに収まります。やはりカバーアートが表示できるとオサレよね。見た目重視なら M5Stack 使えって? 手作り感ないし、高いじゃん、アレ。
メインの UI は WebServer.h でブラウザ経由でアクセスするようにしています。ただ全部それだと面倒なので、起動後最初のステーション選択と再生中のボリューム調整だけは ESP32 内蔵のタッチセンサを使ったタッチスイッチでできるようにしています。アイキャッチ画像で LCD の右側にちょっと見える裸ピンがそれ。場所とらなくてイイ。

サンプルコード

GitHub に PlatformIO プロジェクトとして置きました。可視性のためカバーアート取得・表示以外の部分は最低限にしてあります。上記の WebUI とかタッチスイッチとかありません。自分でやるのが楽しいのです。開発ボードを使うってのはそういうコトです。

256行目の vTaskDelete(NULL); をコメントアウトして、280行目の xTaskCreateUniversal(… もコメントアウト、その下に void *arg; task_artwork(arg); と書いてコンパイル・アップロードすると、ワタクシの「実装(失敗編)」を追体験できます。だからなんだというわけではありませんが… いや、音飛びなくカバーアートが表示された時のウレシサというかそうゆうものをですね…

蛇足(読んではいけません)

その1:マルチタスクと FreeRTOS

ESP32 マルチタスク」「FreeRTOS」なんてのを検索して「えっ、なんかダウンロードしてインストールしなきゃなの?」とか「今使ってるライブラリはそれに対応してるの?」とか色々考えた挙句スゴイ身構えていたのだが、結論はまさかの「さいしょからつかえるようになってる」だた。おバカなワタクシ。ずいぶんと回り道したもんだ…(遠い目)

その2:マルチタスクとマルチスレッドとマルチコア

ESP-IDF Programming Guide で「thread」と「task」を検索してみたんだけどサ。なんか ESP32 の世界では並列処理という文脈での「スレッド」は殆ど使われていないっぽいワケよ。(先に使われちゃってるから…?)
なので、「ESP32 マルチタスク」で検索して出てくる「マルチスレッド」は全て「マルチタスク」と読み替えるべき。「タスク」と「スレッド」を別扱いしていたらそこは読み飛ばしちゃった方が精神衛生上よろしい。サイトによって書いてることが違っていて混乱するし、本筋とは関係ないからね。

おバカなワタクシが分かっていなかった事とかのまとめ:
ESP32-S3 はコアが2つある(デュアルコア)からお互いの邪魔をしないで2つの仕事ができるよ。あとそれぞれのコアでも FreeRTOS のはたらきで複数の処理を短い時間で切り替えながら(タイムスライス)進めることができるよ。だから2つよりもっとたくさんの仕事(マルチタスク)ができるよ。でも一つの仕事は一つのコアにしか任せられないから、自分で新しい仕事を始めるときは暇してる方のコアを指名して割り込ませてもらおうね!

その3:ESP32-S3-Zero でタッチセンサがヘンだった

GitHub にある公式サンプルの通りにやったら、S3-Zero で touchAttachInterrupt() のコールバック関数が呼ばれなくなる不具合が発生。最初はちゃんと動くんだけど、ストリーム再生開始後から反応がなくなる。同じメーカで同じチップの ESP32-S3-Pico では問題ない。こういうパターンは解決が難しいか無意味なことが多いので、さっさとタッチ判別のループタスクを自作して回避。本来必要ないコードを書くことになってしまって悔しいから Gist を張り付けておく。

  • touch_default: タッチセンサは気温や湿度など環境によって値が変化するので、起動直後の値を基準として憶えておく。あとタッチによる変化は割合で判定
  • touchdetected: タッチ判定を一旦グローバル変数に入れて loop() で改めて取り出している。前述の公式サンプルでも使われている手法なのだが、最初なんでこんな回りくどいことをしているのかわからず、touchDetect() から直接他の関数を呼び出したところ見事にリセットかけられてしまった。そうだった、タスクの中から他の関数を呼び出すとそれもタスク内で実行されるんだった。リソース不足でのリセットということでいいのかな
  • WROOM や WROVER といった以前の機種と今回使った S3 とでは touchRead() で返る値が全然違う。触れていない状態で前者は 40~50 前後、後者は 30000~40000 となっている(機種によって違うかも)。タッチによる変化も前者は値が減少するが、後者は増加する。

コメント

Gravatar 対応