M5AtomS3 + Atomic Echo Base で音声録音サーバーを作ってみたメモ

M5AtomS3 + Atomic Echo Base で音声録音サーバーを作ってみたメモ

M5AtomS3 + Atomic Echo Base で音声録音サーバーを作ってみたメモです。

背景

M5AtomS3 + EchoBase を音声録音サーバーを作ってみました。

デバイス内部で保管した音声を内部で処理して外部サーバーに送るのはデバイスのマシンパワー的に負荷がかかりそうなので、今回は音声録音サーバーとして動き、外部からデータを取得する方式にします。

今回は基本的な短押しで録音開始/停止、長押しで再生にフォーカスを当てます。

Atomic Echo Base(マイクロフォン/スピーカー搭載 音声認識ベース) — スイッチサイエンス

Atomic Echo Base はこちらです。ドキュメントはこちらにあります。→ Atomic Echo Base

そして、M5AtomS3 をつけて使います。

できること

メイン機能は、

  • 短押し(1回目): 録音開始
  • 短押し(2回目): 録音停止
  • 長押し(500ms以上): 録音済み音声を再生

で、今回はくわしくは触れませんが、以下の機能も備えています。

  • HTTP API で外部から制御
  • Webhook で他のサービスに通知
  • ブラウザから録音データをダウンロード/再生

実際のコード

実際のコードはこちらです。

全体構成としては、

  • M5Unified + M5EchoBase ライブラリを使用
  • ESP-IDF の HTTP サーバーで API を提供
  • PSRAM に録音データを保存

が、あります。

録音の仕組みとしては、

  • 8MB PSRAM のうち 2MB を録音バッファに確保
  • 16kHz / 16bit / ステレオで約30秒録音可能
  • 録音データに WAV ヘッダーを付けて配信

が、あります。

/*
 * M5AtomS3 EchoBase Audio Recorder Server
 *
 * 【概要】
 * M5AtomS3 + EchoBase で音声を録音し、HTTP経由でWAVファイルとして提供するサーバー
 *
 * 【使い方】
 * 1. atoms3r-echo-base-server.env.h を作成してWiFi設定を記述
 *    ファイル内容例:
 *      #ifndef ATOMS3R_ECHO_BASE_SERVER_ENV_H
 *      #define ATOMS3R_ECHO_BASE_SERVER_ENV_H
 *      const char* ssid = "your_wifi_ssid";
 *      const char* password = "your_wifi_password";
 *      #endif
 * 2. Arduino IDEでコンパイル&書き込み
 * 3. シリアルモニタまたはディスプレイでIPアドレスを確認
 * 4. ボタンを押して録音開始
 * 5. もう一度ボタンを押して録音停止
 * 6. PCから http://<IP>/api/audio.wav でWAVダウンロード
 *
 * 【APIエンドポイント】
 * GET /api/status               - 全ステータス情報(health, version, settings含む)
 * GET /api/record/start         - 録音開始
 * GET /api/record/stop          - 録音停止
 * GET /api/audio/download       - 録音済みWAVダウンロード
 * GET /api/audio/listen         - 録音済みWAVブラウザ再生
 * GET /api/audio/play           - 録音済み音声をデバイスで再生
 * GET /api/volume/playing?level=<0-100> - 再生音量設定(パーセント指定)
 * GET /api/webhook              - Webhook URL確認
 * GET /api/webhook?add_url=<URL> - Webhook URL登録
 *
 * 【ボタン操作】
 * 短押し(1回目): 録音開始
 * 短押し(2回目): 録音停止
 * 長押し(500ms以上): 録音済み音声を再生(RECORDED状態のみ)
 *
 * 【状態遷移】
 * init -> ready -> recording -> recorded -> (ready)
 *
 * 【Webhook機能】
 * /api/webhook?add_url=<URL> でWebhook URLを登録すると、以下のイベント発生時にPOSTで通知:
 * - startup: 起動時
 * - recording_started: 録音開始時
 * - recording_stopped: 録音停止時(download_url, recorded_size, duration_sec付き)
 * - playing_started: 再生開始時
 * - playing_stopped: 再生終了時
 * ※デフォルト値"no_url"の場合は通知されない
 *
 * 【必要なライブラリ】
 * - M5Unified
 * - M5EchoBase
 * - ArduinoJson
 */

