🧿

円形ディスプレイに Spotify カバーアートを表示するデバイスを作った話

に公開

作ったもの

https://youtu.be/alQKB4Y2lGo?si=dJtrwlHx2Isq9iWB&rel=0

今年はじめに X で MagSafe 給電可能な円形ディスプレイを見かけてよさげーと思って買ったものの忙しすぎて放置してたやつを最近再発掘したので Spotify で再生中のカバーアートを表示するデバイスに仕立ててみた。単に表示するだけだとおもしろくないので円形なのを生かしてレコードラベルっぽくして再生中は回転させるようにしてみた。かわいい。

開発環境

  • macOS 26.1
  • Cursor 2 (ほぼ Opus 4.5)
  • arduino-cli
    • ESP32 Core: 3.0.1 (最新の 3.3.3 は TLS まわりがなんかおかしくて Spotify API コールがコケる)
    • ESP32_Display_Panel 0.1.4 (↑のプロダクトページからダウンロードできるZIPの中のDemoに使われてるやつじゃないとダメ)
    • ESP32_IO_Expander 0.0.2 (同上)

Spotify 連携

Spotify の再生状態やカバーアート画像を取得するには独自の Spotify アプリを作って Web API を使う必要がある。認証は OAuth 2.0 (PKCE) 方式で行う。PKCE はクライアントシークレット不要で、公開アプリでも安全に認証できる方式。

トークンの取得から使用までの流れはこんな感じ:

  1. Client ID → Spotify Developer Dashboard で取得(公開情報、シークレット不要)
  2. Refresh Token → 初回のみ PKCE 認証フローで取得(永続的)
  3. Access Token → Refresh Token から取得(1時間有効、50分ごとに自動更新)
  4. API Request → Access Token を使って Spotify API を呼び出す

1. Spotify App の作成

Spotify Developer Dashboard でアプリを作成し、Client ID を取得する。Redirect URI は http://127.0.0.1:8888/callback を設定する。

2. リフレッシュトークンの取得

通常の Web アプリでは認証ページをどこかにデプロイして認証してトークン取得して連携する必要あるんだが、今回はデバイスにリフレッシュトークンを書き込むだけなのでローカルサーバーを動かしてそこで取得しちゃう。

PKCE フローの流れ:

  1. ローカルサーバー(token-retriever/)で code_verifiercode_challenge を生成
  2. ブラウザで Spotify 認証ページにリダイレクト(code_challenge を含む)
  3. ユーザーがログイン&権限承認
  4. 認証コード(code)が返ってくる
  5. codecode_verifier を Spotify API に送ってトークン交換
  6. 取得した Refresh Token を config.h に自動書き込み

コピペするのがめんどくさかったのでサーバーはトークン取得したらそれをそのままソースコードに書き込むようにした。

3. トークンの更新

API コールに必要なアクセストークンはリフレッシュトークンを使って取得する。アクセストークンは 1 時間で有効期限が切れるので、50分ごとに自動更新する(有効期限の前にリフレッシュ)。

リフレッシュトークンも有効期限があるが、Spotify API が新しいリフレッシュトークンを返した場合は自動的に保存される(トークンローテーション)。

トークンは ESP32 の NVS(Non-Volatile Storage)という特殊な領域に保存されていて再起動時にも保持されるようになっている。初回起動時は config.h に埋め込まれたリフレッシュトークンが使われ、その後は NVS に保存されたものが優先される。

4. API コール

使用しているエンドポイント

  • GET /v1/me/player/currently-playing - 現在再生中のトラック情報とカバーアートURLを取得
  • PUT /v1/me/player/play - 再生開始
  • PUT /v1/me/player/pause - 一時停止
  • POST /v1/me/player/next - 次のトラックへスキップ
  • POST /v1/me/player/previous - 前のトラックへ戻る

ポッドキャストにも対応するため、currently-playing には ?additional_types=track,episode パラメータを付与している。このパラメータを付けないとポッドキャストを再生しても止まったままになる。(変更が認識されない)

ポーリング戦略

再生状態の取得は3秒おきに API コールしている。ほんとは WebSocket とか SSE とかでサーバー側から変更を通知してくれるのがベストなんだが Spotify API にはそういう機能がないので仕方なく定期的に API コールするようにしている。

ただ3秒おきのコールだと変更検知に遅れが出てしまうので、曲の変化に関してだけは曲の長さを考慮して曲の終わりに近づくにつれて polling 間隔を短くしたりギリ直前はその残り時間に合わせて次のコールタイミングを調整するようにすることで、極力つぎの曲に移り変わるタイミングで情報が得られるようにしている。

