Maker.io main logo

ESP-NOW Walkie Talkies

162

2026-06-30 | By Adafruit Industries

License: See Original Project Amplifiers Displays LCD / TFT Microcontrollers Sound Arduino Adafruit Feather

Courtesy of Adafruit

Guide by Liz Clark

Overview

arduino_compatibles_handshake

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.

case_1

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.

Circuit Diagram

diagram_2

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)

3D Printing

printing_3

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:

Direct .STL Download (ZIP File)

Download from Printables

The main case can be printed vertically with tree supports.

arduino_compatibles_video_2026-04-07_03-30-34_1_2_3

The case lid has standoffs for the amp, microphone and Feather. It also has mounting clips for the battery.

lid_4

The main case has cutouts for the Feather buttons and TFT, microphone and speaker. The case lid standoffs position the components at the proper distance from the cutouts.

case_5

Arduino IDE Setup

The ESP32-S2/S3 bootloader does not have USB serial support for Windows 7 or 8. (See https://github.com/espressif/arduino-esp32/issues/5994) please update to version 10 which is supported by espressif! Alternatively, you can try this community-crafted Windows 7 driver (https://github.com/kutukvpavel/Esp32-Win7-VCP-drivers).

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.

Arduino IDE Download

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.

menu_6

A dialog will pop up just like the one shown below.

dialog_7

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

manager_8

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.

install_9

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.

board_10

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.

Make sure to press the reset button after uploading code from the Arduino IDE to the ESP32-S3!

Code the Walkie Talkies

code_11

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.

ESP-NOW_Walkie_Talkies.uf2

Load the UF2

Click the link above to download the UF2 file.

Save it on your computer wherever it is convenient for you.

download_12

board_13

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.

drag_14

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.

Code

Download File

Copy Code
// 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);
}

View on GitHub

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.

Wiring

wiring_15

You can use the case lid as a jig for wiring up the components.

jig_16

Power

Solder a wire from the amp's Vin to the Feather's 3.3V (red wire).

solder_17

Solder a wire from the mic's 3V to the Feather's 3.3V (red wire).

solder_17a

I2S Data

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_18

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)

wire_19

Button Prep

Connect the button LED GND to the button GND. The LED GND is marked with a raised minus (-) symbol.

button_20

Mount the toggle switch and the push button into the case.

mount_21

Shared GND

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.

shared_22

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.

second_23

Solder the other end of the GND wire to the center pin on the toggle switch.

end_24

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.

toggle_25

Solder a second wire to the LED GND on the push button.

second_26

Solder the other end of the wire to GND on the microphone.

microphone_27

Push Button

Solder a wire to the push button output pin (blue wire).

blue_28

Solder the other end of the wire to Feather D13.

feather_29

Solder a wire to the LED positive pin (+) on the push button (yellow wire).

positive_30

Solder the other end of the wire to Feather pin A0.

pin_31

Toggle Switch Enable

Solder a wire from the third pin on the toggle switch to EN pin on the Feather (green wire).

switch_32

Assembly

Cut and strip the two wires from the speaker.

strip_33

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_34

Mount the Amp, Mic and Battery

Attach the amp to its standoffs with two M2.5 screws.

attach_35

Attach the mic to its standoffs with two M2.5 screws.

mic_36

Insert the battery into its clip. Make sure the battery wires are pointing towards the amp.

battery_37

Feather

w.FL connectors are fragile and do not have many connect/disconnect cycles. Exercise caution installing the connector to the Feather.

Attach the w.FL connector to the Feather. The connector clicks onto the port on the Feather.

connector_38

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.

standoffs_39

Mount the Speaker

Remove the adhesive from the front of the speaker. Insert it into the speaker mount in the case.

remove_40

RP-SMA Plug

Insert the RP-SMA plug into the top mounting hole on the case. Secure it with its included nuts.

plug_41

Close It Up

Place the lid onto the case. Use four M3 screws to secure it.

place_42

Antenna

Attach the external antenna to the RP-SMA plug.

antenna_43

Use

arduino_compatibles_pairingOnBoot

Turn on the walkie talkies with the toggle switch. The walkie talkies will pair right after booting.

arduino_compatibles_closeUpPairing

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.

arduino_compatibles_recordCloseUp

Release the push to talk button after recording your message to send it.

Once the packet is received, the walkie talkie will play the audio message through its speaker.

arduino_compatibles_playbackCloseUp

 

Mfr Part # 6303
ESP32-S3 REVERSE TFT FEATHER
Adafruit Industries LLC
Mfr Part # 5444
COAX CBL RP-SMA TO W.FL MHF3
Adafruit Industries LLC
Mfr Part # 945
2.4GHZ DIPOLE SWIVEL ANTENNA WIT
Adafruit Industries LLC
Mfr Part # 3006
EVAL BOARD FOR MAX98357A
Adafruit Industries LLC
Mfr Part # 6049
ADAFRUIT I2S MEMS MICROPHONE BRE
Adafruit Industries LLC
Mfr Part # 3923
SPEAKER 8OHM 1W TOP PORT
Adafruit Industries LLC
16MM ILLUMINATED PUSHBUTTON - YE
Mfr Part # 1441
16MM ILLUMINATED PUSHBUTTON - YE
Adafruit Industries LLC
Mfr Part # 3221
MINI PANEL MOUNT SPDT TOGGLE SWI
Adafruit Industries LLC
Mfr Part # 1781
BATTERY LITH-ION 3.7V 2.2AH 1865
Adafruit Industries LLC
Mfr Part # 5044
CABLE A PLUG TO C PLUG 6.5'
Adafruit Industries LLC
Add all DigiKey Parts to Cart
Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.