#include <M5Unified.h>
#include <WiFi.h>
#include <M5EchoBase.h>
#include "esp_http_server.h"
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include "atoms3r-echo-base-server.env.h"

// ———— ファームウェア情報 ————
#define FW_NAME "AtomS3_EchoBase_Server"
#define FW_VERSION "1.2.9"

// ———— 録音設定 ————
// PSRAM活用: 8MB PSRAMのうち2MBを録音用に確保
// 2MB = 約1分の録音 @ 16kHz 16bit stereo (16000 * 2 * 2 * 60 = 3,840,000 bytes ≈ 3.7MB)
#define MAX_RECORD_SIZE (1024 * 1024 * 2)  // 最大2MB (PSRAMに確保)
#define SAMPLE_RATE 16000
#define BITS_PER_SAMPLE 16
#define NUM_CHANNELS 2  // ステレオ(I2Sの出力形式に合わせる)
#define BYTES_PER_SAMPLE 4  // 16bit * 2ch = 4 bytes per frame

// ———— グローバル変数 ————
M5EchoBase echobase(I2S_NUM_0);
static uint8_t* audioBuffer = nullptr;
static uint8_t wavHeader[44];
static uint32_t recordedSize = 0;  // 実際に録音されたサイズ
static uint8_t speakerVolume = 128;  // 再生音量 (0-255, デフォルト128 = 50%)
static String webhookUrl = "no_url";  // Webhook送信先URL

// ステート管理
enum State {
    STATE_INIT,
    STATE_READY,
    STATE_RECORDING,
    STATE_RECORDED,
    STATE_PLAYING
};
State currentState = STATE_INIT;
unsigned long recordStartTime = 0;

// ———— ディスプレイログ関数 ————
void displayLog(String message) {
    M5.Lcd.println(message);
    Serial.println(message);
}

// ———— ステータス文字列取得 ————
const char* getStateString() {
    switch(currentState) {
        case STATE_INIT: return "init";
        case STATE_READY: return "ready";
        case STATE_RECORDING: return "recording";
        case STATE_RECORDED: return "recorded";
        case STATE_PLAYING: return "playing";
        default: return "unknown";
    }
}

// ———— Webhook送信 ————
void sendWebhook(const char* event, JsonDocument* additionalData = nullptr) {
    // Webhook URLが未設定の場合はスキップ
    if (webhookUrl == "no_url" || webhookUrl.length() == 0) {
        return;
    }

    HTTPClient http;
    http.begin(webhookUrl);
    http.setTimeout(1000);  // 1秒でタイムアウト
    http.addHeader("Content-Type", "application/json");

    // JSONペイロード作成
    StaticJsonDocument<512> doc;
    doc["event"] = event;
    doc["firmware_name"] = FW_NAME;
    doc["firmware_version"] = FW_VERSION;
    doc["timestamp"] = millis() / 1000;
    doc["ip_address"] = WiFi.localIP().toString();

    // 追加データがあればマージ
    if (additionalData != nullptr) {
        JsonObject root = doc.as<JsonObject>();
        JsonObject additional = additionalData->as<JsonObject>();
        for (JsonPair kv : additional) {
            root[kv.key()] = kv.value();
        }
    }

    String payload;
    serializeJson(doc, payload);

    // POSTリクエスト送信(レスポンスは待たない)
    int httpCode = http.POST(payload);

    // すぐに切断(レスポンスボディは読まない)
    http.end();

    // ログ出力(デバッグ用)
    if (httpCode > 0) {
        displayLog("Webhook sent: " + String(event) + " (" + String(httpCode) + ")");
    } else {
        displayLog("Webhook failed: " + String(event));
    }
}

