Community project

LoRa Push-To-Talk Handheld

ESP32
Photo of LoRa Push-To-Talk Handheld
Generated with AI

AndiService Electronics

Published September 25, 2026

Build a pair of long-range wireless walkie-talkies using LoRa radio technology and an ESP32 microcontroller. This project combines voice transmission over the 433 MHz ISM band with real-time audio compression, letting two operators communicate across distances of several kilometers with just a push-to-talk button.

The guide provides a complete wiring diagram, full parts list, and step-by-step assembly instructions for constructing two identical units. Firmware with ADPCM audio codec, LoRa packet handling, and an OLED status display is included, along with battery charging and power management setup.

Wiring diagram

Wiring diagram for LoRa Push-To-Talk Handheld

Gather all the parts

QtyComponent
1

SX1278 LoRa RF Module (Ra-02)

433 MHz

Ai-Thinker Ra-02 style LoRa module based on Semtech SX1278, typically used at 433 MHz. Uses SPI plus reset and DIO interrupt pins. Operates at 3.3V logic only.

1

HiLetgo INMP441 I²S Microphone Module

I2S MEMS microphone

Omnidirectional 24-bit I²S MEMS microphone module based on the TDK InvenSense INMP441 IC. Outputs digital audio over a 3-wire I²S bus (SCK/BCLK, WS/LRCLK, SD/DOUT). Supply 1.8–3.3 V; native 3.3 V operation with no level shifting required. L/R channel select pin: tie to GND for left-channel mono output or VDD for right-channel mono output. Use the ESP32 Arduino core or ESP-IDF I2S peripheral API; no separate PlatformIO library is required.

1

MAX98357A I2S Class-D Mono Amplifier Breakout

3 W mono

I2S-input Class-D mono audio amplifier IC on a compact breakout board. Accepts I2S digital audio input (BCLK, LRC, DIN) and drives a small speaker or transducer directly. No I2C/SPI control bus is needed. The amplifier supply range is 2.5V-5.5V, and the I2S input pins are compatible with 3.3V logic. SD/MODE controls shutdown and channel selection; GAIN selects 3 dB, 6 dB, 9 dB, 12 dB, or 15 dB gain.

1

8Ω Speaker

8 Ω, 1 W

Generic small 8Ω 0.5-3W loudspeaker (~28mm typical). Pair with an I2S amp (MAX98357A) or class-D amp (TPA3116D2) for usable volume; do not drive directly from a GPIO pin. Audio output for music/voice playback.

1

SSD1306 OLED

0.96 in, 128×64

0.96 inch 128x64 OLED display with I2C interface

1

Push Button

PTT

Momentary push button switch

1

Lithium Ion Battery - 3.7V 2000mAh

3.7 V, 2000 mAh

3.7V 2000mAh lithium-ion polymer battery with JST-PH connector and protection circuitry. Output ranges from 4.2 V fully charged to 3.0 V cutoff.

1

TP4056 Li-Ion/LiPo charger module with protection

protected, USB charging

TP4056 single-cell Li-Ion/LiPo linear charger module, 5V USB input, 1A charge current (programmable). Common variants ship with DW01 protection. Pair with battery_lipo_storage for the cell.

1

3.3v Buck Boost

3.3 V regulated

TI TPS63030/TPS63031 high-efficiency single-inductor buck-boost converter family with 1A switches. Used to hold a regulated rail when battery voltage crosses above and below the target output.

1

SPST Power Switch

SPST

A small on-off switch that disconnects battery power from the electronics while charging or programming.

Assemble it in 6 steps

1. Siapkan dua unit yang sama

