Full voice loop on an ESP32-S3
Built for
children aged 4 to 10, and a hardware startup pitch built around it
Project
Luno voice assistant
Press the button, talk, hear Luna answer through a 3-watt speaker. Firmware handles I2S mic and amp; the Python server handles transcription, a child-safe prompt, and TTS converted to PCM for easy playback. Custom PCB designed in KiCad.
Demo
Incoming call from Child (age 6)
Live signal
awaiting first turn…
- turn
- 0/9
- agent replies
- 0
Illustrative exchange, authored from Luna's real system prompt.
Illustrative transcript authored from the agent's real system prompt; no model call.
How it works
01
WebSocket instead of HTTP so audio streams both ways without buffering a whole clip.
02
PCM output instead of MP3 removes the decoder from the microcontroller.
03
Conversation history per client so follow-up questions work.
04
System prompt limits replies to one to three sentences because this is a voice channel.
05
Hardware: INMP441 mic, MAX98357A amp, one button; schematic and PCB in KiCad.
Stack and code
- ESP32-S3
- Arduino C++
- I2S
- Python websockets
- OpenAI Whisper
- gpt-4o-mini
- ElevenLabs
- KiCad
server_pcm.py— The server loop and Luna's prompt.
"""
Kids Voice Assistant Backend Server - PCM Audio Version
This version sends PCM audio instead of MP3 for easier ESP32 playback.
WebSocket server that handles:
1. Audio streaming from ESP32
2. OpenAI Whisper transcription
3. ChatGPT response generation
4. ElevenLabs TTS synthesis (converted to PCM)
5. Audio streaming back to ESP32
"""
import asyncio
import json
import os
import io
import struct
import subprocess
from datetime import datetime
from dotenv import load_dotenv
import websockets
from openai import OpenAI
import httpx
# Load environment variables
load_dotenv()
# Configuration
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
ELEVENLABS_API_KEY = os.getenv("ELEVENLABS_API_KEY")
ELEVENLABS_VOICE_ID = os.getenv("ELEVENLABS_VOICE_ID", "21m00Tcm4TlvDq8ikWAM")
# Initialize OpenAI client
openai_client = OpenAI(api_key=OPENAI_API_KEY)
# System prompt for kid-friendly responses
SYSTEM_PROMPT = """You are Luna, a friendly and helpful voice assistant for children aged 4-10.
Your responses should be:
- Simple and easy to understand
- Fun and engaging
- Educational when appropriate
- Safe and age-appropriate
- Short (1-3 sentences max) since this is a voice conversation
- Enthusiastic and encouraging
Never discuss anything inappropriate for children. If asked about something unsuitable,
gently redirect to a fun, educational topic."""
# Store conversation history per client
conversations = {}
class AudioBuffer:
"""Buffer for accumulating audio chunks"""
def __init__(self):
self.chunks = []
self.sample_rate = 16000
def add_chunk(self, data: bytes):
self.chunks.append(data)
def get_audio(self) -> bytes:
return b''.join(self.chunks)
def clear(self):
self.chunks = []
def duration_seconds(self) -> float:
total_bytes = sum(len(c) for c in self.chunks)
samples = total_bytes // 2
return samples / self.sample_rate
async def transcribe_audio(audio_data: bytes) -> str:
"""Transcribe audio using OpenAI Whisper"""
try:
wav_buffer = io.BytesIO()
sample_rate = 16000
bits_per_sample = 16
num_channels = 1
byte_rate = sample_rate * num_channels * bits_per_sample // 8
block_align = num_channels * bits_per_sample // 8
data_size = len(audio_data)
wav_buffer.write(b'RIFF')
wav_buffer.write(struct.pack('<I', 36 + data_size))
wav_buffer.write(b'WAVE')
wav_buffer.write(b'fmt ')
wav_buffer.write(struct.pack('<I', 16))
wav_buffer.write(struct.pack('<H', 1))
~/Documents/Luno_Websocket/backend/server_pcm.py
KidsVoiceAssistant_PCM.ino— ESP32-S3 firmware: I2S capture and playback over WebSocket.
/*
* Kids Voice Assistant - ESP32-S3 with PCM Audio
*
* Hardware:
* - ESP32-S3 DevKit
* - INMP441 I2S Microphone (with transistor power control)
* - MAX98357A I2S Amplifier + Speaker
* - Push Button
*
* Wiring (Custom Configuration):
* Microphone (INMP441):
* VDD -> 3.3V (through transistor controlled by MIC_POWER_PIN)
* GND -> Transistor (controlled by GPIO 2)
* SD -> GPIO 6
* WS -> GPIO 15
* SCK -> GPIO 18
* L/R -> GND
*
* Speaker (MAX98357A):
* DIN -> GPIO 17
* BCLK -> GPIO 5
* LRC -> GPIO 4
*
* Button -> GPIO 21
*/
#include <WiFi.h>
#include <WebSocketsClient.h>
#include <ArduinoJson.h>
#include <driver/i2s.h>
// ============ CONFIGURATION - EDIT THESE ============
// WiFi credentials
const char* WIFI_SSID = "Unavailable";
const char* WIFI_PASSWORD = "punjab123";
// WebSocket server - Use your computer's local IP address
// Find it with: ifconfig (Mac/Linux) or ipconfig (Windows)
// Example: 192.168.1.100, 192.168.0.50, 10.0.0.5, etc.
const char* WS_HOST = "192.168.1.23547"; // <-- CHANGE THIS to your computer's IP
const uint16_t WS_PORT = 8765;
// ============ PIN DEFINITIONS (YOUR WIRING) ============
#define BUTTON_PIN 21 // Physical button pin
#define MIC_POWER_PIN 2 // Transistor controlling mic GND
// I2S Ports
#define MIC_I2S_PORT I2S_NUM_0
#define SPK_I2S_PORT I2S_NUM_1
// Microphone pins (INMP441)
#define I2S_MIC_WS 15 // Word Select (LRCLK)
#define I2S_MIC_SCK 18 // Bit Clock
#define I2S_MIC_SD 6 // Data
// Speaker pins (MAX98357A)
#define I2S_SPK_WS 4 // Word Select (LRC)
#define I2S_SPK_SCK 5 // Bit Clock (BCLK)
#define I2S_SPK_DIN 17 // Data In
// Status LED (optional - uses built-in if available)
#define LED_PIN 48 // Built-in LED on many ESP32-S3 boards, change if needed
// ============ AUDIO SETTINGS ============
#define SAMPLE_RATE 16000
#define I2S_BUFFER_LEN 1024
#define RECORD_SECONDS 10
#define RECORD_BUFFER_SIZE (SAMPLE_RATE * RECORD_SECONDS)
// ============ STATE MACHINE ============
enum State {
IDLE,
RECORDING,
PROCESSING,
RECEIVING_AUDIO,
PLAYING
};
State state = IDLE;
// ============ GLOBAL VARIABLES ============
WebSocketsClient webSocket;
// Recording buffer
int16_t* recordBuffer = nullptr;
~/Documents/Luno_Websocket/esp32/KidsVoiceAssistant_PCM/KidsVoiceAssistant_PCM.ino