// ———— WAVヘッダー作成 ————
void createWavHeader(uint8_t* header, uint32_t dataSize) {
    uint32_t fileSize = dataSize + 36;
    uint32_t sampleRate = SAMPLE_RATE;
    uint16_t bitsPerSample = BITS_PER_SAMPLE;
    uint16_t numChannels = NUM_CHANNELS;
    uint32_t byteRate = sampleRate * numChannels * bitsPerSample / 8;
    uint16_t blockAlign = numChannels * bitsPerSample / 8;
    uint32_t fmtSize = 16;
    uint16_t audioFormat = 1; // PCM

    memcpy(header, "RIFF", 4);
    memcpy(header + 4, &fileSize, 4);
    memcpy(header + 8, "WAVE", 4);
    memcpy(header + 12, "fmt ", 4);
    memcpy(header + 16, &fmtSize, 4);
    memcpy(header + 20, &audioFormat, 2);
    memcpy(header + 22, &numChannels, 2);
    memcpy(header + 24, &sampleRate, 4);
    memcpy(header + 28, &byteRate, 4);
    memcpy(header + 32, &blockAlign, 2);
    memcpy(header + 34, &bitsPerSample, 2);
    memcpy(header + 36, "data", 4);
    memcpy(header + 40, &dataSize, 4);
}

// ———— 録音開始 ————
void startRecording() {
    if (currentState != STATE_READY && currentState != STATE_RECORDED) {
        displayLog("Cannot start");
        return;
    }

    M5.Lcd.fillScreen(BLACK);
    M5.Lcd.setCursor(0, 0);
    displayLog("===============");
    displayLog("REC RECORDING");
    displayLog("===============");
    displayLog("Press to STOP");
    displayLog("Max: " + String(MAX_RECORD_SIZE / 1024) + "KB");
    displayLog("");

    currentState = STATE_RECORDING;
    recordStartTime = millis();
    recordedSize = 0;

    echobase.setMute(true);
    delay(100);  // マイク安定化のため待機時間を延長

    // 録音開始のWebhook通知
    sendWebhook("recording_started");
}

// ———— 録音停止 ————
void stopRecording() {
    if (currentState != STATE_RECORDING) {
        displayLog("Not recording");
        return;
    }

    currentState = STATE_RECORDED;
    unsigned long duration = millis() - recordStartTime;

    M5.Lcd.fillScreen(BLACK);
    M5.Lcd.setCursor(0, 0);
    displayLog("===============");
    displayLog("REC COMPLETED");
    displayLog("===============");
    displayLog("Time: " + String(duration) + "ms");
    displayLog("Size: " + String(recordedSize) + "B");

    float durationSec = duration / 1000.0;
    displayLog("Duration: " + String(durationSec, 1) + "s");
    displayLog("");
    displayLog("Ready to download");
    displayLog("Press for new rec");
    displayLog("Hold to PLAY");

    // 録音停止のWebhook通知(ダウンロードURL付き)
    StaticJsonDocument<256> extraData;
    extraData["download_url"] = "http://" + WiFi.localIP().toString() + "/api/audio/download";
    extraData["recorded_size"] = recordedSize;
    extraData["duration_sec"] = (float)recordedSize / (SAMPLE_RATE * BYTES_PER_SAMPLE);
    sendWebhook("recording_stopped", &extraData);
}

// ———— 再生開始 ————
void startPlaying() {
    if (currentState != STATE_RECORDED) {
        displayLog("No recording");
        return;
    }

    if (recordedSize == 0) {
        displayLog("No audio data");
        return;
    }

    M5.Lcd.fillScreen(BLACK);
    M5.Lcd.setCursor(0, 0);
    displayLog("===============");
    displayLog("PLAYING");
    displayLog("===============");
    displayLog("Size: " + String(recordedSize) + "B");

    float durationSec = (float)recordedSize / (SAMPLE_RATE * BYTES_PER_SAMPLE);
    displayLog("Duration: " + String(durationSec, 1) + "s");

    currentState = STATE_PLAYING;

    // スピーカー音量設定(ミュート解除前に設定)
    displayLog("Setting vol: " + String(speakerVolume) + "/255");
    echobase.setSpeakerVolume(speakerVolume);
    delay(100);  // 音量設定の反映を待つ

    // スピーカーミュート解除
    echobase.setMute(false);
    delay(50);

    // 再生開始のWebhook通知
    sendWebhook("playing_started");

    // 再生実行
    displayLog("Playing...");
    echobase.play(audioBuffer, recordedSize);

    // 再生完了後、RECORDED状態に戻る
    currentState = STATE_RECORDED;

    M5.Lcd.fillScreen(BLACK);
    M5.Lcd.setCursor(0, 0);
    displayLog("===============");
    displayLog("PLAY COMPLETED");
    displayLog("===============");
    displayLog("Press for new rec");
    displayLog("Hold to PLAY");

    // 再生終了のWebhook通知
    sendWebhook("playing_stopped");
}

