← Back to list

Build a Voice-Enabled AI Chatbot: Text-to-Speech with ElevenLabs, Streamlit & LangGraph

This article shows how to add a Read Aloud feature to a LangGraph chatbot built with Streamlit. The chatbot uses an LLM (GPT-4o-mini) for…

Nachiket Mehendale in Data And Beyond · 2026-06-25 09:50 · 50 claps · 4.7 min read paywalled
#text-to-speech #read-aloud #ai-chatbot #elevenlabs-ai #langgraph-chatbot
Open on Medium ↗
Wiki topics: LLM · Large Language Models AGT · AI Agents MM · Multimodal & Generative Media

Building a Text-to-Speech Read Aloud Feature for AI Chatbots: ElevenLabs + Streamlit + LangGraph

This article shows how to add a Read Aloud feature to a LangGraph chatbot built with Streamlit. The chatbot uses an LLM (GPT-4o-mini) for responses and ElevenLabs for speech generation. This project uses the ElevenLabs free tier.

Tech Stack

Tech Stack

Tech Stack

How the Backend Works

The backend is implemented in backend.py. It defines a simple LangGraph workflow with a single node that sends messages to GPT-4o-mini and returns the response.

State Definition

The state holds the conversation history. add_messages automatically appends new messages to the existing list.

from langgraph.graph.message import add_messages
from typing import TypedDict, Annotated
from langchain_core.messages import BaseMessage

class chatState(TypedDict):
    Messages: Annotated[list[BaseMessage], add_messages]

Chat Node

The node receives the current state, passes all messages to the LLM, and returns the response. LangGraph automatically appends it to the state via the add_messages reducer.

llm = ChatOpenAI(model="gpt-4o-mini")

def chat_node_function(state: chatState):
    Chat_Messages = state['Messages']
    llm_response = llm.invoke(Chat_Messages)
    return {'Messages': llm_response}

Graph Structure

from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver

CheckPointer_instance = MemorySaver()

graph = StateGraph(chatState)
graph.add_node('chat_node', chat_node_function)
graph.add_edge(START, 'chat_node')
graph.add_edge('chat_node', END)

chatWorkflow = graph.compile(checkpointer=CheckPointer_instance)

MemorySaver keeps the conversation history in memory and separates it using thread_id, so each thread behaves as an independent chat session.

chatWorkflow is the compiled LangGraph workflow. This is the object imported and used by the Streamlit frontend. (Frontend.py)

from backend import chatWorkflow

This import is the link between the backend and frontend. TTS, audio playback, and the UI are all managed by the frontend.

Streaming

The frontend calls chatWorkflow.stream() with stream_mode=’messages’, which returns the response one token at a time. Streamlit’s st.write_stream() reads these tokens and displays them as they arrive.

ai_message = st.write_stream(
    message_chunk.content
    for message_chunk, metadata in chatWorkflow.stream(
        {'Messages': HumanMessage(content=user_input)},
        config={'configurable': {'thread_id': 'thread_001'}},
        stream_mode='messages',
    )
    if message_chunk.content
)

ElevenLabs Integration

Why ElevenLabs?

ElevenLabs offers realistic and natural-sounding voices. Its free plan lets you use premade voices through the REST API, and you don’t need a credit card to get started.

Important: Free Tier Rules

1. Use the correct model

The free tier supports only eleven_multilingual_v2. The older eleven_monolingual_v1 model is no longer available for free accounts.

2. Use only premade voices

With the free tier, you can access only ElevenLabs’ built-in premade voices through the API.

Community voices (such as those in the Professional or Library categories) are not available on the free plan and will return a 402 Payment Required error, even if they are visible in your dashboard.

To find the premade voices available on the free tier, I used the following code:

import requests

r = requests.get(
    "https://api.elevenlabs.io/v1/voices",
    headers={"xi-api-key": "provide_your_elevenlabs_api_key"}
)

for v in r.json()["voices"]:
    print(v["name"], "|", v["voice_id"], "|", v["category"])

Filter the results and use only voices with the category premade. These are the only voices that reliably work with the free-tier API. For this project, I used George (JBFqnCBsd6RMkjVDRZzb), a warm and engaging storyteller voice. It works well on the free tier and produces natural-sounding narration.

The TTS Function

The function takes the AI response text, calls the ElevenLabs API, and returns the audio as a base64-encoded MP3 string.