Buat dua perangkat dengan daftar bagian dan sambungan yang sama. Sebuah handy talky tidak dapat diuji sendirian: satu unit mengirim saat tombol PTT ditekan, dan unit kedua menerima suara.

  • Gunakan satu antena 433 MHz pada masing-masing modul Ra-02 sebelum menyalakan perangkat.
  • Pasang semua modul di meja atau breadboard dahulu; pindahkan ke kotak hanya setelah kedua unit berkomunikasi.
  • Jangan menyalakan atau memancarkan Ra-02 tanpa antena 433 MHz karena bagian radio dapat rusak.

2. Buat jalur daya dan pengisian

Hubungkan kabel merah baterai ke B+ modul TP4056 dan kabel hitam baterai ke B- (daya baterai). Hubungkan OUT+ TP4056 ke VIN regulator dan OUT- TP4056 ke GND regulator (daya masuk regulator). Hubungkan VOUT regulator ke IN sakelar daya, lalu OUT sakelar ke rel 3V3 semua modul (daya yang dapat dimatikan). Hubungkan semua GND modul ke rel GND dari OUT- TP4056 (jalur balik listrik). Masukkan kabel USB 5 V hanya ke IN+ dan IN- TP4056 saat mengisi baterai.

  • Gunakan TP4056 enam-pad bertanda B+/B- dan OUT+/OUT- agar perlindungan baterainya dipakai.
  • Pastikan regulator benar-benar keluaran 3,3 V dan sanggup sedikitnya 500 mA.
  • Jangan hubungkan baterai langsung ke ESP32, OLED, atau Ra-02; baterai penuh dapat mencapai 4,2 V dan dapat merusak bagian 3,3 V.
  • Jangan sambungkan kabel USB pengisian ke OUT+ atau OUT- TP4056; sambungkan hanya ke IN+ dan IN- agar modul pengisi tidak rusak.

3. Sambungkan modul radio dan antena

Hubungkan Ra-02 VCC ke 3V3 (power), GND ke GND (ground), MOSI ke GPIO23 (data), MISO ke GPIO19 (data), SCK ke GPIO18 (clock), NSS ke GPIO4 (pilih radio), RESET ke GPIO26 (mengatur ulang radio), dan DIO0 ke GPIO33 (memberi tahu ESP32 saat radio selesai). Pasang antena 433 MHz ke konektor antena Ra-02.

  • Jaga kabel antara Ra-02 dan ESP32 pendek, terutama kabel 3V3 dan GND.
  • GPIO4 dipakai sebagai kabel pemilih radio; ESP32 dapat memakai pin biasa untuk tugas ini.
  • Ra-02 hanya boleh diberi 3,3 V — memasukkannya ke 5 V dapat merusak modul radio.

4. Pasang layar, tombol bicara, dan mikrofon

Hubungkan OLED VCC ke 3V3 (power), GND ke GND (ground), SDA ke GPIO21 (data), dan SCL ke GPIO22 (clock). Hubungkan satu kaki tombol PTT ke GPIO27 (signal) dan kaki lainnya ke GND (ground). Hubungkan INMP441 VDD ke 3V3 (power), GND ke GND (ground), SCK ke GPIO14 (clock), WS ke GPIO25 (clock), SD ke GPIO13 (data suara), dan L/R ke GND (memilih suara mono kiri).

  • Jika layar kosong, periksa tulisan kecil di modul OLED; rancangan ini memakai alamat layar umum 0x3C.
  • Tombol tidak memerlukan resistor tambahan karena firmware memakai penahan sinyal di dalam ESP32.
  • Pastikan VCC dan GND layar serta mikrofon tidak tertukar — daya yang terbalik dapat merusak modul.

5. Sambungkan penguat dan speaker

Hubungkan MAX98357A VIN ke 3V3 (power), GND ke GND (ground), BCLK ke GPIO14 (clock), LRC ke GPIO25 (clock), dan DIN ke GPIO32 (data suara). Hubungkan SPK+ penguat ke terminal positif speaker dan SPK- ke terminal negatif speaker (keluaran suara).

  • Kabel BCLK GPIO14 dan LRC GPIO25 memang dipakai bersama oleh mikrofon dan penguat.
  • Gunakan speaker 8 ohm paling tidak 1 W; suara dari speaker biasa terdengar lebih jelas daripada buzzer.
  • Jangan hubungkan SPK- ke GND; kedua kabel speaker berasal dari penguat dan menghubungkannya ke GND dapat merusak penguat.

