Have you ever wanted to add a human-like voice to your app? When you try to implement text-to-speech (TTS), the browser's built-in speech synthesis sounds robotic, and platforms like Google TTS or Amazon Polly can feel overly complex with unpredictable pricing. At least, that was my experience until I started working with ElevenLabs.
The API design is clean, and when you delegate the initial implementation to Antigravity, you can have a working voice feature up and running within a few hours. In this guide, I'll walk you through real, tested code to get you started with voice AI app development.
Why ElevenLabs Works Well with Antigravity
ElevenLabs is an AI voice platform that specializes in human-like speech synthesis. As of May 2026, the free plan allows up to 10,000 characters per month — enough to prototype without spending anything.
The reason ElevenLabs pairs naturally with Antigravity is that both the Python and TypeScript SDKs are well-documented. When you ask Antigravity to "implement streaming TTS using the ElevenLabs Python SDK," it reliably generates code that matches the current SDK version without needing much correction.
Here's a quick pricing reference (May 2026):
- Starter: $5/month, 30,000 characters/month
- Creator: $22/month, 100,000 characters/month
- Pro: $99/month, 500,000 characters/month
For indie developers, estimating your per-user character consumption before choosing a plan is worth the effort. If the average conversation is around 200 characters, the Creator plan covers roughly 500 conversations per month.
Project Setup
Start by giving Antigravity this prompt to scaffold the initial structure:
Create a Python + FastAPI backend using the ElevenLabs API.
Feature: a streaming text-to-speech (TTS) endpoint
Environment variable: ELEVENLABS_API_KEY
Include a requirements.txt as well
Based on Antigravity's output, here's the directory structure we'll be working with:
voice-app/
├── backend/
│ ├── main.py
│ ├── requirements.txt
│ └── .env
└── frontend/
├── app/
│ └── page.tsx
└── package.json
Install the dependencies directly in Antigravity's terminal:
# Backend
pip install elevenlabs fastapi uvicorn python-dotenv
# Frontend (Next.js)
npm installImplementing Text-to-Speech with Streaming
The key to a good TTS experience is streaming — the user hears audio starting almost immediately, rather than waiting for the entire file to generate first.
# backend/main.py
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from elevenlabs.client import ElevenLabs
from pydantic import BaseModel
import os
from dotenv import load_dotenv
load_dotenv()
app = FastAPI()
# CORS setup for local development
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_methods=["*"],
allow_headers=["*"],
)
client = ElevenLabs(api_key=os.getenv("ELEVENLABS_API_KEY"))
class TTSRequest(BaseModel):
text: str
voice_id: str = "JBFqnCBsd6RMkjVDRZzb" # "George" - default voice
@app.post("/api/tts")
async def text_to_speech(request: TTSRequest):
"""
Streams audio chunks from ElevenLabs to the client.
The frontend plays the audio using the Web Audio API.
"""
def audio_stream():
# generate() returns a generator; audio arrives in chunks
audio_generator = client.generate(
text=request.text,
voice=request.voice_id,
model="eleven_turbo_v2_5", # Low-latency model — ideal for indie apps
stream=True,
)
for chunk in audio_generator:
if chunk:
yield chunk
return StreamingResponse(
audio_stream(),
media_type="audio/mpeg",
headers={"Transfer-Encoding": "chunked"},
)Verify it works with a quick test:
uvicorn main:app --reload
# → Starts at http://localhost:8000
# Test with curl
curl -X POST http://localhost:8000/api/tts \
-H "Content-Type: application/json" \
-d '{"text": "Hello from Antigravity Lab"}' \
--output test.mp3
# Play test.mp3 — you should hear a natural-sounding voicePlaying Audio in the Frontend
Here's a Next.js component that fetches the audio stream from the backend and plays it in the browser:
// app/page.tsx
"use client";
import { useState } from "react";
export default function VoiceApp() {
const [text, setText] = useState("");
const [isPlaying, setIsPlaying] = useState(false);
const [error, setError] = useState<string | null>(null);
const handlePlay = async () => {
if (!text.trim()) return;
setIsPlaying(true);
setError(null);
try {
const response = await fetch("/api/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
// Stream → ArrayBuffer → Blob → Audio object
const audioData = await response.arrayBuffer();
const audioBlob = new Blob([audioData], { type: "audio/mpeg" });
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
audio.play();
audio.onended = () => {
setIsPlaying(false);
URL.revokeObjectURL(audioUrl); // Important: free the memory
};
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
setIsPlaying(false);
}
};
return (
<main className="max-w-xl mx-auto p-8">
<h1 className="text-2xl font-bold mb-4">Voice AI Demo</h1>
<textarea
className="w-full border rounded p-2 mb-4 h-32"
placeholder="Enter text to read aloud..."
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button
onClick={handlePlay}
disabled={isPlaying}
className="bg-blue-600 text-white px-6 py-2 rounded disabled:opacity-50"
>
{isPlaying ? "Playing..." : "▶ Play Audio"}
</button>
{error && <p className="text-red-500 mt-2">{error}</p>}
</main>
);
}One thing to watch out for: the URL.revokeObjectURL() call after playback ends. Antigravity sometimes omits this in first-pass generated code, and skipping it causes memory to accumulate with each playback. Always add it to the onended handler.
Adding Speech-to-Text with Scribe
ElevenLabs also offers Scribe, their speech-to-text model. Here's the backend endpoint that accepts a recorded audio file and returns a transcript:
# Add to backend/main.py
from fastapi import UploadFile, File
import tempfile
import os
@app.post("/api/stt")
async def speech_to_text(audio: UploadFile = File(...)):
"""
Accepts an uploaded audio file and returns the transcript.
The frontend sends a Blob recorded via MediaRecorder.
"""
# Save to a temp file — the ElevenLabs SDK requires a file path
with tempfile.NamedTemporaryFile(
delete=False, suffix=".webm"
) as tmp_file:
content = await audio.read()
tmp_file.write(content)
tmp_path = tmp_file.name
try:
with open(tmp_path, "rb") as f:
result = client.speech_to_text.convert(
file=f,
model_id="scribe_v1",
language_code="en", # Set to your target language
)
return {"text": result.text}
finally:
os.unlink(tmp_path) # Always clean up the temp fileFor the frontend microphone recording, ask Antigravity: "Create a React component that records microphone input using MediaRecorder and sends the audio to /api/stt." It handles the browser permission flow and the FormData upload in one shot.
API Key Security and Cost Monitoring
Two things that bite indie developers early: API key exposure and surprise charges.
Keep your .env file out of version control:
# .gitignore
.env
.env.localElevenLabs has a usage dashboard where you can check monthly character consumption. I recommend asking Antigravity to add a simple logging middleware that writes request counts to a local file during development. That way you have a rough character count before checking the dashboard.
For model selection, I prefer eleven_turbo_v2_5 during development (lower latency, lower cost) and switch to eleven_multilingual_v2 for production if voice quality is a priority. The turbo model is good enough for most internal tooling and prototypes.
Next Step — Start with One Notification
The smallest useful starting point is converting a single in-app notification into audio. Set up the /api/tts endpoint, wire it to one message your app already shows users, and deploy it. Real usage data will tell you more about latency and audio quality than any amount of local testing.
From there, Antigravity can help you extend the feature: "change the voice to a Japanese female speaker," "add caching so we don't re-generate audio for the same text," or "add a volume control to the player." Each of these is a focused two-minute conversation.
For related API integration patterns, see the Gemini Live API Voice Agent Implementation Guide and Custom Agent Integration with External APIs.