// ———— APIハンドラ: /api/audio/download ————
esp_err_t get_audio_handler(httpd_req_t* req) {
    displayLog("API: audio/download");

    if (currentState != STATE_RECORDED) {
        const char* msg = "No recording available";
        httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, msg);
        displayLog("Error: No data");
        return ESP_FAIL;
    }

    createWavHeader(wavHeader, recordedSize);

    httpd_resp_set_type(req, "audio/wav");
    httpd_resp_set_hdr(req, "Content-Disposition", "attachment; filename=recording.wav");
    httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");

    // ヘッダー送信
    httpd_resp_send_chunk(req, (const char*)wavHeader, 44);
    // 音声データ送信
    httpd_resp_send_chunk(req, (const char*)audioBuffer, recordedSize);
    // 終了
    httpd_resp_send_chunk(req, NULL, 0);

    displayLog("WAV sent OK");
    return ESP_OK;
}

// ———— APIハンドラ: /api/audio/listen ————
esp_err_t get_audio_listen_handler(httpd_req_t* req) {
    displayLog("API: audio/listen");

    if (currentState != STATE_RECORDED) {
        const char* msg = "No recording available";
        httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, msg);
        displayLog("Error: No data");
        return ESP_FAIL;
    }

    createWavHeader(wavHeader, recordedSize);

    httpd_resp_set_type(req, "audio/wav");
    httpd_resp_set_hdr(req, "Content-Disposition", "inline");  // ブラウザで再生
    httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");

    // ヘッダー送信
    httpd_resp_send_chunk(req, (const char*)wavHeader, 44);
    // 音声データ送信
    httpd_resp_send_chunk(req, (const char*)audioBuffer, recordedSize);
    // 終了
    httpd_resp_send_chunk(req, NULL, 0);

    displayLog("Listen OK");
    return ESP_OK;
}

// ———— APIハンドラ: /api/audio/play ————
esp_err_t get_audio_play_handler(httpd_req_t* req) {
    displayLog("API: audio/play");

    if (currentState != STATE_RECORDED) {
        const char* msg = "{\"error\":\"No recording available\"}";
        httpd_resp_set_type(req, "application/json");
        httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
        httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, msg);
        displayLog("Error: No data");
        return ESP_FAIL;
    }

    if (recordedSize == 0) {
        const char* msg = "{\"error\":\"No audio data\"}";
        httpd_resp_set_type(req, "application/json");
        httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
        httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, msg);
        displayLog("Error: Empty data");
        return ESP_FAIL;
    }

    // 再生開始(非同期で実行される)
    startPlaying();

    // レスポンス生成
    StaticJsonDocument<128> doc;
    doc["status"] = "ok";
    doc["message"] = "Playing audio on device";

    float durationSec = (float)recordedSize / (SAMPLE_RATE * BYTES_PER_SAMPLE);
    doc["audio_duration_sec"] = durationSec;

    String output;
    serializeJson(doc, output);

    httpd_resp_set_type(req, "application/json");
    httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
    httpd_resp_send(req, output.c_str(), output.length());

    displayLog("Play OK");
    return ESP_OK;
}

// ———— APIハンドラ: /api/record/start ————
esp_err_t get_record_start_handler(httpd_req_t* req) {
    displayLog("API: record/start");
    startRecording();

    const char resp[] = "Recording started";
    httpd_resp_send(req, resp, strlen(resp));
    return ESP_OK;
}

// ———— APIハンドラ: /api/record/stop ————
esp_err_t get_record_stop_handler(httpd_req_t* req) {
    displayLog("API: record/stop");
    stopRecording();

    const char resp[] = "Recording stopped";
    httpd_resp_send(req, resp, strlen(resp));
    return ESP_OK;
}