6. Nyalakan dan uji dua perangkat

Periksa sekali lagi tidak ada kabel 5 V yang masuk ke rel 3V3. Nyalakan sakelar, lalu gunakan USB papan ESP32 saat menekan Deploy di Schematik. Setelah firmware ada pada dua perangkat, tekan dan tahan PTT di unit pertama sambil berbicara dekat mikrofon; unit kedua harus berada dalam keadaan mendengarkan.

  • Pada layar akan tertulis READY atau LISTENING saat siap menerima dan TALKING saat tombol ditekan.
  • Jauhkan kedua antena sekitar 1 meter ketika pengujian awal agar penerima tidak terlalu dekat dengan pemancar.
  • Aturan frekuensi dan daya radio berbeda menurut negara; gunakan frekuensi, antena, dan daya pancar yang diizinkan di lokasi Anda.

Review all connections

1. Connections between "lora_radio" and "ESP32"

Functionlora_radioESP32
powerVCC3V3
groundGNDGND
spiMOSIGPIO 23
spiMISOGPIO 19
spiSCKGPIO 18
digitalRESETGPIO 26
digitalDIO0GPIO 33
spiNSSGPIO 4

2. Connections between "mic" and "ESP32"

FunctionmicESP32
powerVDD3V3
groundGNDGND
groundL/RGND
digitalSCK → MAX98357A I2S Class-D Mono Amplifier Breakout BCLKEXT
digitalWS → MAX98357A I2S Class-D Mono Amplifier Breakout LRCEXT
dataSDGPIO 13

3. Connections between "audio_amp" and "ESP32"

Functionaudio_ampESP32
powerVIN3V3
groundGNDGND
dataBCLKGPIO 14
dataLRCGPIO 25
dataDINGPIO 32
dataSPK+ → 8Ω Speaker POSEXT
dataSPK- → 8Ω Speaker NEGEXT

4. Connections between "screen" and "ESP32"

FunctionscreenESP32
powerVCC3V3
groundGNDGND
i2cSDAGPIO 21
i2cSCLGPIO 22

5. Connections between "ptt_button" and "ESP32"

Functionptt_buttonESP32
groundGNDGND
digitalSIGNALGPIO 27

6. Connections between "battery" and "ESP32"

FunctionbatteryESP32
powerBAT+ → TP4056 Li-Ion/LiPo charger module with protection B+EXT
groundBAT- → TP4056 Li-Ion/LiPo charger module with protection B-EXT

7. Connections between "charger" and "ESP32"

FunctionchargerESP32
powerIN+ → 5 V USB charging connectorEXT
groundIN- → USB charging connector groundEXT
powerOUT+ → 3.3v Buck Boost VINEXT
groundOUT- → 3.3v Buck Boost GNDEXT

8. Connections between "regulator" and "ESP32"

FunctionregulatorESP32
powerVOUT → SPST Power Switch INEXT

9. Connections between "power_switch" and "ESP32"

Functionpower_switchESP32
powerOUT3V3

Deploy the firmware

#include <Arduino.h>
#include <SPI.h>
#include <LoRa.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include "driver/i2s.h"


// Hoisted type definitions
struct AdpcmState {
  int predictor;
  int index;
};


// Forward declarations
void showState(const String &state, const String &detail);
uint8_t encodeSample(int sample, AdpcmState &state);
int decodeSample(uint8_t code, AdpcmState &state);
void configureAudio();
void transmitVoicePacket();
void playVoicePacket();

