Atom EchoS3R で音声録音サーバーを作ってみたメモ

Atom EchoS3R で音声録音サーバーを作ってみたメモ

Atom EchoS3R で音声録音サーバーを作ってみたメモです。

背景

M5AtomS3 + Atomic Echo Base で音声録音サーバーを作ってみたメモ - 1ft-seabass.jp.MEMO

M5AtomS3 + Atomic Echo Base で音声録音サーバーをつくりました。今回は Atom EchoS3R でも同じ仕組みを作ってみます。

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

Atom Echo S3R - スマートスピーカー開発キット — スイッチサイエンス

Atom EchoS3R はこちらです。ドキュメントはこちら。→ Atomic Echo Base

できること

Atom EchoS3R は画面がないので、状態のフィードバックは音で行います。

  • 起動完了 ミ・ミ・ミ♪
  • WiFi接続中 ド(1秒ごと)
  • WiFi接続成功 ド・ミ・ソ♪
  • 録音開始 ソ・ラ・シ♪
  • 録音停止 シ・ラ・ソ♪

とフィードバック。

メイン機能は、

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

です。

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

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

実際のコード

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

全体構成としては、

  • M5Unified の M5.Mic / M5.Speaker を使用
  • ESP-IDF の HTTP サーバーで API を提供
  • リングバッファ方式で録音データを保存

が、あります。

録音の仕組みとしては、

  • 16kHz / 16bit / モノラルで約30秒録音可能
  • リングバッファが一周したら自動停止
  • 録音データに WAV ヘッダーを付けて配信

が、あります。

/*
 * Atom EchoS3R Audio Recorder Server (M5Unified M5.Speaker/M5.Mic version)
 *
 * 【概要】
 * Atom EchoS3R で音声を録音し、HTTP経由でWAVファイルとして提供するサーバー
 * ディスプレイなしバージョン(ログ出力はUART Serial2に対応可能)
 *
 * 【使い方】
 * 1. echos3r-server.env.h を作成してWiFi設定とWebhook設定を記述
 *    ファイル内容例:
 *      #ifndef ECHOS3R_SERVER_ENV_H
 *      #define ECHOS3R_SERVER_ENV_H
 *      const char* ssid = "your_wifi_ssid";
 *      const char* password = "your_wifi_password";
 *      const char* webhook_url = "no_url";  // または実際のWebhook URL
 *      #endif
 * 2. PlatformIO で env:m5stack-atoms3r 環境でコンパイル&書き込み
 * 3. API経由でIPアドレスを確認(または別のAtomS3をUART接続してログ確認)
 * 4. ボタンを押して録音開始
 * 5. もう一度ボタンを押して録音停止
 * 6. PCから http://<IP>/api/audio/download でWAVダウンロード
 *
 * 【UART出力(オプション)】
 * displayLog()関数内のコメントアウトを外し、setup()でSerial2.begin()を呼び出すことで
 * 別のAtomS3等にUART経由でログを出力可能
 *
 * 【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 0.2.11
 * - ArduinoJson
 */

#include <M5Unified.h>
#include <WiFi.h>
#include "esp_http_server.h"
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include "echos3r-server.env.h"

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

// ———— 録音設定 ————
// PSRAM活用: リングバッファ方式
static constexpr const size_t record_number = 2400;  // 30秒録音対応 (2400 * 200 / 16000 = 30秒)
static constexpr const size_t record_length = 200;  // 1チャンクあたりのサンプル数
static constexpr const size_t record_size = record_number * record_length;
static constexpr const size_t record_samplerate = 16000;
static size_t rec_record_idx = 2;
static int16_t *rec_data = nullptr;

#define SAMPLE_RATE 16000
#define BITS_PER_SAMPLE 16
#define NUM_CHANNELS 1  // モノラル
#define BYTES_PER_SAMPLE 2  // 16bit * 1ch = 2 bytes per frame

// ———— グローバル変数 ————
static uint8_t wavHeader[44];
static uint32_t recordedSize = 0;  // 実際に録音されたサイズ(バイト数)
static uint8_t speakerVolume = 128;  // 再生音量 (0-255, デフォルト128 = 50%)
static String webhookUrl = webhook_url;  // Webhook送信先URL(env.hから初期化)

// ステート管理
enum State {
    STATE_INIT,
    STATE_READY,
    STATE_RECORDING,
    STATE_RECORDED,
    STATE_PLAYING
};
State currentState = STATE_INIT;
unsigned long recordStartTime = 0;
size_t recordStartIdx = 0;  // 録音開始時のインデックス

// ———— ログ関数(UART出力版)————
void displayLog(String message) {
    // UART (Serial2) 出力のみ
    // Serial2.println(message);
    // ※Serial2を使用する場合は、setup()内でSerial2.begin()を呼び出してください
}

// ———— 音階定義 ————
#define NOTE_C4  262  // ド
#define NOTE_E4  330  // ミ
#define NOTE_G4  392  // ソ
#define NOTE_A4  440  // ラ
#define NOTE_B4  494  // シ