// ———— APIハンドラ: /api/status ————
// healthcheck, version, settings の情報を全て含む統合エンドポイント
esp_err_t get_status_handler(httpd_req_t* req) {
    StaticJsonDocument<512> doc;

    // ステータス情報
    doc["status"] = getStateString();
    doc["alive"] = true;  // healthcheck互換

    // バージョン情報
    doc["firmware"] = FW_NAME;
    doc["version"] = FW_VERSION;

    // システム情報
    doc["heap"] = ESP.getFreeHeap();
    doc["psram_size"] = ESP.getPsramSize();
    doc["psram_free"] = ESP.getFreePsram();
    doc["uptime"] = millis() / 1000;

    // 録音設定情報
    doc["sample_rate"] = SAMPLE_RATE;
    doc["bits_per_sample"] = BITS_PER_SAMPLE;
    doc["num_channels"] = NUM_CHANNELS;
    doc["max_buffer_size"] = MAX_RECORD_SIZE;
    doc["recorded_size"] = recordedSize;

    // 音量設定情報
    doc["speaker_volume"] = speakerVolume;
    doc["speaker_volume_percent"] = map(speakerVolume, 0, 255, 0, 100);

    // Webhook設定情報
    doc["webhook_url"] = webhookUrl;
    doc["webhook_configured"] = (webhookUrl != "no_url");

    // 録音中の追加情報
    if (currentState == STATE_RECORDING) {
        doc["recording_duration"] = (millis() - recordStartTime) / 1000.0;
    }

    // 録音済みの追加情報
    if (currentState == STATE_RECORDED && recordedSize > 0) {
        float audioDuration = (float)recordedSize / (SAMPLE_RATE * BYTES_PER_SAMPLE);
        doc["audio_duration_sec"] = audioDuration;
    }

    String output;
    serializeJson(doc, output);

    httpd_resp_set_type(req, "application/json");
    httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
    httpd_resp_send(req, output.c_str(), output.length());

    return ESP_OK;
}


// ———— APIハンドラ: /api/volume/playing ————
esp_err_t get_volume_handler(httpd_req_t* req) {
    displayLog("API: volume/playing");

    // クエリパラメータを取得
    char query[100];
    if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) {
        char param[32];
        if (httpd_query_key_value(query, "level", param, sizeof(param)) == ESP_OK) {
            // パーセント値 (0-100) を取得
            int levelPercent = atoi(param);
            levelPercent = constrain(levelPercent, 0, 100);

            // 0-100% を 0-255 に変換
            speakerVolume = map(levelPercent, 0, 100, 0, 255);

            // 即座に音量を反映
            echobase.setSpeakerVolume(speakerVolume);

            displayLog("Volume set: " + String(levelPercent) + "% (" + String(speakerVolume) + "/255)");

            StaticJsonDocument<128> doc;
            doc["level_percent"] = levelPercent;
            doc["level_raw"] = speakerVolume;
            doc["status"] = "ok";

            String output;
            serializeJson(doc, output);

            httpd_resp_set_type(req, "application/json");
            httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
            httpd_resp_send(req, output.c_str(), output.length());

            return ESP_OK;
        }
    }

    // パラメータなし or エラー時は現在の音量を返す
    int currentPercent = map(speakerVolume, 0, 255, 0, 100);

    StaticJsonDocument<128> doc;
    doc["level_percent"] = currentPercent;
    doc["level_raw"] = speakerVolume;
    doc["status"] = "current";

    String output;
    serializeJson(doc, output);

    httpd_resp_set_type(req, "application/json");
    httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
    httpd_resp_send(req, output.c_str(), output.length());

    return ESP_OK;
}

// ———— APIハンドラ: /api/webhook ————
esp_err_t get_webhook_handler(httpd_req_t* req) {
    displayLog("API: webhook");

    // クエリパラメータを取得
    char query[256];
    if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) {
        char param[200];
        if (httpd_query_key_value(query, "add_url", param, sizeof(param)) == ESP_OK) {
            // Webhook URL設定
            webhookUrl = String(param);
            displayLog("Webhook set: " + webhookUrl);

            StaticJsonDocument<256> doc;
            doc["status"] = "ok";
            doc["webhook_url"] = webhookUrl;
            doc["message"] = "Webhook URL registered";

            String output;
            serializeJson(doc, output);

            httpd_resp_set_type(req, "application/json");
            httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
            httpd_resp_send(req, output.c_str(), output.length());

            return ESP_OK;
        }
    }

    // パラメータなし: 現在のWebhook URLを返す
    StaticJsonDocument<256> doc;
    doc["webhook_url"] = webhookUrl;
    doc["status"] = (webhookUrl == "no_url") ? "not_configured" : "configured";

    String output;
    serializeJson(doc, output);

    httpd_resp_set_type(req, "application/json");
    httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
    httpd_resp_send(req, output.c_str(), output.length());

    return ESP_OK;
}


