🔊

ESP32 + DFPlayer Mini で作る、ブラウザ操作できる Wi-Fi オーディオプレーヤー

に公開

ESP32 と DFPlayer Mini を使って、スマホのブラウザから操作できる音楽プレーヤーを作ったので紹介します。

自分用のメモも兼ねているので、間違いやより良い方法があればぜひ教えてください

ESP32 が Wi-Fi アクセスポイントとして動作し、アプリ不要で以下のような操作ができます。

  • 再生・一時停止・前後スキップ
  • 音量調整(スライダー)
  • イコライザ変更(NORMAL / POP / ROCK / JAZZ / CLASSIC / BASS)
  • 再生時間のリアルタイム表示
  • 再生中は波紋アニメーションが動く UI

DFPlayer Mini は MP3 のメタデータが取得しづらいため、1曲3分想定でタイマー管理し、3分に達したら次の曲に進めるという割り切り仕様にしています。


作ったものの概要

  • ESP32 が Wi-Fi アクセスポイント(AP)+ Web サーバとして動作
  • DFPlayer Mini で microSD 上の MP3 を再生
  • スマホ / PC からブラウザで Web UI にアクセス
  • 再生 / 一時停止 / 前の曲 / 次の曲
  • 音量スライダー
  • イコライザ選択
  • 再生時間のリアルタイム表示
  • 再生中は波紋アニメーションが動く円形 UI

「ESP32_Music」という Wi-Fi に接続するだけで、アプリなしで音楽プレーヤーとして使えます。


用意するもの

  • ESP32 開発ボード
  • DFPlayer Mini
  • スピーカー(8Ω推奨)
  • microSDカード(音楽ファイル用)
  • USBケーブル(PC と ESP32 接続用)
  • Arduino IDE または PlatformIO

配線

DFPlayer Mini と ESP32 の接続は以下の通り。

DFPlayer Mini ESP32 GPIO 備考
VCC 5V 電源
GND GND グラウンド
TX GPIO0 ESP32 RX (Serial1)
RX GPIO4 ESP32 TX (Serial1)
SPK_1 スピーカー
SPK_2 スピーカー

ポイント:

  • ESP32 と DFPlayer の GND は必ず共通にする

  • DFPlayer の電源は 5V 推奨(3.3V だと不安定になりやすい)

  • ESP32 → DFPlayer Mini(TX→RX)は 3.3V → 5V 系への入力になるので、

    厳密には レベル変換 or 分圧 を入れるのが安全。

    (動いてはいますが、長期運用や個体差まで考えると対策した方が安心)


SDカードの構成

DFPlayer Mini にはファイル名のルールがあります。

