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
動かしてみる
よし Atomic Echo Base + AtomS3R で、クリックで録音開始、もう一度クリックすると録音停止できた音声を、長押しで再生させる仕組みができた!実はこれサーバーにもなって、外部からアクセス録音音声をダウンロードできたり再生もできるのでいろいろ連携して使えそうです! pic.twitter.com/ijl2iKmlpT
— Tanaka Seigo (@1ft_seabass) January 9, 2026
PlatformIO でビルド&書き込みできたら、
- ディスプレイで IP アドレスを確認できたら WiFi 接続完了
- ボタンを押して録音開始
- もう一度押して録音停止
- 長押しで再生
という動きになります。