// ———— URIハンドラ定義 ————
httpd_uri_t uri_audio = {
    .uri = "/api/audio/download", .method = HTTP_GET,
    .handler = get_audio_handler, .user_ctx = NULL
};
httpd_uri_t uri_audio_listen = {
    .uri = "/api/audio/listen", .method = HTTP_GET,
    .handler = get_audio_listen_handler, .user_ctx = NULL
};
httpd_uri_t uri_audio_play = {
    .uri = "/api/audio/play", .method = HTTP_GET,
    .handler = get_audio_play_handler, .user_ctx = NULL
};
httpd_uri_t uri_record_start = {
    .uri = "/api/record/start", .method = HTTP_GET,
    .handler = get_record_start_handler, .user_ctx = NULL
};
httpd_uri_t uri_record_stop = {
    .uri = "/api/record/stop", .method = HTTP_GET,
    .handler = get_record_stop_handler, .user_ctx = NULL
};
httpd_uri_t uri_status = {
    .uri = "/api/status", .method = HTTP_GET,
    .handler = get_status_handler, .user_ctx = NULL
};
httpd_uri_t uri_volume = {
    .uri = "/api/volume/playing", .method = HTTP_GET,
    .handler = get_volume_handler, .user_ctx = NULL
};
httpd_uri_t uri_webhook = {
    .uri = "/api/webhook", .method = HTTP_GET,
    .handler = get_webhook_handler, .user_ctx = NULL
};

// ———— Webサーバー起動 ————
void start_webserver() {
    httpd_config_t config = HTTPD_DEFAULT_CONFIG();
    config.max_uri_handlers = 10;  // 8個のハンドラがあるので10に設定(余裕を持たせる)
    httpd_handle_t server = NULL;

    if (httpd_start(&server, &config) == ESP_OK) {
        httpd_register_uri_handler(server, &uri_audio);
        httpd_register_uri_handler(server, &uri_audio_listen);
        httpd_register_uri_handler(server, &uri_audio_play);
        httpd_register_uri_handler(server, &uri_record_start);
        httpd_register_uri_handler(server, &uri_record_stop);
        httpd_register_uri_handler(server, &uri_status);
        httpd_register_uri_handler(server, &uri_volume);
        httpd_register_uri_handler(server, &uri_webhook);
        displayLog("Server started");
        displayLog("8 endpoints OK");
    } else {
        displayLog("Server start FAILED");
    }
}