// ———— サウンドフィードバック関数 ————
void playTone(int frequency, int duration) {
    M5.Speaker.tone(frequency, duration);
    delay(duration);
    delay(10);  // 音が途切れないように少し余分に待つ
    M5.Speaker.stop();
}

void soundBootup() {
    // 起動完了: ミ・ミ・ミ♪
    playTone(NOTE_E4, 150);
    delay(50);
    playTone(NOTE_E4, 150);
    delay(50);
    playTone(NOTE_E4, 150);
    delay(50);
}

void soundWifiConnecting() {
    // WiFi接続中: ド(短め)
    playTone(NOTE_C4, 100);
    delay(50);
}

void soundWifiConnected() {
    // WiFi接続成功: ド・ミ・ソ♪
    playTone(NOTE_C4, 150);
    delay(50);
    playTone(NOTE_E4, 150);
    delay(50);
    playTone(NOTE_G4, 200);
    delay(50);
}

void soundRecordingStart() {
    // 録音開始: ソ・ラ・シ♪
    playTone(NOTE_G4, 150);
    delay(50);
    playTone(NOTE_A4, 150);
    delay(50);
    playTone(NOTE_B4, 200);
    delay(50);
}

void soundRecordingStop() {
    // 録音終了: シ・ラ・ソ♪
    playTone(NOTE_B4, 150);
    delay(50);
    playTone(NOTE_A4, 150);
    delay(50);
    playTone(NOTE_G4, 200);
    delay(50);
}

