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…
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
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
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
Summary Notes
-
On the free tier, ElevenLabs works only with premade voices and the eleven_multilingual_v2 model.
-
ElevenLabs returns audio as an MP3 file. Convert it to Base64 and embed it in an HTML <audio> tag to play it in Streamlit.
-
Use st.button(“🔊”, key=f”tts_{idx}”) for the Read Aloud button. Give each button a unique key.
-
Streamlit reruns the entire script on every interaction. Store chat messages in st.session_state to keep the chat history.
-
Audio is generated only when the user clicks the 🔊 button, which helps reduce API usage.
-
To avoid repeated API calls, store the generated audio in st.session_state.
-
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