constexpr int LORA_CS = 4;
constexpr int LORA_RST = 26;
constexpr int LORA_DIO0 = 33;
constexpr int LORA_SCK = 18;
constexpr int LORA_MISO = 19;
constexpr int LORA_MOSI = 23;
constexpr int OLED_SDA = 21;
constexpr int OLED_SCL = 22;
constexpr int PTT_PIN = 27;
constexpr int I2S_BCLK = 14;
constexpr int I2S_LRCK = 25;
constexpr int I2S_DOUT = 32;
constexpr int I2S_DIN = 13;

constexpr long LORA_FREQUENCY = 433E6;
constexpr uint16_t SAMPLE_RATE = 4000;
constexpr size_t SAMPLES_PER_PACKET = 160; // 40 ms of voice
constexpr uint8_t PACKET_MARKER = 0x54;
constexpr uint8_t PACKET_BYTES = 83;

Adafruit_SSD1306 display(128, 64, &Wire, -1);
uint8_t packetNumber = 0;
String shownState;

const int indexTable[16] = {
  -1, -1, -1, -1, 2, 4, 6, 8,
  -1, -1, -1, -1, 2, 4, 6, 8
};
const int stepTable[89] = {
  7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 25, 28, 31,
  34, 37, 41, 45, 50, 55, 60, 66, 73, 80, 88, 97, 107, 118, 130,
  143, 157, 173, 190, 209, 230, 253, 279, 307, 337, 371, 408, 449,
  494, 544, 598, 658, 724, 796, 876, 963, 1060, 1166, 1282, 1411,
  1552, 1707, 1878, 2066, 2272, 2499, 2749, 3024, 3327, 3660, 4026,
  4428, 4871, 5358, 5894, 6484, 7132, 7845, 8630, 9493, 10442,
  11487, 12635, 13899, 15289, 16818, 18500, 20350, 22385, 24623,
  27086, 29794, 32767
};



void showState(const String &state, const String &detail) {
  String key = state + detail;
  if (key == shownState) return;
  shownState = key;
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(2);
  display.setCursor(0, 4);
  display.println("HANDY");
  display.println("TALKY");
  display.setTextSize(1);
  display.setCursor(0, 44);
  display.print(state);
  display.setCursor(0, 55);
  display.print(detail);
  display.display();
}

uint8_t encodeSample(int sample, AdpcmState &state) {
  int step = stepTable[state.index];
  int diff = sample - state.predictor;
  uint8_t code = 0;
  if (diff < 0) {
    code = 8;
    diff = -diff;
  }
  int delta = step >> 3;
  if (diff >= step) { code |= 4; diff -= step; delta += step; }
  step >>= 1;
  if (diff >= step) { code |= 2; diff -= step; delta += step; }
  step >>= 1;
  if (diff >= step) { code |= 1; delta += step; }
  if (code & 8) state.predictor -= delta;
  else state.predictor += delta;
  state.predictor = constrain(state.predictor, -32768, 32767);
  state.index = constrain(state.index + indexTable[code], 0, 88);
  return code;
}

int decodeSample(uint8_t code, AdpcmState &state) {
  int step = stepTable[state.index];
  int delta = step >> 3;
  if (code & 4) delta += step;
  if (code & 2) delta += step >> 1;
  if (code & 1) delta += step >> 2;
  if (code & 8) state.predictor -= delta;
  else state.predictor += delta;
  state.predictor = constrain(state.predictor, -32768, 32767);
  state.index = constrain(state.index + indexTable[code & 0x0F], 0, 88);
  return state.predictor;
}

