無改造M5StickS3でつくる超小型リアルタイム・トランシーバ(ウォーキー・トーキー)
M5StickS3を2台使い、ESP-NOWプロトコルを使ってWi-Fiルーター不要(ピアツーピア)でリアルタイムの音声通話ができる「ウォーキー・トーキー(トランシーバー)」アプリをArduino IDEで実装しました。
基本的にすべてGeminiにコーディングさせていますが、ESP32-S3の高速な通信機能と、M5Unifiedライブラリを活用したハードウェア制御のポイント、開発時に直面したいくつかのハマりどころと解決策を備忘録を兼ねて共有します。
開発の背景とシステム概要
「ボタンを押している間だけ送信し、離しているときは常時受信待ち受け状態になる」という、一般的なトランシーバーの挙動をM5StickS3で実現しました。
- マイコン・プラットフォーム: M5StickS3 (ESP32-S3 + ES8311 オーディオコーデック)
- 通信プロトコル: ESP-NOW (ブロードキャスト通信による低遅延なダイレクト通信)
- 使用ライブラリ: M5Unified, M5GFX, WiFi.h, esp_now.h
設計上の主な工夫と技術的ポイント
1. マイクとスピーカーのハードウェア的排他制御
M5StickS3が搭載しているオーディオコーデック(ES8311)は、マイクとスピーカーを同時に有効にすることができません。
これを回避するため、アプリ内に MODE_LISTEN / MODE_TRANSMIT / MODE_RECEIVE のステートマシン(状態管理)を設け、ボタンの押下やデータの受信トリガーに応じて動的に切り替える仕組みを実装しています。
- 送信時: M5.Speaker.end() でスピーカーを停止し、M5.Mic.begin() でマイクを有効化。
- 受信・待受時: M5.Mic.end() でマイクを停止し、M5.Speaker.begin() でスピーカーを有効化。
2. 受信終了時の「ブー」音(バッファハング)対策
音声データが途切れた後もスピーカーの出力バッファに最後の波形データが残り続け、持続的なノイズ(ブーという音)が発生する問題がありました。
これを解決するため、タイムアウト監視(300ms)を導入しました。
C++
const unsigned long RECV_TIMEOUT_MS = 300;
// メインループ内で新規データが途絶えたことを検知したら即座に停止
else if (currentMode == MODE_RECEIVE && (millis() - lastRecvTime > RECV_TIMEOUT_MS)) {
M5.Speaker.stop(); // バッファの残音を強制停止
currentMode = MODE_LISTEN;
}
これにより、相手が話すのをやめた瞬間にパッと無音になり、綺麗に待受状態へ復帰します。
3. 割り込み処理と描画の競合回避
ESP-NOWの受信コールバック関数(OnDataRecv)内で直接LCDの描画(M5.Display)を行うと、割り込み処理の重さから画面がブラックアウトしたりフリーズしたりする不具合が起きます。
対策として、コールバック内では最小限のデータコピーとフラグ立て(volatile bool newDataReceived = true;)のみ行い、実際の音声再生と画面描画は安全なメインループ(loop())側で処理するように設計しています。
4. 視認性の高いUIと省電力(オートスリープ)の実装
- バッテリー残量・信号強度(RSSI)のリアルタイムバー表示: 通信状態や電池残量を一目で把握できるようにカスタム描画関数を実装。
- 画面の自動減光: 5秒間操作や受信がない場合にバックライトを消灯(M5.Display.setBrightness(0))し、ボタン押下またはデータ受信時に自動復帰させることでバッテリー消費を軽減。
完全なソースコード
以下のコードを両方のM5StickS3に書き込むことで、すぐにトランシーバーとして動作します。
C++
\#**include** \<M5Unified.h\>
\#**include** \<WiFi.h\>
\#**include** \<esp\_now.h\>
\#**define** SAMPLE\_RATE 16000
\#**define** BUFFER\_SIZE 256
typedef struct {
uint8\_t audioData\[BUFFER\_SIZE\];
size\_t dataSize;
} \_\_attribute\_\_((packed)) audio\_packet\_t;
audio\_packet\_t incomingPacket;
volatile bool newDataReceived \= false;
int lastRssi \= \-99;
uint8\_t broadcastAddress\[\] \= {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
esp\_now\_peer\_info\_t peerInfo;
enum Mode {
MODE\_LISTEN,
MODE\_TRANSMIT,
MODE\_RECEIVE
};
Mode currentMode \= MODE\_LISTEN;
unsigned long lastRecvTime \= 0;
unsigned long lastActivityTime \= 0;
unsigned long lastBattCheckTime \= 0;
const unsigned long RECV\_TIMEOUT\_MS \= 300;
const unsigned long SLEEP\_TIMEOUT\_MS \= 5000;
bool isScreenDimmed \= false;
void OnDataRecv(const esp\_now\_recv\_info \*info, const uint8\_t \*incomingData, int len) {
if (len \== sizeof(audio\_packet\_t) && currentMode \!= MODE\_TRANSMIT) {
memcpy(\&incomingPacket, incomingData, sizeof(audio\_packet\_t));
if (info && info-\>rx\_ctrl) {
lastRssi \= info-\>rx\_ctrl-\>rssi;
}
newDataReceived \= true;
}
}
void OnDataSent(const wifi\_tx\_info\_t \*info, esp\_now\_send\_status\_t status) {
// 送信完了時処理
}
void drawBatteryBar(int x, int y, int w, int h, int percent) {
M5.Display.drawRect(x, y, w, h, WHITE);
M5.Display.fillRect(x \+ w, y \+ 2, 2, h \- 4, WHITE);
int fillW \= (w \- 4) \* percent / 100;
M5.Display.fillRect(x \+ 2, y \+ 2, w \- 4, h \- 4, BLACK);
if (fillW \> 0) {
M5.Display.fillRect(x \+ 2, y \+ 2, fillW, h \- 4, WHITE);
}
}
void drawSignalBar(int x, int y, int rssi) {
int bars \= 0;
if (rssi \== 0 || rssi \< \-90) bars \= 0;
else if (rssi \>= \-60) bars \= 4;
else if (rssi \>= \-70) bars \= 3;
else if (rssi \>= \-80) bars \= 2;
else bars \= 1;
for (int i \= 0; i \< 4; i++) {
int barH \= 3 \+ i \* 2;
int bx \= x \+ i \* 4;
int by \= y \+ (9 \- barH);
if (i \< bars) {
M5.Display.fillRect(bx, by, 3, barH, WHITE);
} else {
M5.Display.drawRect(bx, by, 3, barH, WHITE);
}
}
}
void drawHeader() {
int batt \= M5.Power.getBatteryLevel();
drawBatteryBar(5, 3, 26, 10, batt);
M5.Display.setTextSize(1);
M5.Display.setCursor(35, 3);
M5.Display.printf("%3d%%", batt);
M5.Display.setCursor(95, 3);
M5.Display.print("SIG");
drawSignalBar(122, 3, (currentMode \== MODE\_TRANSMIT) ? 0 : lastRssi);
}
void updateStatus(const char\* statusText) {
if (isScreenDimmed) return;
M5.Display.fillRect(0, 20, 160, 60, BLACK);
M5.Display.setTextSize(2);
M5.Display.setCursor(5, 35);
M5.Display.print(statusText);
}
void setup() {
auto cfg \= M5.config();
M5.begin(cfg);
M5.Display.setRotation(3);
M5.Display.fillScreen(BLACK);
M5.Display.setTextColor(WHITE, BLACK);
M5.Display.setBrightness(128);
WiFi.mode(WIFI\_STA);
WiFi.disconnect();
if (esp\_now\_init() \!= ESP\_OK) {
return;
}
esp\_now\_register\_send\_cb(OnDataSent);
esp\_now\_register\_recv\_cb(OnDataRecv);
memcpy(peerInfo.peer\_addr, broadcastAddress, 6);
peerInfo.channel \= 0;
peerInfo.encrypt \= false;
esp\_now\_add\_peer(\&peerInfo);
M5.Mic.end();
M5.Speaker.begin();
M5.Speaker.setVolume(255);
currentMode \= MODE\_LISTEN;
lastActivityTime \= millis();
drawHeader();
updateStatus("Listening");
}
void loop() {
M5.update();
if (millis() \- lastBattCheckTime \> 2000) {
lastBattCheckTime \= millis();
if (\!isScreenDimmed) {
M5.Display.fillRect(0, 0, 160, 15, BLACK);
drawHeader();
}
}
if (M5.BtnA.isPressed()) {
lastActivityTime \= millis();
if (isScreenDimmed) {
M5.Display.setBrightness(128);
isScreenDimmed \= false;
M5.Display.fillRect(0, 0, 160, 80, BLACK);
drawHeader();
updateStatus("Transmitting");
}
if (currentMode \!= MODE\_TRANSMIT) {
M5.Speaker.stop();
M5.Speaker.end();
M5.Mic.begin();
currentMode \= MODE\_TRANSMIT;
updateStatus("Transmitting");
M5.Display.fillRect(90, 0, 70, 15, BLACK);
M5.Display.setCursor(95, 3);
M5.Display.print("SIG");
drawSignalBar(122, 3, \-99);
}
audio\_packet\_t outgoingPacket;
if (M5.Mic.record(outgoingPacket.audioData, BUFFER\_SIZE, SAMPLE\_RATE)) {
outgoingPacket.dataSize \= BUFFER\_SIZE;
esp\_now\_send(broadcastAddress, (uint8\_t \*)\&outgoingPacket, sizeof(audio\_packet\_t));
}
}
else {
if (currentMode \== MODE\_TRANSMIT) {
M5.Mic.end();
M5.Speaker.begin();
M5.Speaker.setVolume(255);
currentMode \= MODE\_LISTEN;
updateStatus("Listening");
M5.Display.fillRect(90, 0, 70, 15, BLACK);
drawHeader();
}
if (newDataReceived) {
lastActivityTime \= millis();
if (isScreenDimmed) {
M5.Display.setBrightness(128);
isScreenDimmed \= false;
M5.Display.fillRect(0, 0, 160, 80, BLACK);
drawHeader();
updateStatus("Receiving");
}
if (currentMode \!= MODE\_RECEIVE) {
M5.Mic.end();
M5.Speaker.begin();
M5.Speaker.setVolume(255);
currentMode \= MODE\_RECEIVE;
}
newDataReceived \= false;
lastRecvTime \= millis();
M5.Display.fillRect(90, 0, 70, 15, BLACK);
M5.Display.setCursor(95, 3);
M5.Display.print("SIG");
drawSignalBar(122, 3, lastRssi);
M5.Speaker.playRaw((const uint8\_t\*)incomingPacket.audioData, incomingPacket.dataSize, SAMPLE\_RATE, false, 0, true);
updateStatus("Receiving");
}
else if (currentMode \== MODE\_RECEIVE && (millis() \- lastRecvTime \> RECV\_TIMEOUT\_MS)) {
M5.Speaker.stop();
currentMode \= MODE\_LISTEN;
updateStatus("Listening");
}
if (\!isScreenDimmed && (millis() \- lastActivityTime \> SLEEP\_TIMEOUT\_MS)) {
M5.Display.setBrightness(0);
isScreenDimmed \= true;
}
}
}
まとめ
M5StickS3のハードウェア制約(マイクとスピーカーの排他利用)をステートマシンで綺麗に裁きつつ、ESP-NOWによるリアルタイム通信を組み合わせることで、実用的な超小型トランシーバーを簡単に構築することができました。
表示の部分などまだ怪しいところもありますが、コードの書き換えや機能拡張のベースとしても扱いやすい構成になっていますので、ぜひ試してみてください。