To convert speech to text in Python, you send audio to a speech recognition model and get text back. The interesting decision is which kind of model, because a recorded file and a live conversation are different jobs. A file can wait for the whole clip. A caller can’t.
This guide covers three ways to do it, each with code that runs as written: transcribe a recorded file, stream audio with automatic turn detection, and transcribe your microphone live. The streaming examples use Cartesia Ink 2, the model we build for voice agents. Where a free local model is the better fit, we say so and show that too.
Pick an approach
| You have | Use | Why |
|---|---|---|
| Recorded files, no audio can leave | faster-whisper | Free, local, accurate on clean audio. Batch only |
| Recorded files, no GPU to manage | A batch STT API | One HTTP call per file |
| Live audio: an agent, captions, a phone | A streaming STT API with turn detection | Text arrives while the person talks, and the model says when they stop |
| A weekend script | The SpeechRecognition package | Fast to try. Its default Google key is for testing only |
Most tutorials stop at SpeechRecognition with recognize_google(). That package is a handy wrapper over a dozen engines, but its default Google backend uses a shared key that Google may revoke at any time, and it waits for a full phrase before it sends anything. Fine for a demo. Not something you want answering phones.
Prerequisites
- Python 3.9 or later
- For the Cartesia examples, an API key from the Cartesia playground and the SDK:
python -m pip install "cartesia[websockets]"
export CARTESIA_API_KEY="your-api-key"
The websockets extra is needed for the streaming examples. Keep the key in an environment variable or a secret manager, never in code you commit.
1. Transcribe a recorded file
If the audio is already recorded, send the whole file in one request. Cartesia’s batch endpoint accepts containers like WAV and MP3, and can return a timestamp for each word. Save this as transcribe_file.py:
import os
import sys
from cartesia import Cartesia
client = Cartesia(api_key=os.environ["CARTESIA_API_KEY"])
with open(sys.argv[1], "rb") as audio_file:
result = client.stt.transcribe(
file=audio_file,
model="ink-whisper",
language="en",
timestamp_granularities=["word"],
)
print(result.text)
for word in result.words or []:
print(f"{word.start:6.2f}s {word.word}")
Run it with python transcribe_file.py meeting.mp3. Batch transcription uses ink-whisper, which covers about 100 languages; pass the language code of your audio. The batch STT example in the docs has the async version.
The local alternative
If the audio can’t leave your machine, or you have a lot of it and a GPU, run Whisper yourself. faster-whisper reimplements OpenAI’s Whisper on CTranslate2 and runs on a laptop CPU:
python -m pip install faster-whisper
from faster_whisper import WhisperModel
model = WhisperModel("base.en", device="cpu", compute_type="int8")
segments, info = model.transcribe("jfk.flac", beam_size=5)
print(f"Detected language: {info.language} ({info.language_probability:.2f})")
for segment in segments:
print(f"[{segment.start:.2f}s -> {segment.end:.2f}s] {segment.text}")
On the 11-second JFK sample from the Whisper repo, the base.en model printed the quote word for word in about 2.5 seconds on a 4-core server CPU, after a one-time 140 MB model download. Larger models are more accurate and slower. What you don’t get is streaming: Whisper works on finished segments, so it’s the wrong tool for a conversation.
2. Stream audio and get turns back
A voice agent needs two things a batch model can’t give it: text while the person is still talking, and a decision about when they’ve finished. Ink 2 does both on one WebSocket. Its turn detection listens to what’s being said as well as to the silence, so a caller who pauses mid-sentence to think doesn’t get cut off just because the line went quiet.
This script streams a WAV file as if it were live audio. Save it as stream_file.py:
import os
import sys
import time
import wave
from cartesia import Cartesia
with wave.open(sys.argv[1], "rb") as wav:
if wav.getnchannels() != 1 or wav.getsampwidth() != 2:
raise SystemExit("Convert the file to 16-bit mono WAV first.")
sample_rate = wav.getframerate()
chunks = []
while data := wav.readframes(sample_rate // 10): # 100 ms per chunk
chunks.append(data)
client = Cartesia(api_key=os.environ["CARTESIA_API_KEY"])
transcript = ""
with client.stt.auto_finalize.websocket(
model="ink-2",
encoding="pcm_s16le",
sample_rate=sample_rate,
) as ws:
for chunk in chunks:
ws.send_raw(chunk)
time.sleep(0.1) # send at the speed of speech
ws.send({"type": "close"}) # flush buffered audio; the server then closes
turn_text = ""
for event in ws:
if event.type == "turn.update":
turn_text = event.transcript or turn_text # the whole turn so far
print("...", turn_text)
elif event.type == "turn.end":
text = event.transcript or turn_text # fall back if turn.end is empty
print("turn:", text)
transcript += text # join verbatim, no strip()
turn_text = ""
elif event.type == "error":
raise SystemExit(event.message)
print(transcript)
If your file isn’t 16-bit mono WAV, convert it first:
ffmpeg -i input.mp3 -ac 1 -ar 16000 -c:a pcm_s16le input.wav
Three details in that script are easy to get wrong:
- Raw samples, not a file. Realtime endpoints take audio bytes with no header, which is why the script reads frames out of the WAV instead of sending the file. You declare
encodingandsample_ratewhen you connect, and they have to match the bytes. The audio input guide notes the server might not return an error if they don’t, so a wrong rate shows up as a bad transcript, not an exception. - Real-time pacing. The
sleep(0.1)after each 100 ms chunk isn’t decoration. Realtime endpoints expect about one second of audio per second, and dumping a whole file into the socket can overload the server-side buffer. For a file you don’t need live, use the batch call from step 1. - Cumulative transcripts. Every
turn.updateholds the whole turn so far, not a new fragment. Concatenating updates repeats words. Take the text fromturn.end, once per turn, and join it exactly as it arrives. The script also keeps the latestturn.updateand uses it if aturn.endever arrives with an empty transcript, a case our own clients guard against.
To keep it short, this script reads events after it has sent the whole file, so everything prints at the end. The microphone example below sends and reads at the same time.
Ink 2 transcribes English, French, Hindi, Japanese and Spanish, and detects which one it’s hearing. For other languages on a live stream, see our speech-to-text model comparison.
3. Transcribe your microphone live
Same endpoint, real input. This version uses sounddevice to capture 16 kHz mono audio and the async client to send and receive at the same time:
python -m pip install sounddevice
On Linux you may also need PortAudio (sudo apt install libportaudio2). Save this as microphone.py:
import asyncio
import os
import sounddevice as sd
from cartesia import AsyncCartesia
SAMPLE_RATE = 16000
BLOCK = SAMPLE_RATE // 10 # 100 ms of audio per message
async def main():
loop = asyncio.get_running_loop()
audio = asyncio.Queue(maxsize=50) # about 5 s of audio
def enqueue(block):
if audio.full(): # the network fell behind: drop the oldest block
audio.get_nowait()
audio.put_nowait(block)
def on_audio(indata, frames, time_info, status):
loop.call_soon_threadsafe(enqueue, bytes(indata))
client = AsyncCartesia(api_key=os.environ["CARTESIA_API_KEY"])
async with client.stt.auto_finalize.websocket(
model="ink-2",
encoding="pcm_s16le",
sample_rate=SAMPLE_RATE,
) as ws:
async def send():
with sd.RawInputStream(
samplerate=SAMPLE_RATE,
blocksize=BLOCK,
channels=1,
dtype="int16",
callback=on_audio,
):
while True:
await ws.send_raw(await audio.get())
async def receive():
turn_text = ""
async for event in ws:
if event.type == "turn.start":
print("(listening)")
elif event.type == "turn.update":
turn_text = event.transcript or turn_text
elif event.type == "turn.end":
print("You said:", event.transcript or turn_text)
turn_text = ""
elif event.type == "error":
raise RuntimeError(event.message)
await asyncio.gather(send(), receive())
asyncio.run(main())
Run python microphone.py, say something, pause, and the transcript of that turn prints. Press Ctrl+C to stop.
The audio callback runs on a separate thread, so it hands each block to the event loop with call_soon_threadsafe instead of touching the socket directly. The microphone keeps sending while you’re quiet, which matters: the server treats missing audio as a network delay, not as silence, so a muted or paused input should send zeros rather than nothing. The queue is capped at about five seconds of audio: if the connection falls behind, the oldest blocks are dropped instead of piling up in memory, because a transcript of something said a minute ago is no use to a live conversation.
In a voice agent, turn.start is where you stop the bot talking, and turn.end is where you send the text to your LLM. To start the reply sooner, handle turn.eager_end too: begin generating when it fires, and throw the draft away if turn.resume follows. The turn detection docs list the thresholds if your callers pause a lot (or barely at all).
Common problems
| Symptom | Likely cause and fix |
|---|---|
| Transcript is gibberish or empty | Declared encoding or sample_rate doesn’t match the audio. Save the bytes you send and play them with ffplay -f s16le -ar 16000 -ac 1 audio.raw |
| Words appear two or three times | You joined turn.update events. Use turn.end only |
| The last second of speech is missing | You closed the socket without sending {"type": "close"}, or stopped reading events before the server closed |
| Turns never end while the mic is muted | You stopped sending audio. Send silence (PCM samples of value zero) instead |
| Server error partway through a file | Audio sent faster than real time. Pace the chunks, or use batch |
”You need to install cartesia[websockets]” | Install the extra: python -m pip install "cartesia[websockets]" |
| Phone audio transcribes badly | Phone lines usually carry 8 kHz μ-law. Declare encoding="pcm_mulaw" and sample_rate=8000 instead of converting; see G.711 explained |
What it costs
Local models cost compute and nothing else. On Cartesia, Ink 2 uses 3 credits per second of audio, and an hour of audio is $0.39 on the Scale plan; the free plan’s credits cover about an hour and 50 minutes of Ink 2. Current numbers are on the pricing page.
If you’d rather not run the pipeline yourself, Cartesia Managed Agents wire Ink, an LLM and Sonic text to speech onto a phone number for you. And if you only need to transcribe a handful of recordings, faster-whisper on your laptop is probably all you need.
Next steps
- Try Ink 2 on your own voice in the browser, no code, on the Ink page.
- Feed the transcripts into an LLM and speak the reply with Python text to speech.
- Put the pieces in a framework: build a Pipecat voice agent with Cartesia.
Related documentation
- Realtime speech-to-text quickstart
- Compare STT endpoints: auto, manual and batch
- Keyterm prompting for names and jargon the model should spell right
- Voice activity detection, and why turn detection replaces it