void configureAudio() {
  i2s_config_t config = {};
  config.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX | I2S_MODE_RX);
  config.sample_rate = SAMPLE_RATE;
  config.bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT;
  config.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT;
  config.communication_format = I2S_COMM_FORMAT_STAND_I2S;
  config.intr_alloc_flags = 0;
  config.dma_buf_count = 8;
  config.dma_buf_len = 128;
  config.use_apll = false;
  config.tx_desc_auto_clear = true;
  config.fixed_mclk = 0;
  i2s_driver_install(I2S_NUM_0, &config, 0, nullptr);

  i2s_pin_config_t pins = {};
  pins.bck_io_num = I2S_BCLK;
  pins.ws_io_num = I2S_LRCK;
  pins.data_out_num = I2S_DOUT;
  pins.data_in_num = I2S_DIN;
  i2s_set_pin(I2S_NUM_0, &pins);
  i2s_zero_dma_buffer(I2S_NUM_0);
}

void transmitVoicePacket() {
  int32_t samples[SAMPLES_PER_PACKET];
  size_t bytesRead = 0;
  i2s_read(I2S_NUM_0, samples, sizeof(samples), &bytesRead, portMAX_DELAY);
  if (bytesRead != sizeof(samples)) return;

  uint8_t payload[PACKET_BYTES] = {};
  int16_t first = (int16_t)(samples[0] >> 16);
  AdpcmState state = { first, 0 };
  payload[0] = PACKET_MARKER;
  payload[1] = packetNumber++;
  payload[2] = (uint8_t)(first & 0xFF);
  payload[3] = (uint8_t)((first >> 8) & 0xFF);
  for (size_t i = 1; i < SAMPLES_PER_PACKET; i++) {
    int16_t input = (int16_t)(samples[i] >> 16);
    uint8_t nibble = encodeSample(input, state);
    size_t byteIndex = 4 + (i - 1) / 2;
    if ((i - 1) & 1) payload[byteIndex] |= nibble << 4;
    else payload[byteIndex] = nibble;
  }

  LoRa.idle();
  LoRa.beginPacket();
  LoRa.write(payload, sizeof(payload));
  LoRa.endPacket();
  LoRa.receive();
}

void playVoicePacket() {
  int length = LoRa.parsePacket();
  if (length != PACKET_BYTES) return;
  uint8_t payload[PACKET_BYTES];
  for (int i = 0; i < PACKET_BYTES; i++) {
    if (!LoRa.available()) return;
    payload[i] = (uint8_t)LoRa.read();
  }
  if (payload[0] != PACKET_MARKER) return;

  AdpcmState state = { (int16_t)(payload[2] | (payload[3] << 8)), 0 };
  int32_t out[SAMPLES_PER_PACKET];
  out[0] = ((int32_t)state.predictor) << 16;
  for (size_t i = 1; i < SAMPLES_PER_PACKET; i++) {
    uint8_t packed = payload[4 + (i - 1) / 2];
    uint8_t nibble = ((i - 1) & 1) ? (packed >> 4) : (packed & 0x0F);
    out[i] = ((int32_t)decodeSample(nibble, state)) << 16;
  }
  size_t written = 0;
  i2s_write(I2S_NUM_0, out, sizeof(out), &written, portMAX_DELAY);
}

void setup() {
  pinMode(PTT_PIN, INPUT_PULLUP);
  Wire.begin(OLED_SDA, OLED_SCL);
  display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
  showState("Starting", "433 MHz");

  configureAudio();
  SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, LORA_CS);
  LoRa.setPins(LORA_CS, LORA_RST, LORA_DIO0);
  if (!LoRa.begin(LORA_FREQUENCY)) {
    showState("RADIO ERROR", "check Ra-02");
    while (true) delay(1000);
  }
  LoRa.setTxPower(17);
  LoRa.setSignalBandwidth(500E3);
  LoRa.setSpreadingFactor(6);
  LoRa.setCodingRate4(5);
  LoRa.enableCrc();
  LoRa.receive();
  showState("READY", "hold PTT");
}

void loop() {
  if (digitalRead(PTT_PIN) == LOW) {
    showState("TALKING", "release to listen");
    transmitVoicePacket();
  } else {
    showState("LISTENING", "press PTT");
    playVoicePacket();
  }
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik