New AssemblyAI-TTS-1: Text-to-speech for voice agents. Learn more
Insights & Use Cases

Transcribe audio and video files with Python and Universal

Learn how to transcribe audio and video files in your Python applications with AssemblyAI's Universal speech recognition model.

Abstract green half-sphere illustration

Written by

Patrick Loeber

Published on

22 September 2026

You have a file. You want the words in it. That should take about ten lines of Python, and it does.

This tutorial builds it up properly: the minimal call, then the parts you need in production — catching a failed job, knowing which model ran, splitting the transcript by speaker, handling audio that switches language mid-sentence. Every snippet runs as written.

What you need: Python 3.8 or later, an API key, and an audio or video file. That is the whole list. No converting the file first, no chunking, no polling logic.

Install the SDK and set your API key

Install the Python SDK from PyPI. Everything below follows the pre-recorded quickstart, so the imports match the docs.

pip install assemblyai

Then put your API key in the environment rather than in the file. You can grab one from your dashboard the moment you sign up.

export ASSEMBLYAI_API_KEY=<your-key>

Hardcoding the key is the most common way these scripts end up in a public repo. Read it from the environment on line one and you never have to remember to take it out.

Your first transcript

The smallest thing that works. It points at a public URL, pins the model, and prints the text.

import os

from assemblyai.prerecorded.v2 import Transcriber, TranscriptionConfig

audio_file = "https://assembly.ai/wildfires.mp3"

config = TranscriptionConfig(
    speech_models=["universal-3-5-pro"],
)

transcriber = Transcriber(api_key=os.environ["ASSEMBLYAI_API_KEY"])
transcript = transcriber.transcribe(audio_file, config=config)

print(transcript.text)

Run it and you get a paragraph of text back. Two things are worth pausing on.

transcribe() does more than it looks like — it uploads the file if it is local, submits the job, polls until it finishes, and hands you the result. Hence no callback and no while loop. And the parameter is speech_models, plural, taking a list (see select the speech model). Streaming uses speech_model, singular, taking a string. That catches almost everyone who works both surfaces in the same week.

To transcribe a file on your own disk instead, change one line.

audio_file = "./example.mp3"

Everything else is identical. The SDK notices the local path and uploads it.

Every Snippet Here Runs On The Free Tier

Sign up free and your key works immediately, no card required — 185 hours of pre-recorded transcription on the free tier, enough to build and evaluate something real.

Sign up free

Handle the error status, because jobs do fail

The script above has a bug — the bug in almost every speech-to-text tutorial on the internet. It assumes the job succeeded.

Jobs fail. The URL 404s. The file is a video container with no audio track. When that happens you do not get an exception from transcribe() — you get a transcript object with an error status and a text of None, and your script sails on and writes None into your database.

Check the status explicitly.

import os

from assemblyai import TranscriptStatus
from assemblyai.prerecorded.v2 import Transcriber, TranscriptionConfig

audio_file = "https://assembly.ai/wildfires.mp3"

config = TranscriptionConfig(
    speech_models=["universal-3-5-pro"],
)

transcriber = Transcriber(api_key=os.environ["ASSEMBLYAI_API_KEY"])
transcript = transcriber.transcribe(audio_file, config=config)

print(f"Transcript ID: {transcript.id}")

if transcript.status == TranscriptStatus.error:
    raise RuntimeError(f"Transcription failed: {transcript.error}")

print(transcript.text)

Note where the transcript.id print sits: before the error check, not after. Log that ID on every request, not just failures. It is what you need to fetch the result again, retry, or delete the transcript, and it is the first thing support asks for. The troubleshooting guide maps error strings to causes.

Add speaker labels and language detection

Two options carry most of the value, one line each.

Speaker labels turn a wall of text into a conversation. Set speaker_labels=True and the result gains an utterances list, each carrying a speaker and their text. You do not tell it how many speakers there are — though if you know, the speaker diarization docs cover hinting at a count or a range, which helps on short audio.

Language detection is the one people skip and then regret. Set language_detection=True and you stop having to know a file’s language before you submit it — which matters more than it used to, because a speaker moving between Spanish and English inside one sentence now stays intact instead of collapsing at the switch.

Turn both on and read the utterances.

import os

from assemblyai import TranscriptStatus
from assemblyai.prerecorded.v2 import Transcriber, TranscriptionConfig

audio_file = "https://assembly.ai/wildfires.mp3"

config = TranscriptionConfig(
    speech_models=["universal-3-5-pro"],
    language_detection=True,
    speaker_labels=True,
)

transcriber = Transcriber(api_key=os.environ["ASSEMBLYAI_API_KEY"])
transcript = transcriber.transcribe(audio_file, config=config)

if transcript.status == TranscriptStatus.error:
    raise RuntimeError(f"Transcription failed: {transcript.error}")

for utterance in transcript.utterances:
    print(f"Speaker {utterance.speaker}: {utterance.text}")

Speakers come back as A, B, C. Mapping those to real names is your job, and it is usually trivial once you know the call format — the first speaker on a support call is the agent.

The complete script

Everything above in one file, with the model that ran printed back so you can confirm what you were billed for.

import os

from assemblyai import TranscriptStatus
from assemblyai.prerecorded.v2 import Transcriber, TranscriptionConfig

# A publicly accessible URL, or a local path like "./example.mp3"
audio_file = "https://assembly.ai/wildfires.mp3"

config = TranscriptionConfig(
    speech_models=["universal-3-5-pro"],
    language_detection=True,
    speaker_labels=True,
)

transcriber = Transcriber(api_key=os.environ["ASSEMBLYAI_API_KEY"])
transcript = transcriber.transcribe(audio_file, config=config)