// ———— ステータス文字列取得 ————
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;
    }

    displayLog("===============");
    displayLog("REC RECORDING");
    displayLog("===============");
    displayLog("Press to STOP");
    displayLog("Max: " + String(record_size * sizeof(int16_t) / 1024) + "KB");
    displayLog("");

    // 録音開始音を鳴らすため、一時的にスピーカーを有効化
    M5.Mic.end();
    delay(10);  // マイク停止の安定化待ち
    M5.Speaker.begin();
    delay(50);  // スピーカー初期化の完了を待つ
    soundRecordingStart();  // ソ・ラ・シ♪
    M5.Speaker.end();
    delay(10);  // スピーカー停止の安定化待ち

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

    // マイク開始
    M5.Mic.begin();
    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.Mic.end();
    delay(10);  // マイク停止の安定化待ち
    M5.Speaker.begin();
    delay(50);  // スピーカー初期化の完了を待つ
    soundRecordingStop();  // シ・ラ・ソ♪
    M5.Speaker.end();
    delay(10);  // スピーカー停止の安定化待ち
    M5.Mic.begin();
    delay(10);  // マイク初期化待ち

    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;
    }

    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;

    // マイク停止、スピーカー開始
    M5.Mic.end();
    delay(10);  // マイク停止の安定化待ち
    M5.Speaker.begin();
    delay(50);  // スピーカー初期化の完了を待つ

    // スピーカー音量設定
    M5.Speaker.setVolume(speakerVolume);
    displayLog("Setting vol: " + String(speakerVolume) + "/255");

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

    // 再生実行(リングバッファを考慮)
    displayLog("Playing...");

    // 録音開始位置から現在位置までを再生
    int start_pos = recordStartIdx * record_length;
    int end_pos = rec_record_idx * record_length;

    if (start_pos < end_pos) {
        // 連続した領域
        M5.Speaker.playRaw(&rec_data[start_pos], recordedSize / 2, record_samplerate, false, 1, 0);
    } else {
        // リングバッファを跨いでいる場合
        int first_part_samples = record_size - start_pos;
        M5.Speaker.playRaw(&rec_data[start_pos], first_part_samples, record_samplerate, false, 1, 0);
        if (end_pos > 0) {
            M5.Speaker.playRaw(rec_data, end_pos, record_samplerate, false, 1, 0);
        }
    }

    // 再生完了待ち
    while (M5.Speaker.isPlaying()) {
        delay(1);
        M5.update();
    }

    // 再生完了後、スピーカー停止してマイク再開
    M5.Speaker.end();
    delay(10);  // スピーカー停止の安定化待ち
    M5.Mic.begin();
    delay(10);  // マイク初期化待ち

    currentState = STATE_RECORDED;

    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);

    // 音声データ送信(リングバッファを考慮)
    int start_pos = recordStartIdx * record_length;
    int end_pos = rec_record_idx * record_length;

    if (start_pos < end_pos) {
        // 連続した領域
        httpd_resp_send_chunk(req, (const char*)&rec_data[start_pos], recordedSize);
    } else {
        // リングバッファを跨いでいる場合
        int first_part_bytes = (record_size - start_pos) * sizeof(int16_t);
        httpd_resp_send_chunk(req, (const char*)&rec_data[start_pos], first_part_bytes);
        if (end_pos > 0) {
            int second_part_bytes = end_pos * sizeof(int16_t);
            httpd_resp_send_chunk(req, (const char*)rec_data, second_part_bytes);
        }
    }

    // 終了
    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);

    // 音声データ送信(リングバッファを考慮)
    int start_pos = recordStartIdx * record_length;
    int end_pos = rec_record_idx * record_length;

    if (start_pos < end_pos) {
        // 連続した領域
        httpd_resp_send_chunk(req, (const char*)&rec_data[start_pos], recordedSize);
    } else {
        // リングバッファを跨いでいる場合
        int first_part_bytes = (record_size - start_pos) * sizeof(int16_t);
        httpd_resp_send_chunk(req, (const char*)&rec_data[start_pos], first_part_bytes);
        if (end_pos > 0) {
            int second_part_bytes = end_pos * sizeof(int16_t);
            httpd_resp_send_chunk(req, (const char*)rec_data, second_part_bytes);
        }
    }

    // 終了
    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"] = record_size * sizeof(int16_t);
    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);

            // 即座に音量を反映
            M5.Speaker.setVolume(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);

    // シリアル初期化
    Serial.begin(115200);
    delay(3000);

    displayLog("===========");
    displayLog(FW_NAME);
    displayLog("v" + String(FW_VERSION));
    displayLog("===========");
    displayLog("EchoS3R (No Display)");

    // マイク初期化
    displayLog("Init Mic...");
    auto mic_cfg = M5.Mic.config();
    mic_cfg.noise_filter_level = (mic_cfg.noise_filter_level + 8) & 255;
    M5.Mic.config(mic_cfg);

    // PSRAMに録音バッファを確保
    displayLog("Allocate buffer...");
    rec_data = (int16_t*)heap_caps_malloc(record_size * sizeof(int16_t), MALLOC_CAP_8BIT);
    if (!rec_data) {
        displayLog("PSRAM alloc FAILED!");
        displayLog("System halted.");
        while (true) delay(1000);
    }
    memset(rec_data, 0, record_size * sizeof(int16_t));
    displayLog("PSRAM OK: " + String(record_size * sizeof(int16_t) / 1024) + "KB");

    // スピーカー音量設定
    M5.Speaker.setVolume(speakerVolume);
    displayLog("Speaker volume: " + String(speakerVolume) + "/255");

    // 起動完了音: ミ・ミ・ミ♪
    M5.Speaker.begin();
    delay(50);  // スピーカー初期化の完了を待つ
    soundBootup();
    M5.Speaker.end();
    delay(10);

    // WiFi接続
    displayLog("WiFi connecting...");
    WiFi.mode(WIFI_STA);
    WiFi.begin(ssid, password);

    int attempts = 0;
    unsigned long lastBeep = 0;
    while (WiFi.status() != WL_CONNECTED) {
        delay(100);

        // 1秒ごとに「ド」を鳴らす
        if (millis() - lastBeep >= 1000) {
            M5.Speaker.begin();
            delay(50);  // スピーカー初期化の完了を待つ
            soundWifiConnecting();  // ド
            M5.Speaker.end();
            delay(10);
            lastBeep = millis();
            attempts++;
        }

        if (attempts > 30) {  // 30秒でタイムアウト
            displayLog("WiFi TIMEOUT");
            displayLog("Check settings");
            while (true) delay(1000);
        }
    }

    // WiFi接続成功音: ド・ミ・ソ♪
    M5.Speaker.begin();
    delay(50);  // スピーカー初期化の完了を待つ
    soundWifiConnected();
    M5.Speaker.end();
    delay(10);

    displayLog("WiFi OK");
    displayLog("IP: " + WiFi.localIP().toString());
    displayLog("MAC: " + WiFi.macAddress());

    // Webサーバー起動
    start_webserver();
    displayLog("Server started");
    displayLog("===============");

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

    // マイク開始(初期状態)
    M5.Speaker.end();
    delay(10);  // スピーカー停止の安定化待ち
    M5.Mic.begin();
    delay(100);  // マイク初期化の安定化待ち

    // 起動時の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 (M5.Mic.isEnabled()) {
            auto data = &rec_data[rec_record_idx * record_length];
            if (M5.Mic.record(data, record_length, record_samplerate)) {
                recordedSize += record_length * sizeof(int16_t);

                if (++rec_record_idx >= record_number) {
                    rec_record_idx = 0;
                }

                // リングバッファが一周したら自動停止
                if (rec_record_idx == recordStartIdx) {
                    displayLog("Buffer full!");
                    stopRecording();
                }
            }
        }
    }

    delay(10);
}

使い方

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

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

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

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.11
    bblanchon/ArduinoJson@^7.4.2

動かしてみる

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

  • 「ミ・ミ・ミ♪」で起動完了
  • 「ド・ミ・ソ♪」で WiFi 接続成功
  • ボタンを押すと「ソ・ラ・シ♪」で録音開始
  • もう一度押すと「シ・ラ・ソ♪」で録音停止
  • 長押しで再生

という動きになります。