Whisper

Record & Transcribe your Meetings

Two girls gossiping.
Two girls gossiping.
© Mohammed Saed

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:

bash
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:

bash
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.mp3

It 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:

bash
#!/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:

bash
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.

bash
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:

bash
#!/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.