print(f"Transcript ID: {transcript.id}")

if transcript.status == TranscriptStatus.error:
    raise RuntimeError(f"Transcription failed: {transcript.error}")

print(f"Model used: {transcript.json_response['speech_model_used']}")

print(f"\nFull transcript:\n\n{transcript.text}")

print("\nBy speaker:\n")
for utterance in transcript.utterances:
    print(f"Speaker {utterance.speaker}: {utterance.text}")

Save it, run it, and you have speaker attribution, automatic language handling, an error path that fails loudly, and a logged ID for every job.

What Universal-3.5 Pro actually gives you

The identifier you pinned above is universal-3-5-pro, at $0.21 per hour of audio. Here is what that buys, in numbers rather than adjectives, all from the Universal-3.5 Pro launch post.

18 languages with native code-switching, no configuration. English, Spanish, French, German, Italian, Portuguese, Arabic, Danish, Dutch, Finnish, Hebrew, Hindi, Japanese, Mandarin, Norwegian, Swedish, Turkish, and Vietnamese. The important word is native: the switch is handled inside a single utterance, rather than by detecting one language per file and routing to a per-language model — the architecture that loses the entity sitting at the switch point.

7.69 average normalized WER on the code-switching benchmark. Lower is better. On the same benchmark, ElevenLabs Scribe v2 posts 8.77, the previous-generation Universal-3 Pro 9.07, Deepgram Nova-3 Multilingual 12.22, and OpenAI GPT-4o Transcribe 44.58. That last figure is not a typo. A model can be strong at monolingual transcription and unusable the moment two languages share a sentence.

cpWER of 30.17 on diarization, averaged, against Deepgram Nova-3 English at 37.92, ElevenLabs Scribe v2 at 35.26, and Gladia at 36.87. cpWER punishes you for attributing the right words to the wrong speaker — the failure that makes a diarized transcript useless downstream. If you turned on speaker_labels above, that is the number that matters to you.

Need coverage beyond those 18? universal-2 is still there at $0.15/hr with 99+ languages — a previous-generation model kept for legacy integrations and long-tail languages. Reach for it when the language list is the binding constraint, not otherwise. Full list in the supported languages docs; pricing has the rest.

Want To Hear It On Your Own Audio First

Drop a file into the playground and read the transcript in the browser — no install, no API key, no code.

Try playground

Where to go next

Two directions from here.

Live audio. Everything above is the async path: finished file in, finished transcript out. If the audio arrives as it is spoken — a phone call, a meeting, a voice agent — that is a WebSocket, and a genuinely different program. Start with the streaming quickstart or the longer real-time transcription in Python walkthrough.

Structure, not just text. Speech Understanding runs on top of a transcript for summaries, chapters, action items, entity detection, sentiment, topic detection, and translation. When you want your own instructions instead, the LLM Gateway is one OpenAI-compatible endpoint across a large roster of models, with automatic fallback.

Here is what nobody tells you at the start: the transcription call is the easy part, and the last thing you will touch. The work is everything around it — what happens when a job fails at 2am, keeping the transcript IDs, mapping speaker A to a human. Which is the argument for writing the error check and the ID log now, in the ten-line version, rather than after the first bad week in production.

Requirements That Do Not Fit The Defaults

Volume, latency or compliance constraints worth talking through? Talk to an AI expert — faster than a docs crawl.

Talk to AI expert

Frequently asked questions

What audio and video file formats can I transcribe?

Most of them. Audio covers .mp3, .wav, .flac, .m4a, .aac, .ogg, .opus, .aiff, .wma and .amr among around thirty formats; video covers .mp4, .mov, .webm, .m4v, .mts, .m2ts, .ts and .mxf, with the audio track extracted and handled independently. Submit the file in its native format rather than transcoding it first — everything is converted to 16kHz internally anyway, so a conversion on your side just adds a generation of loss. The full list is in the docs.

How long does it take to transcribe a file?

You do not have to manage it. transcriber.transcribe() blocks and polls until the job reaches a terminal status, then returns the finished transcript, so a script written the way this post shows just waits and continues. The published limits are on size and length rather than time: one job accepts up to 5GB and up to 10 hours of audio, and a local upload can be up to 2.2GB. Processing files in bulk? Run the calls concurrently rather than in a loop — they are independent jobs, and serializing them buys you nothing.

How much does it cost?

Universal-3.5 Pro is $0.21 per hour of audio. Universal-2, the previous-generation model with 99+ language coverage, is $0.15 per hour. You are billed on audio duration, not request count or processing time, so a one-minute file costs the same whether the job takes two seconds or twenty. The free tier includes 185 hours of pre-recorded transcription — enough to build and evaluate something real before you put a card in. See pricing for the rest.

Which languages are supported, and do I have to specify one?

Universal-3.5 Pro covers 18: English, Spanish, French, German, Italian, Portuguese, Arabic, Danish, Dutch, Finnish, Hebrew, Hindi, Japanese, Mandarin, Norwegian, Swedish, Turkish, and Vietnamese. You do not have to specify one — set language_detection=True and the model works it out, including speakers who switch language mid-sentence. For a language outside those 18, universal-2 covers 99+ at $0.15/hr. Details in the supported languages docs.

Can I transcribe a local file, or does it have to be a URL?

Either. Pass a publicly accessible URL and the file is fetched; pass a local path like “./example.mp3” and the SDK uploads it before submitting the job. The rest of your code is identical — same audio_file variable, same transcribe() call. Local uploads cap at 2.2GB. The thing to watch with URLs is that they must be reachable without authentication: an expired signed URL, or a link behind a login, comes back as a failed job rather than an exception — exactly the case the error check above catches.