具体的には:

  • 残り時間 < 3秒: 曲の終了 300ms 前にポーリングし、その後 500ms で再ポーリング(2段階検知)
  • 残り時間 < 8秒: 2秒間隔でポーリング
  • それ以外: 3秒間隔(デフォルト)

これにより、曲の切り替わりがだいたい 200-300ms以内 に検知できるようになっている。

レート制限対応

Spotify API のレート制限は具体的な数値が公開されていないが、制限に達すると HTTP 429 エラーが返ってくる。レスポンスに含まれる Retry-After ヘッダーを読み取って、指定された秒数だけ待機するようにしている。これにより、レート制限に引っかかっても自動的に復帰できる。

別のコアで実行する

HTTP リクエストはレスポンスが返ってくるまで 200-1000ms 他の処理をブロックしてしまう。描画ループでこれをやると回転アニメーションがカクつく。

ESP32-S3 はデュアルコア(Core 0 と Core 1)を持っていて、Arduino の loop() は Core 1 で動く。そこで Spotify API コールを FreeRTOS タスクとして Core 0 で実行することで、描画ループをブロックせずに動かすことができる。

// Core 0 で Spotify タスクを作成
xTaskCreatePinnedToCore(spotifyTask, "SpotifyTask", 8192, NULL, 1, &spotifyTaskHandle, 0);

コア間のデータ共有(新しいアルバムアート画像など)にはミューテックスを使って排他制御している。

キャッシュ

このデバイスには SD カードスロットが実装されている(さらに 512MB の SD カードが付属している)。

ネットワークからダウンロードすると 500-1000ms かかるが、SD カードからだと 7-16ms で読み込める。この差はかなり体感に影響するのでダウンロードしたカバーアート画像は SD カードにキャッシュするようにした。

キャッシュ戦略

  • キャッシュキー: 画像URLのハッシュ。同じアルバムの複数曲は1つのキャッシュエントリを共有するので効率的。
  • 有効期限: HTTP の Cache-Control ヘッダーを尊重。Spotify CDN は max-age=15780000(約182日)を返すので、一度キャッシュすればほぼ再ダウンロード不要。
  • stale-while-revalidate: 期限切れでもキャッシュを即座に表示し、バックグラウンドで最新版をダウンロードして更新する。ユーザー体験を優先しつつ、データの鮮度も保つ。(実装してみたけどキャッシュ期間長すぎて発動することないw)

ストレージ管理

キャッシュは無限に増え続けると SD カードを圧迫するので、100MB(約2800枚のアルバムアート)を上限として設定。超えた場合は LRU(最も古いものから)で削除して 80% 以下に抑える仕組みを入れてある。

円形ディスプレイ

製品ページからダウンロードできる資料一式の中にデモコードも含まれているんだが V2.0 は stride がおかしいっぽくてバグってるので、ちゃんと動く V1.0 を参考にした。

ライブラリバージョンが超重要で、V1.0 付属の ESP32_Display_Panel 0.1.4 を使わないとダメ。Arduino Library Manager にある新しいバージョン(1.0.x)は API が全然違って動かない。

ふつうは LVGL を使うっぽいのだが、次セクションで説明するようにかなり細かく描画最適化する必要があってすべて自前でバッファに書き込むようにしている。

ピクセルフォーマットは RGB565(16-bit/pixel)で、R:5bit、G:6bit、B:5bit の構成。RGB888(24-bit)より色深度は落ちるが、1ピクセルあたり 2 バイトで済むのでメモリ効率が良い。360×360 の画面全体で約 253KB になる。(円形ディスプレイだけど内部バッファは 360x360 必要。物理的なピクセルが存在しないだけ。)

初期化

V1.0 のデモコードを参考にした。以下のポイントが重要:

  • configVendorCommands() は使わない(使うと一部黒画面になる)
  • invertColor(true) 必須(パネルのデフォルトでは色が反転している)
  • RGB565 は byte swap が必要

ESP32 は little-endian なのでメモリ上では下位バイトが先に来る。しかし ST77916 は big-endian を期待するので、各ピクセルの上位・下位バイトを入れ替える必要がある。

// 1ピクセルずつ swap する場合
uint16_t swapped = (color >> 8) | (color << 8);

// 2ピクセルずつ処理して高速化(32-bit 単位で処理)
uint32_t v = src32[j];  // 2ピクセル読み込み
uint32_t lo = __builtin_bswap16(v & 0xFFFF);
uint32_t hi = __builtin_bswap16(v >> 16);
dst32[j] = (hi << 16) | lo;