def text_to_speech_base64(text: str) -> str | None:
    response = requests.post(
        f"https://api.elevenlabs.io/v1/text-to-speech/{VOICE_ID}",
        headers={
            "xi-api-key": ELEVENLABS_API_KEY,
            "Content-Type": "application/json",
        },
        json={
            "text": text,
            "model_id": "eleven_multilingual_v2",
            "voice_settings": {
                "stability": 0.5,
                "similarity_boost": 0.75
            },
        },
        timeout=30,
    )
    if response.status_code == 200:
        return base64.b64encode(response.content).decode("utf-8")
    return None

Why Base64?

MP3 data is binary, while HTML expects text. Base64 converts the MP3 bytes into a text string that can be embedded directly in an HTML <audio> tag. The browser decodes and plays the audio automatically.

The Read Aloud Button

The button is created using Streamlit’s st.button() with an emoji label. No custom components or JavaScript are needed.

clicked = st.button("🔊", key=f"tts_{idx}", help="Read Aloud")

Each Read Aloud button is given a unique key, such as tts_0, tts_1, and so on, based on the message’s position in the chat history. This ensures that every button has a unique identifier and avoids duplicate key errors in Streamlit.

When a user clicks the button, the app generates speech from the message text and displays an audio player.

if clicked:
    with st.spinner("Generating audio..."):
        audio_b64 = text_to_speech_base64(message['content'])
    if audio_b64:
        st.markdown(f"""
        <audio autoplay style="width:100%;margin-top:4px;">
            <source src="data:audio/mp3;base64,{audio_b64}" type="audio/mp3">
        </audio>
        """, unsafe_allow_html=True)

The autoplay attribute starts playback automatically, and the audio player appears directly below the AI response.

Chat History and Session State

Streamlit reruns the entire script whenever a user interacts with the app. Without st.session_state, the chat history would be cleared on every rerun.

To keep messages between reruns, the chat history is stored as a list of dictionaries in st.session_state.

if 'message_history' not in st.session_state:
    st.session_state['message_history'] = []

Each message is stored as:

{'role': 'user' | 'assistant', 'content': '...'}

On each rerun, the app loops through the chat history and displays all messages. The 🔊 Read Aloud button is shown only for assistant responses.

One Detail Worth Noting

The ElevenLabs API is called only when the user clicks the 🔊 button, not when the AI generates a response.

1)No audio is generated unless the user clicks 🔊. 2)Longer responses may take a few seconds to convert, so st.spinner() shows a loading message. 3)Clicking 🔊 multiple times generates the audio again and makes a new API call each time.

For a production app, a good optimization is to cache the generated audio in st.session_state so it can be reused without calling the API again.

Common Errors and Their Fixes

Common Issues / Fixes

Common Issues / Fixes

Demo

We cannot demonstrate the actual audio output in this blog post. However, the GIF below shows the chatbot’s 🔊 Read Aloud feature and how the audio player is displayed and used.

GIF Featuring Read Aloud Feature in LangGraph-based chatbot

GIF Featuring Read Aloud Feature in LangGraph-based chatbot

Summary Notes

  1. On the free tier, ElevenLabs works only with premade voices and the eleven_multilingual_v2 model.

  2. ElevenLabs returns audio as an MP3 file. Convert it to Base64 and embed it in an HTML <audio> tag to play it in Streamlit.

  3. Use st.button(“🔊”, key=f”tts_{idx}”) for the Read Aloud button. Give each button a unique key.

  4. Streamlit reruns the entire script on every interaction. Store chat messages in st.session_state to keep the chat history.

  5. Audio is generated only when the user clicks the 🔊 button, which helps reduce API usage.

  6. To avoid repeated API calls, store the generated audio in st.session_state.

  7. Streamlit is great for demos and prototypes. For production applications, consider using FastAPI for the backend and Next.js for the frontend.

Thanks for reading! If you found this helpful, please Like and Follow for more tutorials.


메타데이터
post_id
6a2a83beba0e
slug
building-a-text-to-speech-read-aloud-feature-for-ai-chatbots-elevenlabs-streamlit-langgraph-6a2a83beba0e
url
https://medium.com/data-and-beyond/building-a-text-to-speech-read-aloud-feature-for-ai-chatbots-elevenlabs-streamlit-langgraph-6a2a83beba0e
canonical_url
https://medium.com/data-and-beyond/building-a-text-to-speech-read-aloud-feature-for-ai-chatbots-elevenlabs-streamlit-langgraph-6a2a83beba0e
author_url
https://medium.com/@nachiket4jan
status
ok
fetched_at
2026-08-01 20:32:42