Overview
Ditch the smart phone and communicate with your buddy by building this set of walkie talkies! A Feather ESP32-S3 Reverse TFT runs Arduino code that uses the ESP-NOW wireless protocol to send and receive I2S audio packets, up to 10 seconds each. The w.FL antenna adds extra range to communicate at longer distances indoors or outdoors.
The case design is inspired by toy walkie talkies that were ubiquitous in the early-1990s. The LED button on the side acts as a status indicator when pairing or sending and receiving messages. The onboard TFT shows status messages, such as MAC address, signal strength and packet status.
Parts
The parts listed below are what is needed to build one walkie talkie. You will need to build two walkie talkies for this project to work.
Page last edited April 14, 2026
Text editor powered by tinymce.
Circuit Diagram
I2S Mic
- Mic VIN to Feather 3.3V (red wire)
- Mic GND to Feather GND (black wire)
- Mic BCLK to Feather D5 (blue wire)
- Mic LRCL to Feather D6 (green wire)
- Mic DOUT to Feather D9 (yellow wire)
I2S Amp
- Amp Vin to Feather 3.3V (red wire)
- Amp GND to Feather GND (black wire)
- Amp BCLK to Feather D10 (white wire)
- Amp LRC to Feather D11 (cyan wire)
- Amp DIN to Feather D12 (orange wire)
- Speaker + to Amp terminal block + (red wire)
- Speaker - to Amp terminal block - (black wire)
LED Push Button
- Button output to Feather D13 (purple wire)
- Button GND to Feather GND (black wire)
- LED - to Feather GND (black wire)
- LED + to Feather A0 (grey wire)
Toggle Switch
- Toggle output to Feather EN (pink wire)
- Toggle GND to Feather GND (black wire)
Page last edited April 14, 2026
Text editor powered by tinymce.
3D Printing
You can 3D print both of the case parts for this project using the .stl files in the link. The files can be downloaded directly below:
The case lid has standoffs for the amp, microphone and Feather. It also has mounting clips for the battery.
Page last edited April 14, 2026
Text editor powered by tinymce.
Arduino IDE Setup
The first thing you will need to do is to download the latest release of the Arduino IDE. You will need to be using version 1.8 or higher for this guide
To use the ESP32-S2/S3 with Arduino, you'll need to follow the steps below for your operating system. You can also check out the Espressif Arduino repository for the most up to date details on how to install it.
After you have downloaded and installed the latest version of Arduino IDE, you will need to start the IDE and navigate to the Preferences menu. You can access it from the File menu in Windows or Linux, or the Arduino menu on OS X.
A dialog will pop up just like the one shown below.
We will be adding a URL to the new Additional Boards Manager URLs option. The list of URLs is comma separated, and you will only have to add each URL once. New Adafruit boards and updates to existing boards will automatically be picked up by the Board Manager each time it is opened. The URLs point to index files that the Board Manager uses to build the list of available & installed boards.
To find the most up to date list of URLs you can add, you can visit the list of third party board URLs on the Arduino IDE wiki. We will only need to add one URL to the IDE in this example, but you can add multiple URLS by separating them with commas. Copy and paste the link below into the Additional Boards Manager URLs option in the Arduino IDE preferences.
https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
If you're an advanced hacker and want the 'bleeding edge' release that may have fixes (or bugs!) you can check out the dev url instead:
https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_dev_index.json
If you have multiple boards you want to support, say ESP8266 and Adafruit, have both URLs in the text box separated by a comma (,)
Once done click OK to save the new preference settings.
The next step is to actually install the Board Support Package (BSP). Go to the Tools → Board → Board Manager submenu. A dialog should come up with various BSPs. Search for esp32. Choose the latest version, which may be later than the version shown in the screenshot below.
Click the Install button and wait for it to finish. Once it is finished, you can close the dialog.
In the Tools → Board submenu you should see ESP32 Arduino and in that dropdown it should contain the ESP32 boards along with all the latest ESP32-S2/S3 boards.
Look for the board called Adafruit Feather ESP32-S3 Reverse TFT.
Manually Resetting ESP32-S3 Boards
Due to an issue in the Espressif code base, boards with an ESP32-S3 need to be manually reset after uploading code from the Arduino IDE. After your code has been uploaded to the ESP32-S3, press the reset button. After pressing the reset button, your code will begin running.
For additional information, you can track the issue on GitHub in the arduino-esp32 repository.
Page last edited April 14, 2026
Text editor powered by tinymce.
Code the Walkie Talkies
The Arduino code for the project is available as a pre-compiled .UF2 file that you can drag and drop onto your Feather ESP32-S3 Reverse TFT board. Each walkie talkie runs the same code.
Click the link above to download the UF2 file.
Save it on your computer wherever it is convenient for you.
Plug your Feather board into your computer, using a known-good data-sync USB cable, directly, or via an adapter if needed.
Double-click the reset button (highlighted in red above), wait for the NeoPixel LED to turn purple (highlighted in green), and as soon as it turns purple, tap reset again. The second tap needs to happen while the LED is still purple.
You will see a new disk drive appear called FTHRS3BOOT in your File Explorer or Finder (depending on your computer's operating system).
Drag the UF2 file to the FTHRS3BOOT drive.
The code will begin running by starting the ESP-NOW advertisement. You'll see the LED in the push button blink. When the second walkie talkie running the same code boots up, a connection between the two devices will be established.
// SPDX-FileCopyrightText: 2026 Liz Clark for Adafruit Industries
//
// SPDX-License-Identifier: MIT
/*
* ESP-NOW Walkie Talkie for ESP32-S3 Reverse TFT Feather
* Hold button to record, release to send over ESP-NOW.
* Incoming audio plays back automatically.
* Status shown on built-in TFT
*/
#include <esp_now.h>
#include <WiFi.h>
#include <driver/i2s.h>
#include <Adafruit_ST7789.h>
#include <Fonts/FreeSans9pt7b.h>
#include <Fonts/FreeSansBold12pt7b.h>
// --- DISPLAY ---
Adafruit_ST7789 tft = Adafruit_ST7789(TFT_CS, TFT_DC, TFT_RST);
GFXcanvas16 canvas(240, 135);
// --- PIN DEFINITIONS ---
#define MIC_BCLK 5
#define MIC_LRCLK 6
#define MIC_DOUT 9
#define DAC_BCK 10
#define DAC_LCK 11
#define DAC_DIN 12
#define BUTTON_PIN 13
#define LED_PIN A0
// --- AUDIO CONFIG ---
#define SAMPLE_RATE 16000
#define BITS_PER_SAMPLE I2S_BITS_PER_SAMPLE_16BIT
#define RECORD_MAX_SEC 10
#define MAX_SAMPLES (SAMPLE_RATE * RECORD_MAX_SEC)
#define DMA_BUF_COUNT 8
#define DMA_BUF_LEN 256
#define I2S_MIC_PORT I2S_NUM_0
#define I2S_DAC_PORT I2S_NUM_1
// --- ESP-NOW PROTOCOL ---
#define PKT_AUDIO 0x00
#define PKT_DISCOVERY 0x01
#define PKT_MANIFEST 0x02
#define PKT_NACK 0x03
#define PKT_ACK_DONE 0x04
#define ESPNOW_MAX_SIZE 250
#define HEADER_SIZE 6
#define CHUNK_PAYLOAD (ESPNOW_MAX_SIZE - HEADER_SIZE)
#define SAMPLES_PER_CHUNK (CHUNK_PAYLOAD / sizeof(int16_t))
#define MANIFEST_SIZE 8
#define NACK_HEADER 4
#define MAX_NACK_INDICES ((ESPNOW_MAX_SIZE - NACK_HEADER) / 2)
// Timing & retries
#define INTER_CHUNK_US 1500
#define SEND_TIMEOUT_MS 100
#define MAX_RETRIES 3
#define MAX_REPAIR_ROUNDS 3
#define MAX_FULL_RETRIES 3
#define NACK_WAIT_MS 800
#define POST_NACK_SETTLE 50
#define RX_SETTLE_MS 200
#define FULL_RETRY_DELAY 500
// --- DISPLAY COLORS ---
#define COL_BG 0x0000 // black
#define COL_TITLE 0x07FF // cyan
#define COL_OK 0x07E0 // green
#define COL_WARN 0xFD20 // orange
#define COL_ERR 0xF800 // red
#define COL_INFO 0xFFFF // white
#define COL_DIM 0x7BEF // grey
#define COL_ACCENT 0xF81F // magenta
#define COL_RECORDING 0xF800 // red
#define COL_PLAYING 0x07E0 // green
#define COL_SENDING 0xFFE0 // yellow
// --- DISPLAY STATE ---
enum DeviceState {
STATE_BOOTING,
STATE_DISCOVERING,
STATE_IDLE,
STATE_RECORDING,
STATE_SENDING,
STATE_PLAYING
};
DeviceState deviceState = STATE_BOOTING;
char statusLine1[64] = "";
char statusLine2[64] = "";
char peerMacStr[20] = "";
int lastRSSI = 0;
volatile int latestRSSI = 0; // updated in rx callback
unsigned long lastDisplayUpdate = 0;
#define DISPLAY_UPDATE_MS 100
// --- GLOBALS ---
int16_t* txBuffer = nullptr;
int16_t* rxBuffer = nullptr;
size_t txSampleCount = 0;
// Receive state
volatile bool rxComplete = false;
volatile size_t rxSampleCount = 0;
uint8_t rxMsgId = 255;
uint16_t rxTotalChunks = 0;
volatile uint16_t rxChunksReceived = 0;
uint32_t rxExpectedSamples = 0;
volatile bool rxGotManifest = false;
volatile unsigned long rxManifestTime = 0;
bool* rxChunkMap = nullptr;
// Transmit state
uint8_t txMsgId = 0;
volatile bool sendBusy = false;
volatile bool lastSendOk = false;
// Sender: response from receiver
volatile bool nackReceived = false;
volatile bool ackDoneReceived = false;
volatile uint16_t nackCount = 0;
uint16_t nackIndices[MAX_NACK_INDICES];
// Peer tracking
bool peerPaired = false;
uint8_t peerAddr[6];
uint8_t broadcastAddr[] = {0xFF,0xFF,0xFF,0xFF,0xFF,0xFF};
esp_now_peer_info_t peerInfo;
// =============================================
// DISPLAY FUNCTIONS
// =============================================
void updateDisplay() {
canvas.fillScreen(COL_BG);
// ---- Row 1: Title bar ----
canvas.setFont(&FreeSansBold12pt7b);
canvas.setCursor(4, 20);
switch (deviceState) {
case STATE_BOOTING:
canvas.setTextColor(COL_DIM);
canvas.print("WALKIE TALKIE");
break;
case STATE_DISCOVERING:
canvas.setTextColor(COL_WARN);
canvas.print("SCANNING...");
break;
case STATE_IDLE:
canvas.setTextColor(COL_TITLE);
canvas.print("WALKIE TALKIE");
break;
case STATE_RECORDING:
canvas.setTextColor(COL_RECORDING);
canvas.print("RECORDING");
break;
case STATE_SENDING:
canvas.setTextColor(COL_SENDING);
canvas.print("SENDING");
break;
case STATE_PLAYING:
canvas.setTextColor(COL_PLAYING);
canvas.print("PLAYING");
break;
}
// ---- Row 2: Connection info ----
canvas.setFont(&FreeSans9pt7b);
canvas.setCursor(4, 48);
if (peerPaired) {
canvas.setTextColor(COL_OK);
canvas.print("Peer: ");
canvas.setTextColor(COL_INFO);
canvas.print(peerMacStr);
} else {
canvas.setTextColor(COL_DIM);
canvas.print("No peer connected");
}
// ---- Row 3: RSSI / signal ----
if (peerPaired && lastRSSI != 0) {
canvas.setCursor(4, 70);
canvas.setTextColor(COL_DIM);
canvas.print("Signal: ");
// Classify signal strength
if (lastRSSI > -50) {
canvas.setTextColor(COL_OK);
canvas.print("Excellent");
} else if (lastRSSI > -65) {
canvas.setTextColor(COL_OK);
canvas.print("Good");
} else if (lastRSSI > -80) {
canvas.setTextColor(COL_WARN);
canvas.print("Fair");
} else {
canvas.setTextColor(COL_ERR);
canvas.print("Weak");
}
canvas.setTextColor(COL_DIM);
canvas.printf(" (%d dBm)", lastRSSI);
}
// ---- Row 4: Status line 1 ----
if (statusLine1[0]) {
canvas.setCursor(4, 96);
canvas.setTextColor(COL_INFO);
canvas.print(statusLine1);
}
// ---- Row 5: Status line 2 ----
if (statusLine2[0]) {
canvas.setCursor(4, 118);
canvas.setTextColor(COL_DIM);
canvas.print(statusLine2);
}
// Push to screen
tft.drawRGBBitmap(0, 0, canvas.getBuffer(), 240, 135);
}
// Helper: set status lines and refresh display immediately
void setStatus(const char* line1, const char* line2 = nullptr) {
if (line1) strncpy(statusLine1, line1, sizeof(statusLine1) - 1);
if (line2) strncpy(statusLine2, line2, sizeof(statusLine2) - 1);
else statusLine2[0] = '\0';
updateDisplay();
}
// Periodic refresh (call from loop for RSSI updates etc)
void tickDisplay() {
if (millis() - lastDisplayUpdate >= DISPLAY_UPDATE_MS) {
lastDisplayUpdate = millis();
lastRSSI = latestRSSI;
updateDisplay();
}
}
// =============================================
// I2S SETUP
// =============================================
void setupMicI2S() {
i2s_config_t cfg = {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
.sample_rate = SAMPLE_RATE,
.bits_per_sample = BITS_PER_SAMPLE,
.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
.dma_buf_count = DMA_BUF_COUNT,
.dma_buf_len = DMA_BUF_LEN,
.use_apll = false,
.tx_desc_auto_clear = false,
.fixed_mclk = 0
};
i2s_pin_config_t pins = {
.bck_io_num = MIC_BCLK,
.ws_io_num = MIC_LRCLK,
.data_out_num = I2S_PIN_NO_CHANGE,
.data_in_num = MIC_DOUT
};
i2s_driver_install(I2S_MIC_PORT, &cfg, 0, NULL);
i2s_set_pin(I2S_MIC_PORT, &pins);
i2s_zero_dma_buffer(I2S_MIC_PORT);
}
void setupDacI2S() {
i2s_config_t cfg = {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX),
.sample_rate = SAMPLE_RATE,
.bits_per_sample = BITS_PER_SAMPLE,
.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
.dma_buf_count = DMA_BUF_COUNT,
.dma_buf_len = DMA_BUF_LEN,
.use_apll = false,
.tx_desc_auto_clear = true,
.fixed_mclk = 0
};
i2s_pin_config_t pins = {
.bck_io_num = DAC_BCK,
.ws_io_num = DAC_LCK,
.data_out_num = DAC_DIN,
.data_in_num = I2S_PIN_NO_CHANGE
};
i2s_driver_install(I2S_DAC_PORT, &cfg, 0, NULL);
i2s_set_pin(I2S_DAC_PORT, &pins);
i2s_zero_dma_buffer(I2S_DAC_PORT);
}
// =============================================
// ESP-NOW CALLBACKS
// =============================================
void onDataSent(const wifi_tx_info_t* info, esp_now_send_status_t status) {
lastSendOk = (status == ESP_NOW_SEND_SUCCESS);
sendBusy = false;
}
void onDataRecv(const esp_now_recv_info_t* recvInfo, const uint8_t* data, int len) {
if (len < 1) return;
// Capture RSSI from every packet
if (recvInfo->rx_ctrl) {
latestRSSI = recvInfo->rx_ctrl->rssi;
}
uint8_t pktType = data[0];
// ---- Discovery ----
if (pktType == PKT_DISCOVERY) {
if (!peerPaired) {
memcpy(peerAddr, recvInfo->src_addr, 6);
peerPaired = true;
snprintf(peerMacStr, sizeof(peerMacStr), "%02X:%02X:%02X:%02X:%02X:%02X",
peerAddr[0],peerAddr[1],peerAddr[2],
peerAddr[3],peerAddr[4],peerAddr[5]);
Serial.printf("Discovered peer: %s\n", peerMacStr);
}
return;
}
// ---- ACK_DONE ----
if (pktType == PKT_ACK_DONE) {
ackDoneReceived = true;
return;
}
// ---- NACK ----
if (pktType == PKT_NACK && len >= NACK_HEADER) {
uint16_t count = data[2] | (data[3] << 8);
count = min(count, (uint16_t)MAX_NACK_INDICES);
if ((size_t)len < NACK_HEADER + count * 2) return;
nackCount = count;
for (uint16_t i = 0; i < count; i++) {
size_t pos = NACK_HEADER + i * 2;
nackIndices[i] = data[pos] | (data[pos + 1] << 8);
}
nackReceived = true;
return;
}
// ---- Manifest ----
if (pktType == PKT_MANIFEST && len >= MANIFEST_SIZE) {
uint8_t msgId = data[1];
uint16_t total = data[2] | (data[3] << 8);
uint32_t samples = data[4] | (data[5] << 8) | (data[6] << 16) | (data[7] << 24);
if (msgId != rxMsgId) {
rxMsgId = msgId;
rxTotalChunks = total;
rxChunksReceived = 0;
rxSampleCount = 0;
rxComplete = false;
rxGotManifest = false;
memset(rxChunkMap, 0, total);
}
if (rxComplete) return;
rxTotalChunks = total;
rxExpectedSamples = samples;
rxGotManifest = true;
rxManifestTime = millis();
return;
}
// ---- Audio chunk ----
if (pktType != PKT_AUDIO || len < HEADER_SIZE) return;
uint8_t msgId = data[1];
uint16_t seq = data[2] | (data[3] << 8);
uint16_t total = data[4] | (data[5] << 8);
const int16_t* samples = (const int16_t*)(data + HEADER_SIZE);
size_t numSamples = (len - HEADER_SIZE) / sizeof(int16_t);
if (msgId != rxMsgId) {
rxMsgId = msgId;
rxTotalChunks = total;
rxChunksReceived = 0;
rxSampleCount = 0;
rxComplete = false;
rxGotManifest = false;
rxExpectedSamples = 0;
memset(rxChunkMap, 0, total);
}
if (rxComplete) return;
if (seq < rxTotalChunks && !rxChunkMap[seq]) {
size_t offset = (size_t)seq * SAMPLES_PER_CHUNK;
size_t toCopy = min(numSamples, (size_t)(MAX_SAMPLES - offset));
memcpy(&rxBuffer[offset], samples, toCopy * sizeof(int16_t));
rxChunkMap[seq] = true;
rxChunksReceived++;
}
}
// =============================================
// DISCOVERY
// =============================================
void sendDiscoveryBeacon() {
uint8_t pkt[1] = { PKT_DISCOVERY };
esp_now_send(broadcastAddr, pkt, sizeof(pkt));
}
void waitForPeer() {
deviceState = STATE_DISCOVERING;
setStatus("Searching for peer...");
Serial.println("Sending discovery beacons — waiting for peer...");
memcpy(peerInfo.peer_addr, broadcastAddr, 6);
peerInfo.channel = 0;
peerInfo.encrypt = false;
esp_now_add_peer(&peerInfo);
bool ledState = false;
while (!peerPaired) {
sendDiscoveryBeacon();
ledState = !ledState;
digitalWrite(LED_PIN, ledState ? HIGH : LOW);
for (int i = 0; i < 50 && !peerPaired; i++) {
delay(10);
}
tickDisplay();
}
setStatus("Peer found!", "Completing handshake...");
Serial.println("Paired! Sending beacons for grace period...");
for (int i = 0; i < 30; i++) {
sendDiscoveryBeacon();
delay(100);
}
digitalWrite(LED_PIN, LOW);
if (esp_now_is_peer_exist(broadcastAddr)) {
esp_now_del_peer(broadcastAddr);
}
if (!esp_now_is_peer_exist(peerAddr)) {
memcpy(peerInfo.peer_addr, peerAddr, 6);
peerInfo.channel = 0;
peerInfo.encrypt = false;
esp_now_add_peer(&peerInfo);
}
deviceState = STATE_IDLE;
setStatus("Ready. Hold button to talk.");
Serial.println("Peer paired! Ready to talk.");
}
// =============================================
// RECORD
// =============================================
size_t recordAudio() {
deviceState = STATE_RECORDING;
setStatus("Recording...");
Serial.println("Recording...");
int16_t flushBuf[256];
size_t bytesRead;
for (int i = 0; i < DMA_BUF_COUNT; i++) {
i2s_read(I2S_MIC_PORT, flushBuf, sizeof(flushBuf), &bytesRead, portMAX_DELAY);
}
size_t sampleCount = 0;
int16_t readBuf[DMA_BUF_LEN];
digitalWrite(LED_PIN, HIGH);
unsigned long recStart = millis();
while (digitalRead(BUTTON_PIN) == LOW && sampleCount < MAX_SAMPLES) {
i2s_read(I2S_MIC_PORT, readBuf, sizeof(readBuf), &bytesRead, portMAX_DELAY);
size_t samplesRead = bytesRead / sizeof(int16_t);
size_t toCopy = min(samplesRead, (size_t)(MAX_SAMPLES - sampleCount));
memcpy(&txBuffer[sampleCount], readBuf, toCopy * sizeof(int16_t));
sampleCount += toCopy;
// Update display with recording duration periodically
if (millis() - lastDisplayUpdate >= 200) {
char buf[32];
float elapsed = (float)(millis() - recStart) / 1000.0f;
snprintf(buf, sizeof(buf), "%.1fs / %ds", elapsed, RECORD_MAX_SEC);
setStatus("Recording...", buf);
lastDisplayUpdate = millis();
}
}
float dur = (float)sampleCount / SAMPLE_RATE;
Serial.printf("Recorded %d samples (%.1fs)\n", sampleCount, dur);
int fade = min((size_t)200, sampleCount / 4);
for (int i = 0; i < fade; i++) {
txBuffer[i] = (int32_t)txBuffer[i] * i / fade;
txBuffer[sampleCount - 1 - i] = (int32_t)txBuffer[sampleCount - 1 - i] * i / fade;
}
digitalWrite(LED_PIN, LOW);
return sampleCount;
}
// =============================================
// LOW-LEVEL SEND
// =============================================
bool sendPacketWithRetry(uint8_t* packet, size_t packetLen) {
for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
sendBusy = true;
lastSendOk = false;
esp_err_t result = esp_now_send(peerAddr, packet, packetLen);
if (result != ESP_OK) {
sendBusy = false;
delay(5);
continue;
}
unsigned long t0 = millis();
while (sendBusy && (millis() - t0 < SEND_TIMEOUT_MS)) {
delayMicroseconds(200);
}
if (lastSendOk) return true;
delay(5);
}
return false;
}
void sendManifest(uint8_t msgId, uint16_t totalChunks, uint32_t totalSamples) {
uint8_t pkt[MANIFEST_SIZE];
pkt[0] = PKT_MANIFEST;
pkt[1] = msgId;
pkt[2] = totalChunks & 0xFF;
pkt[3] = (totalChunks >> 8) & 0xFF;
pkt[4] = totalSamples & 0xFF;
pkt[5] = (totalSamples >> 8) & 0xFF;
pkt[6] = (totalSamples >> 16) & 0xFF;
pkt[7] = (totalSamples >> 24) & 0xFF;
sendPacketWithRetry(pkt, MANIFEST_SIZE);
}
bool sendChunk(uint8_t msgId, uint16_t seq, uint16_t totalChunks, size_t sampleCount) {
uint8_t packet[ESPNOW_MAX_SIZE];
size_t offset = (size_t)seq * SAMPLES_PER_CHUNK;
size_t remaining = sampleCount - offset;
size_t chunkSamples = min((size_t)SAMPLES_PER_CHUNK, remaining);
size_t payloadBytes = chunkSamples * sizeof(int16_t);
packet[0] = PKT_AUDIO;
packet[1] = msgId;
packet[2] = seq & 0xFF;
packet[3] = (seq >> 8) & 0xFF;
packet[4] = totalChunks & 0xFF;
packet[5] = (totalChunks >> 8) & 0xFF;
memcpy(&packet[HEADER_SIZE], &txBuffer[offset], payloadBytes);
return sendPacketWithRetry(packet, HEADER_SIZE + payloadBytes);
}
// =============================================
// SEND AUDIO WITH REPAIR + FULL RETRY
// =============================================
bool sendAudioAttempt(size_t sampleCount, uint16_t totalChunks) {
// Send all chunks
uint16_t hwFail = 0;
for (uint16_t seq = 0; seq < totalChunks; seq++) {
if (!sendChunk(txMsgId, seq, totalChunks, sampleCount)) {
hwFail++;
}
delayMicroseconds(INTER_CHUNK_US);
// Update display progress every ~50 chunks
if (seq % 50 == 0) {
char buf[48];
snprintf(buf, sizeof(buf), "Chunk %d / %d", seq, totalChunks);
setStatus("Sending...", buf);
}
}
Serial.printf(" Chunks sent (%d HW failures)\n", hwFail);
// Repair rounds
for (int round = 0; round < MAX_REPAIR_ROUNDS; round++) {
nackReceived = false;
ackDoneReceived = false;
nackCount = 0;
sendManifest(txMsgId, totalChunks, (uint32_t)sampleCount);
Serial.printf(" Manifest sent (round %d) — waiting...\n", round + 1);
char buf[48];
snprintf(buf, sizeof(buf), "Verifying (round %d)...", round + 1);
setStatus("Sending...", buf);
unsigned long t0 = millis();
while (!nackReceived && !ackDoneReceived && (millis() - t0 < NACK_WAIT_MS)) {
delay(1);
}
if (ackDoneReceived) {
Serial.println(" ACK received — transfer complete!");
return true;
}
if (!nackReceived) {
Serial.println(" No response — assuming complete.");
return true;
}
if (nackCount == 0) {
Serial.println(" NACK with 0 missing — transfer complete!");
return true;
}
Serial.printf(" NACK: %d missing — retransmitting...\n", nackCount);
snprintf(buf, sizeof(buf), "Repairing %d chunks...", nackCount);
setStatus("Sending...", buf);
delay(POST_NACK_SETTLE);
for (uint16_t i = 0; i < nackCount; i++) {
uint16_t seq = nackIndices[i];
if (seq < totalChunks) {
sendChunk(txMsgId, seq, totalChunks, sampleCount);
delayMicroseconds(INTER_CHUNK_US);
}
}
}
// Final manifest
nackReceived = false;
ackDoneReceived = false;
sendManifest(txMsgId, totalChunks, (uint32_t)sampleCount);
unsigned long t0 = millis();
while (!nackReceived && !ackDoneReceived && (millis() - t0 < NACK_WAIT_MS)) {
delay(1);
}
if (ackDoneReceived) {
Serial.println(" ACK after final manifest — complete!");
return true;
}
return false;
}
void sendAudio(size_t sampleCount) {
uint16_t totalChunks = (sampleCount + SAMPLES_PER_CHUNK - 1) / SAMPLES_PER_CHUNK;
float dur = (float)sampleCount / SAMPLE_RATE;
deviceState = STATE_SENDING;
for (int fullAttempt = 0; fullAttempt < MAX_FULL_RETRIES; fullAttempt++) {
txMsgId++;
Serial.printf("Sending %d samples in %d chunks (msg %d, attempt %d/%d)...\n",
sampleCount, totalChunks, txMsgId,
fullAttempt + 1, MAX_FULL_RETRIES);
char buf[48];
snprintf(buf, sizeof(buf), "%.1fs — attempt %d/%d",
dur, fullAttempt + 1, MAX_FULL_RETRIES);
setStatus("Sending...", buf);
if (sendAudioAttempt(sampleCount, totalChunks)) {
deviceState = STATE_IDLE;
setStatus("Sent!", "Ready. Hold button to talk.");
delay(1000);
setStatus("Ready. Hold button to talk.");
return;
}
Serial.printf(" Attempt %d failed.\n", fullAttempt + 1);
if (fullAttempt < MAX_FULL_RETRIES - 1) {
char buf2[48];
snprintf(buf2, sizeof(buf2), "Retry in %dms...", FULL_RETRY_DELAY);
setStatus("Send failed", buf2);
Serial.printf(" Waiting %dms before full retry...\n", FULL_RETRY_DELAY);
delay(FULL_RETRY_DELAY);
}
}
Serial.println(" ALL ATTEMPTS FAILED — giving up.");
deviceState = STATE_IDLE;
setStatus("Send failed!", "Ready. Hold button to talk.");
delay(2000);
setStatus("Ready. Hold button to talk.");
}
// =============================================
// RECEIVER RESPONSES
// =============================================
void sendNack(uint8_t msgId) {
uint16_t missing[MAX_NACK_INDICES];
uint16_t count = 0;
for (uint16_t i = 0; i < rxTotalChunks && count < MAX_NACK_INDICES; i++) {
if (!rxChunkMap[i]) missing[count++] = i;
}
uint8_t pkt[ESPNOW_MAX_SIZE];
pkt[0] = PKT_NACK;
pkt[1] = msgId;
pkt[2] = count & 0xFF;
pkt[3] = (count >> 8) & 0xFF;
for (uint16_t i = 0; i < count; i++) {
size_t pos = NACK_HEADER + i * 2;
pkt[pos] = missing[i] & 0xFF;
pkt[pos + 1] = (missing[i] >> 8) & 0xFF;
}
sendPacketWithRetry(pkt, NACK_HEADER + count * 2);
Serial.printf(" Sent NACK: %d missing of %d total (%d received)\n",
count, rxTotalChunks, rxChunksReceived);
char buf[48];
snprintf(buf, sizeof(buf), "Receiving... %d/%d chunks", rxChunksReceived, rxTotalChunks);
setStatus("Incoming message", buf);
}
void sendAckDone(uint8_t msgId) {
uint8_t pkt[2] = { PKT_ACK_DONE, msgId };
sendPacketWithRetry(pkt, sizeof(pkt));
Serial.println(" Sent ACK_DONE");
}
// =============================================
// PLAYBACK
// =============================================
void playAudio(int16_t* buffer, size_t sampleCount) {
float dur = (float)sampleCount / SAMPLE_RATE;
deviceState = STATE_PLAYING;
char buf[32];
snprintf(buf, sizeof(buf), "%.1f seconds", dur);
setStatus("Playing...", buf);
Serial.printf("Playing %d samples (%.1fs)...\n", sampleCount, dur);
digitalWrite(LED_PIN, HIGH);
size_t bytesWritten;
size_t offset = 0;
while (offset < sampleCount) {
size_t toWrite = min((size_t)DMA_BUF_LEN, sampleCount - offset);
i2s_write(I2S_DAC_PORT, &buffer[offset], toWrite * sizeof(int16_t),
&bytesWritten, portMAX_DELAY);
offset += bytesWritten / sizeof(int16_t);
}
int16_t silence[DMA_BUF_LEN] = {0};
for (int i = 0; i < DMA_BUF_COUNT; i++) {
i2s_write(I2S_DAC_PORT, silence, sizeof(silence), &bytesWritten, portMAX_DELAY);
}
Serial.println("Playback done!");
digitalWrite(LED_PIN, LOW);
deviceState = STATE_IDLE;
setStatus("Ready. Hold button to talk.");
}
// =============================================
// SETUP
// =============================================
void setup() {
Serial.begin(115200);
tft.init(135, 240);
tft.setRotation(3);
pinMode(TFT_BACKLITE, OUTPUT);
digitalWrite(TFT_BACKLITE, HIGH);
deviceState = STATE_BOOTING;
setStatus("Booting...");
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
// Allocate buffers
txBuffer = (int16_t*)ps_malloc(MAX_SAMPLES * sizeof(int16_t));
rxBuffer = (int16_t*)ps_malloc(MAX_SAMPLES * sizeof(int16_t));
if (!txBuffer) txBuffer = (int16_t*)malloc(MAX_SAMPLES * sizeof(int16_t));
if (!rxBuffer) rxBuffer = (int16_t*)malloc(MAX_SAMPLES * sizeof(int16_t));
size_t maxChunks = (MAX_SAMPLES + SAMPLES_PER_CHUNK - 1) / SAMPLES_PER_CHUNK;
rxChunkMap = (bool*)calloc(maxChunks, sizeof(bool));
if (!txBuffer || !rxBuffer || !rxChunkMap) {
Serial.println("ERROR: Memory allocation failed!");
setStatus("MEMORY ERROR!", "Cannot allocate buffers");
while (1) { delay(100); }
}
memset(rxBuffer, 0, MAX_SAMPLES * sizeof(int16_t));
setStatus("Booting...", "Initializing audio...");
setupMicI2S();
setupDacI2S();
// WiFi + ESP-NOW
WiFi.mode(WIFI_STA);
WiFi.disconnect();
if (esp_now_init() != ESP_OK) {
Serial.println("ERROR: ESP-NOW init failed!");
setStatus("ESP-NOW ERROR!", "Init failed");
while (1) { delay(100); }
}
esp_now_register_send_cb(onDataSent);
esp_now_register_recv_cb(onDataRecv);
Serial.printf("MAC: %s\n", WiFi.macAddress().c_str());
Serial.println("========================================");
Serial.println("ESP-NOW Walkie Talkie");
Serial.printf(" Sample rate: %d Hz | Max: %ds\n", SAMPLE_RATE, RECORD_MAX_SEC);
Serial.println("========================================");
char macBuf[48];
snprintf(macBuf, sizeof(macBuf), "MAC: %s", WiFi.macAddress().c_str());
setStatus("Booting...", macBuf);
delay(500);
waitForPeer();
Serial.println("Hold button to record, release to send.");
Serial.println("Incoming audio plays automatically.");
}
// =============================================
// MAIN LOOP
// =============================================
void loop() {
// Button press -> record and send
if (digitalRead(BUTTON_PIN) == LOW) {
delay(50);
if (digitalRead(BUTTON_PIN) == HIGH) return;
txSampleCount = recordAudio();
if (txSampleCount > SAMPLE_RATE / 5) {
sendAudio(txSampleCount);
} else {
Serial.println("Too short, not sending.");
deviceState = STATE_IDLE;
setStatus("Too short!", "Hold longer to record.");
delay(1000);
setStatus("Ready. Hold button to talk.");
}
while (digitalRead(BUTTON_PIN) == LOW) { delay(10); }
delay(300);
}
// Got manifest but missing chunks? Wait settle time, then respond.
if (rxGotManifest && !rxComplete) {
if (millis() - rxManifestTime >= RX_SETTLE_MS) {
rxGotManifest = false;
if (rxChunksReceived >= rxTotalChunks) {
rxSampleCount = rxExpectedSamples;
rxComplete = true;
sendAckDone(rxMsgId);
} else {
sendNack(rxMsgId);
}
}
}
// Complete message -> play it
if (rxComplete) {
Serial.printf("Received message %d (%d/%d chunks, %d samples)\n",
rxMsgId, rxChunksReceived, rxTotalChunks, rxSampleCount);
playAudio(rxBuffer, rxSampleCount);
rxComplete = false;
rxGotManifest = false;
}
// Periodic display refresh (updates RSSI etc while idle)
tickDisplay();
delay(1);
}
How the Code Works
On boot, the TFT display, I2S amp and I2S microphone are initialized. The ESP-NOW connection is established and a discovery beacon is broadcast. After the beacon is received by another device, the device is registered as an ESP-NOW peer and the connection is fully established between the two walkie talkies.
When a recording is initiated, it is saved as a sample buffer. After the recording finishes, the buffer is sliced into 122-sample chunks. These chunks are sent as a unicast with a slight delay in between each packet to the paired walkie talkie via ESP-NOW. At the end, a manifest packet with the total packet length and message ID is sent.
The receiving walkie talkie checks the received packets against the manifest packet and if all lengths match, it sends back a received packet to confirm that the message was fully received. If that confirmation is not received, then the sending walkie talkie will try again. The receiving walkie talkie plays the message via I2S out to the speaker.
Page last edited April 14, 2026
Text editor powered by tinymce.
Wiring
Wire the mic data lines to the Feather:
- Mic BCLK to Feather D5 (blue wire)
- Mic DOUT to Feather D9 (yellow wire)
- Mic LRCL to Feather D6 (green wire)
Wire the amp data lines to the Feather:
- Amp LRC to Feather D11 (blue wire)
- Amp BCLK to Feather D10 (yellow wire)
- Amp DIN to Feather D12 (green wire)
Solder a wire to the Feather GND pin. Insert the other end of the wire into the GND pin on the amp, but don't solder it yet.
Take a second wire and solder it into the GND pin on the amp. This solders both GND wires in place. The wire should be long enough to reach the toggle switch.
Wire the toggle switch GND pin to the push button GND pin. Run the wire behind the Feather stand-offs. This will help with wire management when closing up the case.
Page last edited April 14, 2026
Text editor powered by tinymce.
Assembly
Secure the speaker wires into the terminal block on the amp.
- Speaker negative to amp terminal block - (black wire)
- Speaker positive to amp terminal block + (red wire)
Secure the Feather to its standoffs. Use M2.5 screws for the mounting holes closest to the three buttons and M2 screws for the mounting holes closest to the reset button.
Plug in the battery to the Feather after it is mounted.
Page last edited April 14, 2026
Text editor powered by tinymce.
Use
Turn on the walkie talkies with the toggle switch. The walkie talkies will pair right after booting.
Press and hold down the push button to record a message. The maximum length for a message is 10 seconds. When you release the button, the audio packet is sent to the paired walkie talkie.
Page last edited April 14, 2026
Text editor powered by tinymce.