毎フレーム 360×360 = 129,600 ピクセルを swap するので、2 ピクセルずつ 32-bit で処理することでループ回数を半分にし、メモリアクセス効率も向上させている。

電源入れた直後は VRAM が不定状態になってて、そのままバックライトをつけるとノイズ画面が一瞬表示されてしまう。これを防ぐため、GPIO 初期化直後にバックライトを OFF → 黒フレーム送信 → バックライト ON という順序にしている。

Tearing Effect


つながってない!

このデバイスでは TE(Tearing Effect)信号が ESP32 に接続されていない。TE はディスプレイコントローラーが「今からフレームを読み出す」タイミングを知らせる PC でいうところの VSync 信号で、これがあればリフレッシュに同期して書き込めるからティアリングを完全に防げる。

しかし TE がないので、なるべく大きなチャンクを高速に送信することでティアリングを目立たなくしている。内部 SRAM の制約で 1 フレーム全体(253KB)を一度に DMA 転送できないため、画面を上下半分ずつ(180 ライン × 2 回)に分けて送信している。ティアリングが起きる可能性はあるが、チャンクが大きいので実用上は気にならない。

画像を回転させる

Web や Unity で画像を回転させるなんて今どき全く難しい点はなくって何も考えずにできちゃうのだが、ESP32 の非力な CPU でそれなりの画質でスムーズに回転アニメーションさせるのはなかなか大変で様々な工夫が必要になる。

Nearest Neighbor or Bilinear Interpolation

Nearest Neighbor は一番近い画素を使って補間する方法で、Bilinear Interpolation は周辺4画素を使って補間する方法。

Nearest Neighbor は画質が荒くなるが計算量が少ないので高速。Bilinear Interpolation は画質が良くなるが計算量が多いので遅い。

comparison
10度回転した状態。左 Nearest Neighbor、右 Bilinear Interpolation

ちなみに外周の黒いレコード部分は NN でもそんなに気にならなかったので高速化のために NN にしてる。Bilinear の面積が減るので結構効く。

Edge Anti-Aliasing

カバーアートを円形に切り抜く必要があるわけだがナイーブにピクセルごとに円の内側判定すると当然ながらエイリアシング(ジャギー)が発生してしまう。
かといって実直にアンチエイリアシングをかけると計算量が爆発してしまってアニメどころではなくなる。

edge-anti-aliasing
左が AA なし、右が AA あり

そこで事前にどのピクセルが円にひっかかっているかどのぐらいピクセルを覆っているかを計算しておいて (Supersampling)、それを使ってカバーアートと背景をブレンドすることでスムーズに見えるようにしている。

また、レコード背景とカバーアートは同時に回転しているので、このエッジブレンド処理はカバーアートをダウンロードした初回だけやるようにして、その合成後の画像を回転させることで回転処理時の負荷を軽減している。

カバーアートのトランジション

曲が変わったときに単純に画像を差し替えるとパッと切り替わって味気ない。そこで新しいカバーアートが中心から円形に広がるアニメーションを実装している。500ms かけて広がっていく。

実装としては、トランジションの進行度(0.0〜1.0)に応じて「表示する円の半径」を計算し、その円の内側だけ新しいカバーを描画、外側は前のカバー(またはレコード盤面)を描画している。

// easeOutQuad: 最初速くて徐々に減速するイージング
float easeOutQuad(float t) {
    return 1.0f - (1.0f - t) * (1.0f - t);
}

// 進行度からトランジション半径を計算
float progress = easeOutQuad(elapsed / 500.0f);
int transitionRadius = COVER_ART_RADIUS * progress;

描画はライン単位で行う。各ライン(y 座標)でトランジション円と交差する x 座標の範囲を x = ±√(r² - y²) で求め、その範囲内だけ新しいカバーをサンプリングする。範囲外は前のカバー(またはレコード背景)をそのまま使う。

事前計算 LUT

回転処理では毎ピクセル sin(θ)cos(θ) を使うが、ESP32 で浮動小数点の三角関数を毎回計算すると遅すぎる。そこで 0.1° 刻みで 3600 エントリの sin/cos ルックアップテーブル(LUT)を事前に計算しておいて、回転角度に応じてテーブルを引くだけで済むようにしている。

// 0.0° 〜 359.9° を 0.1° 刻みで事前計算
static int32_t sinLut[3600];
static int32_t cosLut[3600];

for (int i = 0; i < 3600; i++) {
    float ang = (i / 10.0f) * PI / 180.0f;
    sinLut[i] = (int32_t)(sinf(ang) * 256);  // 8-bit 固定小数点
    cosLut[i] = (int32_t)(cosf(ang) * 256);
}