/mp3/[0001.mp](http://0001.mp3)
/mp3/[0002.mp](http://0002.mp3)
/mp3/[0003.mp](http://0003.mp3)
...

基本的に、番号順に再生されます。


Web UI の構成

ESP32 が提供する Web UI は、以下のような画面構成です。

  • 中央に円形のプレーヤー(波紋アニメーション)
  • 再生 / 一時停止ボタン
  • 前の曲 / 次の曲
  • 再生位置バー(進行状況)
  • 再生時間表示(経過/想定トラック長)
  • 音量スライダー(0〜30)
  • イコライザ選択プルダウン

スマホから操作すると、ネイティブアプリっぽい操作感になります。


仕組み

1. ESP32 を Wi-Fi AP + DNS + Web サーバにする

WiFi.softAP(ssid, password);
dnsServer.start(DNS_PORT, "*", myIP);
  • ESP32 が ESP32_Music という SSID でアクセスポイントを立てます
  • DNS を全ドメイン * → ESP32 の IP に向けることで、簡易的な キャプティブポータル的挙動にしています

OS によっては自動でログインページが開きますが、開かない場合はブラウザで http://192.168.4.1 を開けば UI にアクセスできます。


2. Web UI → ESP32 へ fetch でコマンド送信

ブラウザ側(JavaScript)からは、fetch() でシンプルな HTTP GET を投げています。

// 再生
fetch('/command?cmd=play');

// 音量変更
fetch('/command?cmd=volume&value=15');

コマンドは cmdvalue で表現しておき、

ESP32 側でパースして DFPlayer Mini を操作します。


3. ESP32 → DFPlayer Mini を制御

myDFPlayer.start();          // 再生開始
myDFPlayer.pause();          // 一時停止
[myDFPlayer.next](http://myDFPlayer.next)();           // 次の曲
myDFPlayer.previous();       // 前の曲
myDFPlayer.volume(volume);   // 音量設定
myDFPlayer.EQ(eqSetting);    // イコライザ設定

HardwareSerial myHardwareSerial(1); を使って Serial1 経由で DFPlayer Mini を制御しています。


4. 再生時間の管理

MP3 の長さをメタデータから取らず、1曲3分(180秒)と仮定してタイマーで管理します。

  • ESP32 側で 1 秒ごとに elapsedTime をインクリメント
  • elapsedTime >= trackLength(=180秒)で次の曲にスキップ
  • /track-status エンドポイントで JSON を返し、フロント側の JavaScript から 1 秒ごとにポーリング
server.on("/track-status", handleTrackStatus);

フロントエンド側では setInterval()/track-status を 1 秒ごとに叩き、

プログレスバーと時間表示を更新しています。


コード全文

以下が動作するスケッチの全体です。

#include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>
#include "DFRobotDFPlayerMini.h"

// WiFi設定
const char* ssid = "ESP32_Music";
const char* password = "";

// DNSサーバーとWebサーバーの初期化
DNSServer dnsServer;
WebServer server(80);
const byte DNS_PORT = 53;

// DFPlayer Mini設定
HardwareSerial myHardwareSerial(1); // Serial1 (RX: GPIO0, TX: GPIO4)
DFRobotDFPlayerMini myDFPlayer;

// 再生時間管理
unsigned long previousMillis = 0;
unsigned long interval = 1000; // 1秒間隔
int elapsedTime = 0;
int trackLength = 180; // 曲の長さ(秒): 3分想定
bool isPlaying = false; // 再生状態を追跡

// 現在のイコライザモード
int currentEqMode = 0; // 初期値: NORMAL

// HTMLコンテンツ
const char htmlPage[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>オーディオプレイヤー</title>
    <style>
        body {
            font-family: 'Arial', sans-serif;
            background: linear-gradient(135deg, #141e30, #243b55);
            color: #fff;
            margin: 0;
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            height: 100vh;
        }
        .player-container {
            position: relative;
            display: flex;
            align-items: center;
            justify-content: center;
            width: 250px;
            height: 250px;
            background: radial-gradient(circle, #4e73df, #2a518c);
            border-radius: 50%;
            box-shadow: 0 0 30px rgba(78, 115, 223, 0.6);
            overflow: hidden;
        }
        .wave {
            position: absolute;
            top: 50%;
            left: 50%;
            width: 0;
            height: 0;
            border-radius: 50%;
            background: rgba(78, 115, 223, 0.3);
            transform: translate(-50%, -50%);
            animation: none;
        }
        .wave.active {
            animation: expandCircle 1.5s infinite;
        }
        @keyframes expandCircle {
            0% {
                width: 0;
                height: 0;
                opacity: 0.8;
            }
            100% {
                width: 250px;
                height: 250px;
                opacity: 0;
            }
        }
        .horizontal-controls {
            margin-top: 20px;
            display: flex;
            justify-content: center;
            align-items: center;
            gap: 10px;
        }
        .vertical-controls {
            display: flex;
            flex-direction: column;
            gap: 10px;
            margin-top: 20px;
            width: 300px;
        }
        .button {
            background: white;
            color: #4e73df;
            font-size: 1rem;
            font-weight: bold;
            padding: 10px 15px;
            border: none;
            border-radius: 5px;
            cursor: pointer;
            box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
        }
        .button:hover {
            background: #f0f0f0;
        }
        .progress-bar, .volume-control, .equalizer {
            width: 100%;
        }
        .progress-bar input, .volume-control input {
            width: 100%;
        }
        .equalizer select {
            width: 100%;
            padding: 5px;
        }
        .time-display,
        .volume-display {
            font-size: 1.2rem;
            font-weight: bold;
            text-align: center;
        }
    </style>
</head>
<body>
    <div class="player-container">
        <div class="wave"></div>
        <div class="wave"></div>
        <div class="wave"></div>
    </div>
    <div class="horizontal-controls">
        <button id="prev" class="button">⏮️ 前の曲</button>
        <button id="play-pause" class="button">▶️ 再生</button>
        <button id="next" class="button">次の曲 ⏭️</button>
    </div>
    <div class="vertical-controls">
        <div class="progress-bar">
            <label for="progress">進行:</label>
            <input type="range" id="progress" min="0" max="100" value="0" readonly>
        </div>
        <div class="time-display" id="time-display">
            00:00 / 00:00
        </div>
        <div class="volume-control">
            <label for="volume">音量:</label>
            <input type="range" id="volume" min="0" max="30" value="15">
            <div class="volume-display" id="volume-display">
                音量: 15
            </div>
        </div>
        <div class="equalizer">
            <label for="equalizer">イコライザ:</label>
            <select id="equalizer">
                <option value="0">NORMAL</option>
                <option value="1">POP</option>
                <option value="2">ROCK</option>
                <option value="3">JAZZ</option>
                <option value="4">CLASSIC</option>
                <option value="5">BASS</option>
            </select>
        </div>
    </div>
    <script>
        const playPauseButton = document.getElementById('play-pause');
        const prevButton = document.getElementById('prev');
        const nextButton = document.getElementById('next');
        const volumeSlider = document.getElementById('volume');
        const volumeDisplay = document.getElementById('volume-display');
        const equalizerDropdown = document.getElementById('equalizer');
        const progressBar = document.getElementById('progress');
        const timeDisplay = document.getElementById('time-display');
        const waves = document.querySelectorAll('.wave');
        let isPlaying = false;

        // 再生/一時停止の切り替え
        playPauseButton.addEventListener('click', () => {
            isPlaying = !isPlaying;
            playPauseButton.textContent = isPlaying ? '⏸️ 一時停止' : '▶️ 再生';
            waves.forEach(wave => wave.classList.toggle('active', isPlaying));
            fetch(`/command?cmd=${isPlaying ? 'play' : 'pause'}`);
        });

        // 前の曲
        prevButton.addEventListener('click', () => {
            fetch('/command?cmd=prev');
        });

        // 次の曲
        nextButton.addEventListener('click', () => {
            fetch('/command?cmd=next');
        });

        // 音量調整
        volumeSlider.addEventListener('input', (event) => {
            const volume = [event.target](http://event.target).value;
            volumeDisplay.textContent = `音量: ${volume}`;
            fetch(`/command?cmd=volume&value=${volume}`);
        });

        // イコライザ変更
        equalizerDropdown.addEventListener('change', (event) => {
            const eqSetting = [event.target](http://event.target).value;
            fetch(`/command?cmd=eq&value=${eqSetting}`);
        });

        // 時間のフォーマット変換
        function formatTime(seconds) {
            const mins = Math.floor(seconds / 60);
            const secs = seconds % 60;
            return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
        }

        // 再生状況の取得と更新
        setInterval(() => {
            fetch('/track-status')
                .then(response => response.json())
                .then(data => {
                    progressBar.max = data.trackLength;
                    progressBar.value = data.elapsedTime;
                    timeDisplay.textContent = `${formatTime(data.elapsedTime)} / ${formatTime(data.trackLength)}`;
                });
        }, 1000);
    </script>
</body>
</html>
)rawliteral";

void handleRoot() {
    server.sendHeader("Content-Type", "text/html; charset=UTF-8");
    server.send_P(200, "text/html", htmlPage);
}

// コマンド処理
void handleCommand() {
    if (server.hasArg("cmd")) {
        String cmd = server.arg("cmd");
        Serial.println(cmd);

        if (cmd == "play") {
            myDFPlayer.start();
            isPlaying = true;
        } else if (cmd == "pause") {
            myDFPlayer.pause();
            isPlaying = false;
        } else if (cmd == "next") {
            [myDFPlayer.next](http://myDFPlayer.next)();
            elapsedTime = 0;
        } else if (cmd == "prev") {
            myDFPlayer.previous();
            elapsedTime = 0;
        } else if (cmd == "volume" && server.hasArg("value")) {
            int volume = server.arg("value").toInt();
            myDFPlayer.volume(volume);
        } else if (cmd == "eq" && server.hasArg("value")) {
            int eqSetting = server.arg("value").toInt();
            myDFPlayer.EQ(eqSetting);
            currentEqMode = eqSetting;
        }
    }
    server.send(200, "text/plain", "OK");
}

// 再生状況をJSONで送信
void handleTrackStatus() {
    String json = "{\\"elapsedTime\\":" + String(elapsedTime) +
                  ",\\"trackLength\\":" + String(trackLength) + "}";
    server.send(200, "application/json", json);
}

void setup() {
    Serial.begin(115200);

    // DFPlayer Miniの初期化
    myHardwareSerial.begin(9600, SERIAL_8N1, 0, 4); // RX: GPIO0, TX: GPIO4
    if (!myDFPlayer.begin(myHardwareSerial)) {
        Serial.println("DFPlayer Mini 初期化失敗");
        while (true); // 初期化失敗時に停止
    }
    myDFPlayer.volume(15); // 初期音量設定

    // WiFiアクセスポイント設定
    WiFi.softAP(ssid, password);
    IPAddress myIP = WiFi.softAPIP();
    Serial.print("アクセスポイントIP: ");
    Serial.println(myIP);

    // DNSサーバーの開始
    dnsServer.start(DNS_PORT, "*", myIP);

    // HTTPハンドラ登録
    server.on("/", handleRoot);
    server.on("/command", handleCommand);
    server.on("/track-status", handleTrackStatus);

    // サーバー開始
    server.begin();
    Serial.println("HTTPサーバー開始完了");
}

void loop() {
    // DNSリクエストの処理
    dnsServer.processNextRequest();

    // HTTPクライアントリクエストの処理
    server.handleClient();

    // 再生中の場合、経過時間を更新
    if (isPlaying) {
        unsigned long currentMillis = millis();
        if (currentMillis - previousMillis >= interval) {
            previousMillis = currentMillis;
            elapsedTime++;

            if (elapsedTime >= trackLength) {
                [myDFPlayer.next](http://myDFPlayer.next)();
                elapsedTime = 0;
            }
        }
    }
}

使い方

  1. 上記スケッチを ESP32 に書き込む
  2. ESP32 が起動すると ESP32_Music という Wi-Fi が出現
  3. スマホ / PC で ESP32_Music に接続
  4. ブラウザで http://192.168.4.1 にアクセス
  5. 表示された Web UI から再生・停止・音量・イコライザなどを操作

※ 一部の端末では、接続すると自動的にログインページとしてブラウザが開くこともあります。


トラブルシューティング

DFPlayer が初期化失敗する

  • TX / RX の配線を再確認(TX ↔ RX で接続する)
  • 電源不足(USB だけだとギリギリな場合あり)
  • SDカードの相性・フォーマット(FAT32 でフォーマット推奨)

ノイズが乗る

  • GND をしっかり共通にしていない
  • スピーカーのインピーダンスが低すぎる
  • 配線が長く、電源ラインのノイズを拾っている

曲が途中で止まる

  • SDカードの速度が遅い(Class10 以上推奨)
  • 接触不良(microSD の挿し直し・別カードで確認)

今後の拡張アイデア

  • 曲名の表示(番号 → タイトルに変換するテーブルを持つなど)
  • SDカード内のファイル一覧を Web UI に出して選曲できるようにする
  • MQTT や WebSocket を使って、ネットワーク越しに遠隔操作
  • 再生中の音に合わせて LED を光らせる VU メーター的な演出

今回のコードは↓にもあります。
https://github.com/TatsuyaM2667/ESP32-MusicWebApp.git

Discussion