Build a transcription pipeline with yt-dlp and Whisper
If you need transcripts for videos that don't have captions, the standard
DIY stack is yt-dlp to pull audio and Whisper to transcribe it. It's a
genuinely solid combination and worth building yourself if transcription is
part of your core product. Here's how the pieces fit together, how to get
timestamps out of it, and the operational problems that only show up once
you're running it on more than a handful of videos.
Step 1: pull the audio
yt-dlp handles the download. You don't want the video — just the audio
track, extracted and converted:
yt-dlp -f bestaudio -x --audio-format mp3 -o audio.mp3 "https://youtu.be/VIDEO_ID"-f bestaudio picks the best available audio-only stream so you're not
downloading video you'll throw away, -x extracts the audio, and
--audio-format mp3 transcodes it to MP3 via ffmpeg (which yt-dlp shells
out to, so it needs to be installed and on your PATH). This works well
beyond YouTube — yt-dlp supports a long list of sites, so the same command
is your ingestion path for most video URLs.
Step 2: transcribe it
You've got two real options here: call the OpenAI API, or run Whisper yourself.
Option A: the OpenAI API
Simplest to stand up — no model weights, no GPU:
from openai import OpenAI
client = OpenAI()
with open("audio.mp3", "rb") as f:
result = client.audio.transcriptions.create(
model="whisper-1",
file=f,
response_format="verbose_json", # needed for per-segment timestamps
)
for segment in result.segments:
print(segment.start, segment.end, segment.text)response_format="verbose_json" is the part people miss. The default
response is just a flat string — no timing at all. verbose_json returns
the segment-level breakdown with start, end, and text per segment,
which is what you need if the output is going into an SRT/VTT file or
anything else that has to sync to the audio.
Option B: run it locally
Two libraries cover this. openai-whisper is the reference implementation:
import whisper
model = whisper.load_model("base")
result = model.transcribe("audio.mp3")
for segment in result["segments"]:
print(segment["start"], segment["end"], segment["text"])faster-whisper is a CTranslate2 reimplementation that's noticeably lighter
on memory and runs well on CPU with int8 quantization:
from faster_whisper import WhisperModel
model = WhisperModel("base", device="cpu", compute_type="int8")
segments, info = model.transcribe("audio.mp3")
for segment in segments:
print(segment.start, segment.end, segment.text)Both return segments with start/end/text — that's your timestamp data
without touching the API at all. The trade-off is you're now managing model
weights, picking a model size (bigger models are more accurate and slower),
and, if you want reasonable speed on longer audio, a GPU.
Step 3: chunking long audio
Both the API and local Whisper have practical limits on a single file — the API has an upload size cap, and local inference on long files gets slow and memory-hungry. Past roughly the length of a long podcast episode, split first:
ffmpeg -i audio.mp3 -f segment -segment_time 600 -c copy chunk_%03d.mp3This splits the audio into 600-second (10-minute) chunks without re-encoding. Transcribe each chunk independently, then stitch the results back together — the part that's easy to get wrong is forgetting that each chunk's timestamps start at zero. You have to add the chunk's offset back in before the timestamps mean anything relative to the original file:
SEGMENT_SECONDS = 600
all_segments = []
for i, chunk_path in enumerate(sorted(chunk_paths)):
offset = i * SEGMENT_SECONDS
chunk_segments = transcribe(chunk_path) # however you call Whisper
for seg in chunk_segments:
seg["start"] += offset
seg["end"] += offset
all_segments.append(seg)Skip this step and every chunk after the first reports timestamps as if it were its own standalone file — which reads as correct until you actually seek to one and it's ten minutes off.
What breaks in production
The pipeline above works fine for one video on your laptop. Running it as a service surfaces a different set of problems.
Cloud IPs get blocked. yt-dlp running on AWS, GCP, or most VPS
providers will eventually hit yt-dlp's equivalent of an HTTP 429 or a
"sign in to confirm you're not a bot" wall from YouTube — datacenter IP
ranges are heavily rate-limited or blocked outright, even though the exact
same command works from your home IP. The fix is routing requests through a
residential proxy (yt-dlp --proxy http://user:pass@host:port ...), which
adds cost and another moving part to keep configured and rotated.
Long jobs need a queue. Transcribing a two-hour video takes real wall-clock time — you can't do it inline in an HTTP request/response cycle. That means a job queue, worker processes, and a way for the caller to poll or get notified when the job finishes, on top of the transcription code itself.
Cache or pay twice. If the same video gets requested more than once — common for anything popular — re-downloading and re-transcribing it is pure waste. Caching by video ID (or a hash of the audio) is close to mandatory once you have real traffic.
Cost adds up per minute of audio. Whether you're paying per-minute API usage or paying for GPU time to run Whisper yourself, cost scales with audio length, not with how novel the content is — chunking overlaps, retries after a failed job, and re-transcribing to fix a bad run all get billed the same way.
None of these are hard problems individually. Together, they're the difference between a script and a service.
Or use a managed API
If transcription isn't the product you're building — it's a step on the way to something else — it can save you the build time to call an API that's already handling proxies, queueing, caching, and chunking. transcript.land does this for YouTube, TikTok, X, Instagram, Bilibili, Facebook, RedNote, and direct audio URLs, returning captions when they exist and falling back to AI transcription automatically when they don't:
curl -X POST https://api.transcript.land/v1/transcript \
-H "Authorization: Bearer $TRANSCRIPT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://youtu.be/VIDEO_ID", "format": "json"}'A caption-less video returns a job_id you poll instead of the transcript
directly — the same async pattern you'd build yourself, minus the queue and
worker code:
curl https://api.transcript.land/v1/jobs/JOB_ID \
-H "Authorization: Bearer $TRANSCRIPT_API_KEY"The CLI wraps the polling for you:
brew install ziqorg/tap/transcript
transcript login
transcript get "https://youtu.be/VIDEO_ID" -o jsonOutput formats are TXT, SRT, VTT, Markdown, or JSON. The free tier covers 15 minutes of audio a month, which is enough to see whether the output shape fits your pipeline before committing to anything.
Summary
yt-dlp -f bestaudio -x --audio-format mp3gets you the audio; the OpenAI API withresponse_format="verbose_json", oropenai-whisper/faster-whisperlocally, gets you segment-level timestamps.- Chunk long audio with
ffmpeg's segment muxer, and remember to add each chunk's offset back into its timestamps when you stitch results together. - The parts that turn this from a script into a service are proxies (cloud IPs get blocked), a job queue, a cache, and per-minute cost — worth building if transcription is your product, worth outsourcing if it isn't.