Build a streaming, low‑latency voice agent powered by:
- Murf – real‑time streaming text‑to‑speech (TTS)
- AssemblyAI – real‑time speech‑to‑text (STT) with turn detection
- Gemini (Google genai) – LLM for generating responses
- FastAPI + WebSockets – backend and bi‑directional streaming
- Browser Audio APIs – microphone capture and seamless playback
This repo is a starter project for developers who want to build conversational voice agents using the Murf streaming TTS API.
One conversation turn works like this:
- You click Start Recording in the browser.
- The browser captures microphone audio, converts it to 16‑bit PCM, and streams it to the backend over a WebSocket (
/ws). - The FastAPI backend forwards the audio stream to AssemblyAI, which returns partial and final transcripts and detects when you stop speaking.
- When a final transcript is received, the backend:
- Sends the transcript back to the browser for display.
- Sends the transcript to Gemini, streaming back text chunks.
- Streams those text chunks to Murf over a WebSocket.
- Murf generates audio as text arrives, sending back audio chunks.
- The backend forwards audio chunks to the browser, which plays them in real time.
Everything is streaming end‑to‑end, so you see partial transcripts and hear audio responses with minimal latency.
-
FastAPI app (
app/main.py)- Loads environment variables via
dotenv. - Creates the FastAPI application.
- Serves static assets from
/assets. - Includes:
uirouter → serves the simple HTML UI.ws_chatrouter → provides the main/wsWebSocket.
- Loads environment variables via
-
Frontend (browser)
- HTML is served from the
uirouter. - JavaScript:
recorder.js: client logic for recording, WebSocket connection, transcript display, and audio playback.pcm-processor.js: anAudioWorkletthat converts microphone audio into 16‑bit PCM chunks.
- HTML is served from the
-
Backend services
AssemblyClient(assembly_transcribe.py): streams raw audio to AssemblyAI and handles transcription events (partial + final).GeminiLLM(gemini_llm.py): integrates with Gemini viagoogle-genaiand supports streaming text responses.- WebSocket orchestrator (
ws_chat.py): accepts the browser WebSocket, connects to Murf via WebSocket, and orchestrates the full flow.
-
External APIs
- AssemblyAI – real‑time streaming STT with built‑in turn detection.
- Gemini – LLM used to generate responses from user transcripts.
- Murf – streaming TTS that accepts text chunks and returns audio chunks.
-
End‑to‑end streaming
- Browser → backend: 16‑bit PCM audio over WebSocket.
- Backend → AssemblyAI: streaming audio via Assembly’s client.
- Backend → Murf: streaming text via WebSocket.
- Murf → backend → browser: streaming audio chunks for playback.
-
Low latency
- Partial transcripts appear as the user speaks.
- Turn detection (via AssemblyAI) avoids manual silence timers.
- Gemini streams text chunks instead of a single large response.
- Murf starts generating audio as soon as it sees text.
- Browser plays audio chunks as they arrive, not after the full response.
-
Full‑duplex behavior
- The backend can simultaneously:
- Receive audio from the browser.
- Stream it to AssemblyAI.
- Process transcripts and stream text to Murf.
- Stream Murf audio back to the browser.
- The backend can simultaneously:
- Python: 3.11+
- Browser: modern browser with:
AudioContextAudioWorkletWebSocket
You’ll also need valid API keys for:
ASSEMBLYAI_API_KEYGEMINI_API_KEYMURF_API_KEY
git clone <this-repo-url>
cd murf-voice-agent-starterCreate a .env file in the project root (you can copy from .env.example if present) and set:
ASSEMBLYAI_API_KEY=your_assemblyai_api_key
GEMINI_API_KEY=your_gemini_api_key
MURF_API_KEY=your_murf_api_keyThese are used by AssemblyClient, GeminiLLM, and the Murf WebSocket connection in ws_chat.py.
This project uses pyproject.toml and uv.lock:
uv syncThis will create a virtual environment and install:
fastapi[standard]assemblyaigoogle-genaimurfwebsockets
(If you prefer pip, you can create a venv and install equivalent dependencies manually.)
Using uv:
uv run fastapi dev app/main.pyOr with uvicorn (if installed):
uv run uvicorn app.main:app --reloadThe app exposes:
GET /ui/– minimal HTML UI with Start/Stop buttons.GET /assets/recorder.js– JS client for recording and playback.WebSocket /ws– main streaming audio/text endpoint.
In your browser, navigate to:
http://localhost:8000/ui/
Then:
- Click Start Recording.
- Grant microphone permissions.
- Speak and watch the live transcript appear.
- After you stop speaking, hear a Murf‑generated response based on Gemini’s output.
In recorder.js:
- Requests microphone access using
navigator.mediaDevices.getUserMedia({ audio: true }). - Creates an
AudioContextat 16 kHz (matching AssemblyAI). - Loads
pcm-processor.jsas anAudioWorkletand connects it to the microphone stream. pcm-processor.js:- Receives float samples.
- Buffers and converts them to 16‑bit PCM (
Int16Array). - Posts chunks back to the main thread.
- For each PCM chunk, if the WebSocket is open, it sends
pcmData.bufferdirectly to/wsas binary data.
In websocket_endpoint (ws_chat.py):
- Accepts the WebSocket from the browser.
- Creates
AssemblyClient()andGeminiLLM(). - Reads
MURF_API_KEYand opens a WebSocket to Murf:wss://global.api.murf.ai/v1/speech/stream-input?...
- Calls:
await assembly_client.start(
websocket,
send_to_client=send_to_client,
on_final_transcript=on_final_transcript,
)- Enters a loop:
while True:
data = await websocket.receive_bytes()
if data:
await assembly_client.process_audio(data)In AssemblyClient:
- Uses
StreamingClientfromassemblyai. - Registers event callbacks for
Begin,Turn,Error, andTermination. - Streams bytes to AssemblyAI with
self._streaming_client.stream(audio_data)for real‑time transcription and turn detection.
When AssemblyAI emits a TurnEvent:
- If
event.transcriptis empty → ignored. - If
event.end_of_turnis false:- Treat as partial transcript.
- Sends to browser via
send_to_client({"type": "partial_transcript", "text": event.transcript}).
- If
event.end_of_turnis true:- Treat as final transcript for the user turn.
- Schedules
on_final_transcript(event.transcript)on the FastAPI event loop.
This provides:
- Live partial text while the user speaks.
- A final, stable transcript when the user’s turn ends.
Inside on_final_transcript in ws_chat.py:
- Sends the final transcript to the browser:
await send_to_client({"type": "final_transcript", "text": transcript})- Logs that it’s generating an LLM response.
- Sends a Murf voice configuration message once per turn:
voice_config_msg = {
"voice_config": {
"voiceId": "en-US-amara",
},
"context_id": MURF_CONTEXT_ID,
}
await murf_ws.send(json.dumps(voice_config_msg))- Streams LLM text chunks from
GeminiLLM.generate_streaming_response(transcript):- For each chunk:
text_message = {"text": chunk, "end": False, "context_id": MURF_CONTEXT_ID}
await murf_ws.send(json.dumps(text_message))- After all chunks:
- If the response is very short, waits briefly so Murf has time to start TTS.
- Sends a final message to signal the end of the text stream:
final_message = {"text": "", "end": True, "context_id": MURF_CONTEXT_ID}
await murf_ws.send(json.dumps(final_message))Murf now has everything it needs to synthesize the response.
In handle_murf_audio_stream (background task in ws_chat.py):
- Waits on
murf_ws.recv()in a loop. - For each message:
- Parses JSON.
- If
"audio"is present:- Increments a chunk counter.
- Sends:
await send_to_client({"type": "audio_chunk", "audio_data": data["audio"]})- If
data.get("final")is true:- Sends
{"clear": True, "context_id": MURF_CONTEXT_ID}to Murf to clear context. - Sends
{"type": "speech_complete"}to the browser.
- Sends
In the browser (recorder.js):
-
On
"audio_chunk":- Decodes base64 WAV audio.
- Skips the 44‑byte WAV header on the first chunk.
- Converts 16‑bit PCM into float samples.
- Enqueues buffers into an
AudioContextand schedules them back‑to‑back using aplayheadTimeso they play seamlessly.
-
On
"speech_complete":- Updates the UI to show the response is done.
- Returns to a “ready for next turn” state.
-
Change the Murf voice
- In
ws_chat.py, update the"voiceId"field invoice_config_msgto any Murf voice ID available to your account.
- In
-
Adjust LLM behavior
- In
gemini_llm.py, modify:- The
modelname (e.g., use a different Gemini model). - How you construct prompts (e.g., include persona, instructions, or conversation history).
- The
- In
-
Extend the UI
- Edit
recorder.jsand the HTML in theuirouter to add:- Conversation history panes.
- Voice or language selectors.
- Visualizations of audio or latency.
- Edit
Use this starter as the base for your own Murf‑powered voice agents, adapting the pipeline, prompts, and UI for your specific product or demo.