// ———— セットアップ ————
void setup() {
    auto cfg = M5.config();
    M5.begin(cfg);

    // ディスプレイ初期化
    M5.Lcd.setTextSize(1);
    M5.Lcd.setTextColor(WHITE);
    M5.Lcd.fillScreen(BLACK);
    M5.Lcd.setCursor(0, 0);
    
    // ———— 画面1: ファームウェア情報 ————
    displayLog("===========");
    displayLog(FW_NAME);
    displayLog("v" + String(FW_VERSION));
    displayLog("===========");
    displayLog("Serial wait 3s...");
    
    Serial.begin(115200);
    delay(3000);
    
    displayLog("Init EchoBase...");
    echobase.init(SAMPLE_RATE, 38, 39, 7, 6, 5, 8, Wire);
    echobase.setSpeakerVolume(speakerVolume);
    echobase.setMicGain(ES8311_MIC_GAIN_6DB);
    displayLog("EchoBase OK");
    
    delay(2000);  // 2秒表示
    
    // ———— 画面2: システム情報 ————
    M5.Lcd.fillScreen(BLACK);
    M5.Lcd.setCursor(0, 0);
    displayLog("Heap: " + String(ESP.getFreeHeap()) + " bytes");
    displayLog("PSRAM: " + String(ESP.getPsramSize()) + " bytes");
    displayLog("Display: " + String(M5.Lcd.width()) + "x" + String(M5.Lcd.height()));
    displayLog("Allocate buffer...");

    // PSRAMに大容量バッファを確保
    audioBuffer = (uint8_t*)ps_malloc(MAX_RECORD_SIZE);
    if (!audioBuffer) {
        displayLog("PSRAM alloc FAILED!");
        // フォールバック: 通常メモリで小容量確保を試みる
        audioBuffer = (uint8_t*)malloc(1024 * 256);  // 256KB
        if (!audioBuffer) {
            displayLog("Memory FAILED!");
            displayLog("System halted.");
            while (true) delay(1000);
        }
        displayLog("Fallback: 256KB");
    } else {
        displayLog("PSRAM OK: " + String(MAX_RECORD_SIZE / 1024) + "KB");
    }
    
    delay(2000);  // 2秒表示
    
    // ———— 画面3: WiFi接続 ————
    M5.Lcd.fillScreen(BLACK);
    M5.Lcd.setCursor(0, 0);
    displayLog("WiFi connecting...");
    
    WiFi.mode(WIFI_STA);
    WiFi.begin(ssid, password);
    
    int attempts = 0;
    while (WiFi.status() != WL_CONNECTED) {
        delay(500);
        M5.Lcd.print(".");
        attempts++;
        if (attempts > 60) {  // 30秒でタイムアウト
            M5.Lcd.println();
            displayLog("WiFi TIMEOUT");
            displayLog("Check settings");
            while (true) delay(1000);
        }
    }
    M5.Lcd.println();
    
    displayLog("WiFi OK");
    displayLog("IP: " + WiFi.localIP().toString());
    displayLog("MAC: " + WiFi.macAddress());
    
    delay(2000);  // 2秒表示
    
    // ———— 画面4: 最終画面(ここで停止) ————
    M5.Lcd.fillScreen(BLACK);
    M5.Lcd.setCursor(0, 0);

    start_webserver();
    displayLog("Server started");

    // IPアドレスだけサイズ2で表示
    M5.Lcd.setTextSize(2);
    displayLog("IP: " + WiFi.localIP().toString());
    M5.Lcd.setTextSize(1);

    displayLog("===============");

    currentState = STATE_READY;
    displayLog("READY");
    displayLog("Press to START REC");
    displayLog("===============");

    // 起動時のWebhook通知
    sendWebhook("startup");
}

