All projects
Voice agentFebruary to May 2026Illustrative

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.

02

Demo

Lunastandby

Incoming call from Child (age 6)

0 / 9

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.

03

How it works

Architecture diagram
  1. 01

    WebSocket instead of HTTP so audio streams both ways without buffering a whole clip.

  2. 02

    PCM output instead of MP3 removes the decoder from the microcontroller.

  3. 03

    Conversation history per client so follow-up questions work.

  4. 04

    System prompt limits replies to one to three sentences because this is a voice channel.

  5. 05

    Hardware: INMP441 mic, MAX98357A amp, one button; schematic and PCB in KiCad.

05

Stack and code

server_pcm.py
"""
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
/*
 * 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