円形マスクの境界も同様に事前計算している。円の方程式 x² + y² = r² から、各ライン y における円との交点は x = ±√(r² - y²) で求まる。しかし sqrtf() は遅いので、起動時に 360 ライン分すべてを計算して配列に格納しておく。

// 起動時に全ラインの円境界を事前計算
for (int y = 0; y < SCREEN_HEIGHT; y++) {
    int dy = y - CENTER_Y;
    int dySq = dy * dy;
    if (dySq >= radiusSq) {
        xStart[y] = SCREEN_WIDTH;  // このラインは円の外
        xEnd[y] = -1;
    } else {
        int xRange = (int)sqrtf((float)(radiusSq - dySq));
        xStart[y] = CENTER_X - xRange;
        xEnd[y] = CENTER_X + xRange;
    }
}

通常のレンダリングでは円の半径が固定なので、描画ループでは sqrtf() を一切呼ばずに配列を引くだけで済む。毎フレーム 360 回の sqrtf() がゼロになるのは大きい。

固定小数点演算

浮動小数点演算自体も遅いので、座標計算はすべて固定小数点演算で行っている。8-bit の小数部を持つ整数として扱うことで、乗算やシフト演算だけで座標変換ができる。

Bilinear 補間では通常、小数部の精度がそのまま補間の滑らかさに影響する。しかし 8-bit 精度(256段階)だと重み計算の乗算回数が多くなる。そこで Bilinear 補間の重みだけは 4-bit 精度(16段階)に落としている。

// 8-bit 固定小数点から上位 4-bit だけ取り出す
uint32_t fracX = (fx >> 4) & 0x0F;  // 0-15 の範囲
uint32_t fracY = (fy >> 4) & 0x0F;

// 4-bit × 4-bit = 8-bit で収まる
uint32_t w00 = (16 - fracX) * (16 - fracY);  // 左上の重み
uint32_t w10 = fracX * (16 - fracY);          // 右上の重み
// ...

16段階でも RGB565(R:5bit, G:6bit, B:5bit)の色深度を考えると視覚的には十分。8-bit から 4-bit に落とすことで乗算のビット幅が半分になり、計算が速くなる。

増分計算による座標変換

回転行列の計算を愚直にやると、各ピクセルで cos(θ)*x + sin(θ)*y の乗算が必要になる。360×360 = 129,600 ピクセルに対してこれをやると重い。

ここで回転行列の性質を利用する。ソース座標は以下の式で求まる:

srcX = cos(θ) * x + sin(θ) * y
srcY = -sin(θ) * x + cos(θ) * y

x が 1 増えると:

srcX' = cos(θ) * (x+1) + sin(θ) * y = srcX + cos(θ)
srcY' = -sin(θ) * (x+1) + cos(θ) * y = srcY - sin(θ)

つまり、同じラインの隣接ピクセルでは y は同じで x だけが 1 ずつ増えるので、ラインの最初のピクセルだけ完全な計算をして、以降は += cos(θ)-= sin(θ) の加算だけで次のピクセルの座標が求まる。

固定小数点(整数)で計算しているので浮動小数点のような丸め誤差は蓄積しない。また各ラインの最初で完全な計算をリセットするので、誤差がライン間で積み重なることもない。

// ラインの最初だけ完全な計算
int32_t srcX = cosA * dxStart + sinA * dy + centerFP;
int32_t srcY = -sinA * dxStart + cosA * dy + centerFP;

// 以降は加算だけ
for (int x = xStart; x <= xEnd; x++) {
    sample(srcX, srcY);
    srcX += cosA;  // 乗算なし!
    srcY -= sinA;
}

この最適化で乗算回数が大幅に減り、レンダリング速度が向上する。

ダブルバッファリング

ディスプレイに画像を表示するためにはあらかじめ CPU 側でバッファをつくってそこにピクセルデータを詰め込んだあと QSPI でディスプレイコントローラーに送信する必要がある。バッファ内のピクセルデータを詰め込む処理は当然 CPU がやらないとダメだが、QSPI で転送する部分は DMA を使うことで CPU を介さずにディスプレイコントローラーに送信することができる。ただし、送信中のバッファに書き込むとティアリングが起こってしまうのでもう一つ別のバッファを用意して描画側と送信側が交互にそれぞれ使うことでそれを防いでいる。

