You can transcribe a whole folder of voice memos offline with faster-whisper and about 60 lines of Python. On a 4-core CPU with no GPU, the base.en model took between 0.30 and 0.45 seconds of processing per second of audio on my test clips. This post gives you the script, the output I actually got, and the limits of that test.
If you want the dictation-app side of this (live typing into an editor), start with the dictation tool comparison. This post is about files you already recorded.
What did I run, and on what?
All numbers below come from one session on 2026-10-09.
| Item | Value |
|---|---|
| Machine | Cloud Linux container, Intel Xeon @ 2.80 GHz, 4 cores, 16 GB RAM, no GPU |
| Python | 3.13 in a fresh venv |
| Library | faster-whisper 1.2.1 (CTranslate2 4.8.2), PyAV 15.1.0 |
| Models | base.en and small.en, int8 on CPU |
| Audio | 3 clips of 5.4 to 6.6 seconds |
The audio is not human speech. I generated it with Piper text-to-speech (voice en_US-lessac-medium) because the container has no microphone. That makes it clean, evenly paced, single-speaker English, which is the easy case. Treat the accuracy as a smoke test, not a benchmark. I could not test noisy rooms, accents, long files, GPU speed, or the original openai-whisper package.
How do I set up faster-whisper in a venv?
python3 -m venv whisper-venv
whisper-venv/bin/pip install faster-whisper "av==15.1.0"
I pin av for a reason. My first install pulled PyAV 19.0.1, and the very first transcription crashed:
TypeError: open() got an unexpected keyword argument 'metadata_errors'
faster-whisper 1.2.1 passes metadata_errors to av.open, and that PyAV version rejected it. Installing av==15.1.0 fixed it. That may be fixed upstream by the time you read this, so try the plain install first and pin only if you see the same error.
You do not need a system ffmpeg for faster-whisper, since its README says audio is decoded with PyAV, which bundles the FFmpeg libraries. The original openai-whisper package does need ffmpeg installed, per its repository.
The batch script
It walks one folder, skips files that already have a transcript, writes one .txt per file, and appends timing rows to summary.csv. The skip rule means you can stop and restart a long batch without redoing work.
#!/usr/bin/env python3
"""Transcribe every audio file in a folder offline with faster-whisper.
Usage: python batch_transcribe.py IN_DIR OUT_DIR [--model base.en] [--language en]
"""
import argparse
import csv
import time
from pathlib import Path
from faster_whisper import WhisperModel
AUDIO_EXT = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".opus", ".mp4", ".webm"}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("in_dir", type=Path)
ap.add_argument("out_dir", type=Path)
ap.add_argument("--model", default="base.en")
ap.add_argument("--language", default=None)
ap.add_argument("--threads", type=int, default=4)
args = ap.parse_args()
args.out_dir.mkdir(parents=True, exist_ok=True)
files = sorted(p for p in args.in_dir.iterdir() if p.suffix.lower() in AUDIO_EXT)
if not files:
raise SystemExit(f"no audio files in {args.in_dir}")
t0 = time.perf_counter()
model = WhisperModel(args.model, device="cpu", compute_type="int8",
cpu_threads=args.threads)
print(f"model {args.model} loaded in {time.perf_counter() - t0:.1f}s")
rows = []
for path in files:
target = args.out_dir / f"{path.stem}.txt"
if target.exists():
print(f"skip {path.name} (already transcribed)")
continue
start = time.perf_counter()
segments, info = model.transcribe(str(path), language=args.language,
vad_filter=True, beam_size=5)
text = " ".join(seg.text.strip() for seg in segments) # generator: work happens here
wall = time.perf_counter() - start
target.write_text(text + "\n", encoding="utf-8")
rtf = wall / info.duration if info.duration else float("nan")
rows.append([path.name, f"{info.duration:.2f}", f"{wall:.2f}", f"{rtf:.2f}", info.language])
print(f"{path.name}: {info.duration:.1f}s audio, {wall:.1f}s wall, RTF {rtf:.2f}")
print(f" -> {text}")
with open(args.out_dir / "summary.csv", "a", newline="", encoding="utf-8") as f:
w = csv.writer(f)
if f.tell() == 0:
w.writerow(["file", "audio_s", "wall_s", "rtf", "language"])
w.writerows(rows)
if __name__ == "__main__":
main()
Two details that matter. model.transcribe returns a lazy generator, so the real work happens when you join the segments, and the timer must wrap that line or you will measure nothing. And vad_filter=True trims silence before decoding, which helps on memos with long pauses.
What was the real output?
Command and output from the run, with base.en:
$ python batch_transcribe.py clips out --model base.en
model base.en loaded in 2.9s
memo_01.wav: 5.4s audio, 1.9s wall, RTF 0.36
-> Note to self, move the stand-up to 10.30 on Thursday and ask Priya for the invoice export.
memo_02.wav: 6.6s audio, 3.0s wall, RTF 0.45
-> idea for the blog, write about how to dictate commit messages, and include one worked example with a failed test.
memo_03.wav: 5.7s audio, 1.7s wall, RTF 0.30
-> The deploy failed, because the environment variable for the database URL was missing from the staging server.
RTF (real-time factor) is wall seconds divided by audio seconds, so lower is faster. An RTF under 1 means faster than real time. The very first model load downloaded the weights; the 2.9 seconds above is a cached load. Re-running the same command printed skip memo_01.wav (already transcribed) for all three files, so the resume logic works.
The same clips with small.en:
| Model | Load | memo_01 RTF | memo_02 RTF | memo_03 RTF |
|---|---|---|---|---|
| base.en | 2.9 s | 0.36 | 0.45 | 0.30 |
| small.en | 14.0 s | 1.06 | 0.66 | 0.74 |
Timings on a shared container drift: the editor re-ran the same base.en command on the same day and got RTFs of 0.55, 0.73 and 0.39 with identical transcripts, so read the numbers above as a range, not a benchmark. Treat the small.en first-file figure (1.06) with suspicion. It was the first inference after loading and probably includes warm-up cost. Three tiny files cannot tell you a stable speed. For planning, transcribe a few minutes of your own audio and read the RTF off summary.csv.
Reading the transcripts against what the voice was told to say, there are two visible differences. base.en wrote "10.30" where the source said "ten thirty", and "stand-up" where I wrote "standup". Both are normal formatting choices, but if you score transcripts with a script, you need to normalise numbers and hyphens first. The accuracy test protocol covers that.
Which model size should you pick?
OpenAI's repository lists these sizes, with approximate VRAM and relative speed measured on an A100 for English speech (as summarised from the page on 2026-10-09):
| Size | Parameters | English-only name | Approx. VRAM | Relative speed |
|---|---|---|---|---|
| tiny | 39 M | tiny.en | ~1 GB | ~10x |
| base | 74 M | base.en | ~1 GB | ~7x |
| small | 244 M | small.en | ~2 GB | ~4x |
| medium | 769 M | medium.en | ~5 GB | ~2x |
| large | 1550 M | none | ~10 GB | 1x |
| turbo | 809 M | none | ~6 GB | ~8x |
That table is vendor guidance for GPUs. It does not tell you CPU speed on your laptop, and my CPU numbers do not map neatly onto it. A sensible way to choose: run base.en first, read the transcripts, and move up a size only if names, jargon or accents are wrong. For Hindi or mixed-language memos, drop the .en models and pass --language hi or leave it unset so the model detects the language. I did not test any non-English audio here.
How do I use this on real voice memos?
- Copy the memos into one folder. Phone formats like
.m4aare handled by the extension list and PyAV, but I only tested.wav, so try one.m4afirst. - Run the script with
base.en, then open the.txtfiles next to the audio. - Fix proper nouns once. Whisper does not learn your names between runs, so keep a short list of corrections and apply it with a find and replace pass, or pass names in the model's
initial_promptoption (check the faster-whisper docs for the exact parameter before relying on it; I did not test it). - Feed the cleaned text onward. For meetings, the meeting notes workflow turns a transcript into decisions and owners. For solo notes, the voice memos to blog posts workflow is the next step.
When is local Whisper the wrong choice?
- You need live dictation into an editor. A batch script is for files. Use a dictation app; the voice coding workflow and code review dictation post cover that.
- You have hours of audio and no GPU. At an RTF near 0.4, one hour of audio would take roughly 24 minutes at my measured rate on this CPU, but that extrapolation comes from 18 seconds of test audio, so measure before you promise a deadline.
- The audio is sensitive and the machine is shared. Local means your audio is not uploaded by this script. It does not mean the files are safe on disk. See the privacy guide on what not to paste into AI tools.
- You need speaker labels. This script does not separate speakers. That needs a diarisation step I did not run.
The first model download is the only network step, and the faster-whisper README's CPU benchmark (small model, int8, 8 threads, 13 minutes of audio, 1 minute 42 seconds) is a useful second data point from the maintainers, on different hardware from mine.



