ANTIGRAVITY LABJP
Articles/Integrations
Integrations/2026-05-04Intermediate

Building Voice AI Apps with ElevenLabs and Antigravity — A Practical Development Guide

A hands-on tutorial for integrating ElevenLabs API into Antigravity projects, covering text-to-speech streaming, speech-to-text conversion, and building a voice AI assistant app. Includes real working code and cost planning for indie developers.

ElevenLabsVoice AITTSSTTAPI IntegrationIndie Development6

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 install

Implementing 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 voice

Playing 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 file

For 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.local

ElevenLabs 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.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Integrations2026-07-11
Nobody Is There to Say Yes: Writing Unified Permissions as an Unattended Contract
The v2.2.1 unified permission system assumes a person is watching. On a 3 a.m. scheduled run, that assumption quietly breaks. Here is how I declared the policy up front as an allow / deny / queue contract so an unattended agent never stalls on a prompt no one can answer.
Integrations2026-07-09
Calling Local LLMs from Antigravity — Ollama and LM Studio Integration in Practice
Treating Antigravity as a cloud-LLM-only tool? Pairing it with Ollama or LM Studio opens up real options for confidential projects and cost-sensitive workloads. Here's the practical configuration and operational knowledge.
Integrations2026-07-08
Antigravity Now Saves OAuth Tokens to the OS Keyring — Keeping Auth Alive in Headless Runs
In v2.2.1, refreshed OAuth tokens are saved to the OS keyring automatically. Pleasant on the desktop, but on headless scheduled runs the vault itself may not exist and auth quietly breaks. We design explicit backend selection, a safe file fallback, and per-location liveness checks.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →