Whisper
Record & Transcribe your Meetings

What Gets Said, What Gets Whispered
I meet with a lot of people online. Mostly for work related to my company — some are clients, others are subject matter experts who help me understand different domains.
When I’m in one of these expert conversations, I get a lot of aha moments. I note one down, and then five minutes later another one lands, but this time it connects back to something the person said fifteen minutes ago. Building that richer idea mid-meeting is not as simple as jotting it down and moving on. By the time the meeting ends, the connection gets lost in the ether, forever. It happens a lot. An accountant walks me through how tax returns are calculated, and then ten minutes later picks up the same thread to explain how the calculation shifts once your expense categories include infrastructure cost — and the version of that idea I’d have written down ten minutes earlier was already incomplete.
Then there are the other kinds of meetings. The ones where, partway through, you can feel the channel shift. A new voice joins that you weren’t expecting. The tone tightens. Someone makes a claim, or a framing, that you know wouldn’t survive being put in writing — and they make it anyway, because they’ve decided this conversation doesn’t count. They’re treating it as off the record. Unwatched. Unaccountable. And you’re left trying to remember, in real time, exactly what was just said.
On the Record
When a conversation gets deep and the thoughts start scrambling, it’s hard to assemble what was said into anything coherent afterward. Minutes of meetings help, but taking minutes is just multitasking — it keeps me mentally one step behind when I should be one step ahead. With multiple parties it gets worse. Words get mushy, thoughts melt between emotional statements, and afterward it’s hard to reconstruct who said what, or why.
Without a record, the meeting becomes a story you tell yourself afterward. You leave not really knowing what happened, only knowing your version of it.
What I want is the opposite of that. I want to walk into a meeting knowing the words will keep, so I don’t have to. That changes how I show up. I’m not splitting attention between listening and capturing. I’m not bracing against the next dense thing the expert is about to say. I can be present, ask better questions, follow a thread without panicking that I’ll lose it. The recording isn’t really a chore I do during the meeting — it’s what frees me to actually be in the meeting.
And then there are the other meetings. The ones where the other side has decided the conversation doesn’t count. For those, the record isn’t a cognitive aid — it’s the thing that keeps the conversation honest, even if only for me.
Two Small Scripts
There are two parts to this: ffmpeg for capture, whisper.cpp for
transcription. ffmpeg is the swiss-army knife of audio and video on Unix — it
can record, convert, mix, and re-encode just about anything. whisper.cpp is a
fast C++ implementation of OpenAI’s Whisper speech-to-text model, runnable on a
normal laptop CPU without a Python stack or a GPU.
Both run entirely on my machine. Nothing leaves the laptop.
I wrapped them in two small bash scripts — record and transcribe — kept
separate on purpose. Each does one thing, and keeping them apart means I can
transcribe an old recording without re-recording, or record without committing
to transcribing later.
Find the Right Audio Sources
Before I can record, I need to know what to record from. My hardware setup is a pair of Sony Bluetooth headphones and a RØDE NT-USB microphone. To see what the system can capture from, list the audio sources:
pactl list sources short
That’s everything currently connected — HDMI outputs, the laptop’s built-in mics, the dock, the webcam mic, the headphones, the RØDE. Most of it I don’t care about. The two sources I actually want are:
- System audio (the other person): bluez_output.40_72_18_42_43_8C.1.monitor
- Microphone (You): alsa_input.usb-RODE_Microphones_RODE_NT-USB-00.analog-stereo
One ends in .monitor, the other doesn’t. That’s deliberate. A .monitor source is a virtual source — it captures whatever audio is being sent to a sink. So bluez_output.[mac].monitor is “everything going out to the headphones,” which on a call is the voice of whoever I’m talking to. The RØDE doesn’t need a monitor because it’s a real input device. I record from it directly.
One more thing worth ignoring: bluez_input.40:72:18:42:43:8C is the headphones’s built-in mic. I’m not using it — the RØDE is better, and as we’ll see in the next section, leaving the headphones’s mic out of the picture also avoids a Bluetooth profile gotcha.
Recording with ffmpeg
Once I know which sources to record from, ffmpeg does the actual work. Here’s
the command I run:
ffmpeg \
-f pulse -i bluez_output.40_72_18_42_43_8C.1.monitor \
-f pulse -i alsa_input.usb-RODE_Microphones_RODE_NT-USB-00.analog-stereo \
-filter_complex "[0:a]volume=0.7[sys];[1:a]volume=2.0[mic];[sys][mic]amix=inputs=2:duration=longest:normalize=0[a]" \
-map "[a]" -ar 48000 -c:a libmp3lame -b:a 192k \
recording.mp3It looks busy, but it breaks down cleanly.
-f pulse -i <source> tells ffmpeg to read from a PulseAudio source by name. I
do this twice. Once for the system audio (the headphones monitor), once for the
RØDE. At this point ffmpeg has two separate audio streams to work with.
The -filter_complex block is where they get combined. [0:a]volume=0.7[sys]
takes the first input (the headphones monitor) and drops its volume to 70%.
[1:a]volume=2.0[mic] takes the RØDE and doubles it. The numbers are calibrated
to my desk and speaking voice — system audio comes through the headphones
already amplified, while the RØDE picks up at lower level. If your voice ends up
too quiet in playback, push the mic up. If the other person is blowing out your
eardrums, drop the system. Once you find numbers that work, they stay good.
Then the [sys][mic]amix=inputs=2:duration=longest:normalize=0[a] mixes the two
adjusted streams into one. The important bit here is normalize=0 — by default
amix halves each input’s volume to prevent clipping, which would undo the
volume adjustments I just made. Disabling normalize means I take responsibility
for the levels myself, which is exactly what the explicit volume= filters were
for.
The rest is encoding: -map "[a]" selects the mixed stream, -ar 48000 sets a
48 kHz sample rate, -c:a libmp3lame -b:a 192k encodes to 192 kbps mp3. Decent
quality, reasonable file size — a one-hour meeting comes out around 80 MB.
The Script
I don’t run that command by hand every time. It’s wrapped in a small bash script:
#!/usr/bin/env bash
set -euo pipefail
default_dir="$HOME/recordings"
default_name="recording_$(date +%Y-%m-%d_%H-%M-%S).mp3"
output="$1:-$default_dir/$default_name"
system_source="bluez_output.40_72_18_42_43_8C.1.monitor"
mic_source="alsa_input.usb-RODE_Microphones_RODE_NT-USB-00.analog-stereo"
mkdir -p "$(dirname "$output")"
# Fail loudly if the headset isn't connected.
if ! pactl list sources short | grep -q "$system_source"; then
echo "Bluetooth source not found. Is the headset connected?" >&2
pactl list sources short >&2
exit 1
fi
echo "Recording to: $output"
echo "Press 'q' to stop."
ffmpeg \
-f pulse -i "$system_source" \
-f pulse -i "$mic_source" \
-filter_complex "[0:a]volume=0.7[sys];[1:a]volume=2.0[mic];[sys][mic]amix=inputs=2:duration=longest:normalize=0[a]" \
-map "[a]" -ar 48000 -c:a libmp3lame -b:a 192k \
"$output"
echo "Saved: $output"Saved to ~/.local/bin/record, made executable, and now record from any
terminal starts a meeting recording with a timestamped filename in
~/recordings/. Pressing q stops it.
A small but important thing: the pre-flight check on the Bluetooth source.
Without it, if the headphone is disconnected when I run record, ffmpeg happily
proceeds and produces a file containing only my mic — and I find out an hour
later. Failing fast with a clear error message has saved me twice.
Transcribing with whisper.cpp
With a recording in hand, the second half of the job is turning audio into text.
That’s whisper.cpp. Here’s the command at the core of it:
whisper-cli \
-m ~/.local/share/whisper-models/ggml-large-v3.bin \
-f recording.wav \
-l auto \
-otxt -osrt \
-of recording \
-t "$(nproc)"Before that runs though, the audio needs converting. Whisper expects 16 kHz mono PCM — that’s the format its model was trained on. My recordings are 48 kHz mp3, good for listening back but not what whisper wants. So the first step is an ffmpeg conversion.
ffmpeg \
-i recording.mp3 \
-ar 16000 \
-ac 1 \
-c:a pcm_s16le recording.wav \
-y \
-loglevel error-ar 16000 sets the sample rate, -ac 1 collapses to mono, -c:a pcm_s16le
writes uncompressed 16-bit PCM. The result is a WAV file whisper can read
directly. This is a throwaway file — it exists only to be fed to whisper and
deleted afterwards. The mp3 archive is never touched.
Now the whisper-cli arguments:
-m points to the model file. I use ggml-large-v3 — the largest model. It’s
slower than the smaller ones, but the quality difference is real, especially for
Arabic. My meetings are rarely purely English, and the smaller models handle
Arabic noticeably worse. If you only ever transcribe clean English, a smaller
model is a reasonable trade. I don’t have that luxury, so I pay the speed cost.
-f is the input WAV. -l auto lets whisper detect the language itself. It’s
good at this, and good at code-switching — a meeting that drifts between Arabic
and English mid-sentence mostly transcribes fine. Mostly. Every so often it
locks onto the wrong language in the first few seconds and never recovers,
producing a transcript that’s confidently wrong. For those cases the script has
an override, which I’ll get to.
-otxt -osrt asks for two output formats. The .txt is plain text — for
reading, searching, grepping, pasting into notes. The .srt is subtitles,
timestamped. That one’s more useful than it sounds: open the original recording
with mpv --sub-file=recording.srt recording.mp3 and the transcript scrolls in
sync with the audio, so finding the exact moment someone said a thing becomes
trivial.
-of recording sets the output prefix — whisper writes recording.txt and
recording.srt. And -t "$(nproc)" tells it to use every CPU core; the default
is four, which leaves performance on the table.
The Script
The conversion, the model check, and the transcription are wrapped into
transcribe:
#!/usr/bin/env bash
set -euo pipefail
whisper_model="$HOME/.local/share/whisper-models/ggml-large-v3.bin"
language="${LANG_OVERRIDE:-auto}" # LANG_OVERRIDE=ar to force Arabic
if [[ $# -lt 1 ]]; then
echo "Usage: transcribe <audio-file> [output-prefix]" >&2
exit 1
fi
input="$1"
[[ -f "$input" ]] || { echo "File not found: $input" >&2; exit 1; }
[[ -f "$whisper_model" ]] || { echo "model not found: $whisper_model" >&2; exit 1; }
base="${2:-${input%.*}}"
# Whisper wants 16 kHz mono PCM. Use a temp file we always clean up.
wav="$(mktemp --suffix=.wav)"
trap 'rm -f "$wav"' EXIT
ffmpeg -i "$input" -ar 16000 -ac 1 -c:a pcm_s16le "$wav" -y -loglevel error
whisper-cli \
-m "$whisper_model" \
-f "$wav" \
-l "$language" \
-otxt -osrt \
-of "$base" \
-t "$(nproc)"
echo "Transcript: ${base}.txt"
echo "Subtitles: ${base}.srt"A few choices worth pointing out.
The temp WAV is created with mktemp and registered with trap '... EXIT',
which means it gets deleted when the script exits — whether it finishes cleanly,
errors out, or I Ctrl+c halfway through a long transcription. No stray
multi-hundred-megabyte WAV files left in /tmp, and the original recording is
never modified.