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
動かしてみる
Atom Echo S3R でも、クリックで録音開始、再度クリックすると録音停止できた音声を保管し、長押しで再生させる仕組みができた~!これもサーバーとして外部から録音音声をダウンロードできたり再生もできます。しかし Atomic Echo Base と違い画面がないので音やサーバーに頼るのが勉強になります! pic.twitter.com/GYtiZ1nxJZ
— Tanaka Seigo (@1ft_seabass) January 9, 2026
PlatformIO でビルド&書き込みできたら、
- 「ミ・ミ・ミ♪」で起動完了
- 「ド・ミ・ソ♪」で WiFi 接続成功
- ボタンを押すと「ソ・ラ・シ♪」で録音開始
- もう一度押すと「シ・ラ・ソ♪」で録音停止
- 長押しで再生
という動きになります。