// ———— メインループ ————
void loop() {
    M5.update();

    // ボタン処理
    // 長押し判定(500ms以上押されたら長押し)
    if (M5.BtnA.pressedFor(500)) {
        // RECORDED状態でのみ長押し再生
        if (currentState == STATE_RECORDED) {
            startPlaying();
            // 再生完了まで待つ(wasReleasedの重複判定を防ぐ)
            while (M5.BtnA.isPressed()) {
                M5.update();
                delay(10);
            }
        }
    }
    // 通常のクリック判定(リリース時)
    else if (M5.BtnA.wasReleased()) {
        // 長押しではなかった場合のみクリック処理
        if (!M5.BtnA.wasHold()) {
            if (currentState == STATE_READY || currentState == STATE_RECORDED) {
                // 録音開始
                startRecording();
            } else if (currentState == STATE_RECORDING) {
                // 録音停止
                stopRecording();
            } else if (currentState == STATE_PLAYING) {
                // 再生中は何もしない
                displayLog("Playing...");
            } else {
                displayLog("Busy...");
            }
        }
    }

    // 録音中の処理
    if (currentState == STATE_RECORDING) {
        // バッファがいっぱいでないかチェック
        if (recordedSize < MAX_RECORD_SIZE) {
            // 0.1秒分ずつechobase.record()で録音(ハイブリッド方式)
            const size_t chunk_size = SAMPLE_RATE * BYTES_PER_SAMPLE / 10;  // 0.1秒 = 3200 bytes
            size_t actual_chunk = chunk_size;

            // 残容量チェック
            if (recordedSize + chunk_size > MAX_RECORD_SIZE) {
                actual_chunk = MAX_RECORD_SIZE - recordedSize;
            }

            // echobase.record()で安定した録音
            bool success = echobase.record(audioBuffer + recordedSize, actual_chunk);

            if (success) {
                // 音量計算(簡易的に振幅の平均を使用)
                int16_t* samples = (int16_t*)(audioBuffer + recordedSize);
                int32_t sum = 0;
                for (size_t i = 0; i < actual_chunk / 2; i++) {
                    sum += abs(samples[i]);
                }
                int16_t avgVolume = sum / (actual_chunk / 2);

                recordedSize += actual_chunk;

                int barWidth = M5.Lcd.width();
                int barHeight = 8;
                int labelHeight = 8;  // ラベル用の高さ

                // ———— [VOLUME]ラベル + 音量バー ————
                // ラベル表示位置: 画面下から30px
                M5.Lcd.fillRect(0, M5.Lcd.height() - 30, barWidth, labelHeight, BLACK);
                M5.Lcd.setCursor(0, M5.Lcd.height() - 30);
                M5.Lcd.setTextColor(GREEN);
                M5.Lcd.print("[VOLUME]");
                M5.Lcd.setTextColor(WHITE);

                // 音量バー表示位置: 画面下から22px
                int volumeFillWidth = map(avgVolume, 0, 1000, 0, barWidth);  // 0-1000を画面幅にマップ
                volumeFillWidth = constrain(volumeFillWidth, 0, barWidth);

                M5.Lcd.fillRect(0, M5.Lcd.height() - 22, barWidth, barHeight, BLACK);
                M5.Lcd.fillRect(0, M5.Lcd.height() - 22, volumeFillWidth, barHeight, GREEN);
                M5.Lcd.drawRect(0, M5.Lcd.height() - 22, barWidth, barHeight, WHITE);

                // ———— [MEMORY]ラベル + メモリバー ————
                // ラベル表示位置: 画面下から14px
                M5.Lcd.fillRect(0, M5.Lcd.height() - 14, barWidth, labelHeight, BLACK);
                M5.Lcd.setCursor(0, M5.Lcd.height() - 14);
                M5.Lcd.setTextColor(BLUE);
                M5.Lcd.print("[MEMORY]");
                M5.Lcd.setTextColor(WHITE);

                // メモリバー表示位置: 画面下から6px
                int memoryFillWidth = (int)((float)recordedSize / MAX_RECORD_SIZE * barWidth);

                M5.Lcd.fillRect(0, M5.Lcd.height() - 6, barWidth, barHeight, BLACK);
                M5.Lcd.fillRect(0, M5.Lcd.height() - 6, memoryFillWidth, barHeight, BLUE);
                M5.Lcd.drawRect(0, M5.Lcd.height() - 6, barWidth, barHeight, WHITE);

                // バッファ満杯で自動停止
                if (recordedSize >= MAX_RECORD_SIZE) {
                    stopRecording();
                }
            }
        }
    }

    delay(10);
}

使い方

まず、環境設定ファイルの作成します。

atoms3r-echo-base-server.env.h を作成して WiFi 設定を記述します。

const char* ssid = "your_wifi_ssid";
const char* password = "your_wifi_password";

PlatformIO の設定例 platformio.ini はこんな感じです。

platform = espressif32@6.7.0
board = esp32-s3-devkitc-1
framework = arduino
board_build.arduino.memory_type = qio_opi
build_src_filter = +
build_flags =
    -DESP32S3
    -DBOARD_HAS_PSRAM
    -mfix-esp32-psram-cache-issue
    -DCORE_DEBUG_LEVEL=5
    -DARDUINO_USB_CDC_ON_BOOT=1
    -DARDUINO_USB_MODE=1
lib_deps =
    m5stack/M5Unified@0.2.1
    m5stack/M5GFX@0.2.0
    https://github.com/m5stack/M5Atomic-EchoBase
    bblanchon/ArduinoJson@^7.4.2

動かしてみる

PlatformIO でビルド&書き込みできたら、

  • ディスプレイで IP アドレスを確認できたら WiFi 接続完了
  • ボタンを押して録音開始
  • もう一度押して録音停止
  • 長押しで再生

という動きになります。