さらに、ESP32-S3 のデュアルコアを活かして、レンダリングは Core 1(Arduino の loop())、DMA 送信は Core 0(バックグラウンドタスク)で並列実行することで、レンダリングと送信が完全に並列化され、25-28 FPS のスムーズなアニメーションを実現している。(ほんとうはもうちょっと上げたいが限界…

IRAM と PSRAM

ESP32-S3 には内部 SRAM(512KB)と外部 PSRAM がある。内部 SRAM のうち高速アクセス可能な領域は IRAM(Instruction RAM)と呼ばれ、コードやデータを置くと高速に実行できる。一方 PSRAM(Pseudo-Static RAM)は OPI(Octal SPI)接続の外部メモリで、容量は多い(このボードは 8MB)がアクセス速度は内部 SRAM より遅い。

このプロジェクトでは PSRAM に約 1.3MB のバッファ(フレームバッファ 2 枚、アルバムアート、背景レコードテクスチャなど)を確保している。描画時間の半分以上が PSRAM の読み書きにかかっていたので、一部でも内部 SRAM に持ってこれないか試行錯誤してみたが、内部 SRAM は Wi-Fi スタックやその他の機能に使われていて余裕がほとんどなく、多少持ってきたところで逆に遅くなったりしたのであきらめた。


回転レンダリングの詳細(メモリレイアウト、最適化の経緯など)は ROTATION_RENDERING.md にまとめてある。

タッチパネル

このデバイスには I2C 接続の静電容量式タッチパネル(CST816S)がついている。

このアプリでは以下のジェスチャーを実装している:

  • 中央タップ: 再生/一時停止
  • 端タップ(左右 40px): 前の曲/次の曲
  • 左右スワイプ: 次の曲/前の曲

タッチコントローラーを定期的にポーリングして座標を取得し、指が離れたタイミングでタップ時間と移動距離からジェスチャーを判定している。50px 以上の水平移動でスワイプ、それ以下ならタップとして処理。短すぎるタッチ(30ms 未満)やチャタリングは無視するようにしている。

LVGL を使うとジェスチャー認識も簡単にできるのだが、前述の通り描画周りは独自でやってしまっていたのと、複雑なジェスチャーではないので独自実装となった。

arduwrap.py

以前のプロジェクトでシリアルモニターをずっと開いてると新たにビルドしたファームウェアをアップロードするときにシリアルポートが使用中でエラーになっちゃう、めんどくさいー、っていうのがあったのでそれを解決する arduino-cli の wrapper スクリプトを作った。

これを使うと別ターミナルでシリアルモニターをずっと開きつつ、コンパイル・アップロードが必要になったら自動的に閉じてアップロードして、アップロードが終わったら再度シリアルモニターを開き直すっていうのが自動でできる。便利。

./tools/arduwrap serve --port /dev/cu.usbmodem5301 --baud 115200

serve でシリアルモニターを開く。このプロセスは UNIX domain socket を開いてコンパイル指示を待つ。

./tools/arduwrap compile --fqbn esp32:esp32:esp32s3:USBMode=hwcdc,CDCOnBoot=cdc,FlashMode=qio,FlashSize=16M,PartitionScheme=huge_app,PSRAM=opi ../SPNFY/SPNFY.ino

コンパイルするときは compile サブコマンドを使う。こっちのプロセスは serve 側のプロセスにコンパイル指示を送信して結果を待つ。引数はほぼそのまま arduino-cli に送られる。stdout/stderr もそのまま流れてくる。

serve 側は compile サブコマンドを受け取ったらシリアルモニターを閉じて arduino-cli を呼び出してコンパイルを行い、結果を compile 側のプロセスに送信する。コンパイルが終わったらシリアルモニターを再度開いて結果を表示する。ここにはちょっと技があって、単純に arduino-cli プロセスの終了を待っているとデバイス側が起動して数秒後にシリアルモニターを開くことになってしまうので、arduino-cli の出力に Hard resetting via RTS pin が出た直後に開くようにするとほぼデバイスブート初期からのシリアル出力が受け取れる。

その他に log サブコマンドも実装してあって、これは起動時からのログ(64KB バッファ)を取得することができる。任意の文字列でのフィルターもできる。

で、これらの使い方を .cursorrulesAGENTS.md に書いておくと、コード修正してビルドしてアップロードして実行結果を確認するまでを AI 自ら全部できるようになるので、複雑な計算の実装とかでも正常になるまでやっといてーができるようになる。便利。

ソースコード

https://github.com/Saqoosha/SPNFY

学んだこと

  • AI に適切な指示を出し続けるとかなり複雑な描画最適化も短時間で達成できる(その他の部分含め全体のコーディング実質 3 日くらい)
  • ESP32-S3 のデュアルコアの使い方
  • PSRAM の遅さ
  • この記事の下書きとコードを Cursor にわたしてレビューしてもらうの、とてもよい(知らなかったアルゴリズム詳細まで教えてくれる)

Discussion