Voice-File Transcript and Record
1. Automatic transcription — light, free, agent-friendly
Best default: whisper.cpp, invoked via its whisper-cli binary. It's a pure C/C++ port of Whisper — no PyTorch, no heavy Python ML stack — so an agent can shell out to it without spinning up a big environment. On Apple Silicon it can run the encoder on the Neural Engine via Core ML, which is more than 3x faster than CPU-only execution, and it supports quantized models (q5_0, q8_0) that cut RAM/disk footprint substantially. It also has a built-in Voice Activity Detection mode — passing --vad skips silent stretches before they hit the model, which speeds up long recordings significantly.
Typical agent recipe:
# whisper wants 16kHz mono wav — convert first
ffmpeg -i input.mp3 -ar 16000 -ac 1 -c:a pcm_s16le input.wav
# transcribe (--vad skips silence, speeds up long recordings)
whisper-cli -m models/ggml-base.en.bin -f input.wav --vad --output-txt --output-srtIf you're on Apple Silicon and don't mind a thin Python layer: mlx-whisper. It runs on the GPU through Apple's MLX/Metal stack, pip install mlx-whisper, and is a two-line call:
import mlx_whisper
text = mlx_whisper.transcribe("audio.mp3", path_or_hf_repo="mlx-community/whisper-small")["text"]Also very light on CPU since the actual compute happens on the GPU.
If you want zero local compute at all: a free-tier hosted Whisper endpoint (e.g. Groq's) does the heavy lifting on their servers — the agent just makes a lightweight HTTP call. Trade-off: needs network access and you're bound by whatever the free-tier limits are, so it's "free" with an asterisk rather than fully offline-free.
For fully automated pipelines, the pattern is simple: agent watches a folder → converts new audio to 16kHz mono wav → runs whisper-cli → saves .txt/.srt next to the source file. I can write that script for you if useful.
2. Clean recording from websites on headphones-only setup
Diagnosis first: if your current ffmpeg command is pointed at the built-in microphone, that's almost certainly your noise/buzz source — a mic can only capture whatever leaks into the room plus its own hiss, and it's structurally impossible for that to sound clean. The fix isn't "denoise better," it's "stop touching the mic."
The trusted free tool: BlackHole. It's an open-source, MIT-licensed virtual audio driver — it creates a device that other apps' output (including your browser) can be routed into digitally, with zero air/mic involved:
brew install blackhole-2chOpen Audio MIDI Setup → "+" → Create Multi-Output Device, and check both your headphones and BlackHole 2ch. You'll still hear everything through your headphones while it's simultaneously sent to BlackHole for capture — this directly solves the "no speakers, headphones-only" problem. One known quirk: when using a multi-output device, the built-in/headphone output needs to be enabled and listed as the top device in the list, or macOS can misbehave.
Match sample rates. This is very likely your buzzing culprit. Apple's own guidance on combined devices is explicit: all devices in the aggregate/multi-output device need to have the same sample rate, matching whichever device is set as the clock source, and if the devices aren't hardware-synced, you need to enable Drift Correction for every device except the clock source to compensate for clock drift between them. Skipping this step is the #1 cause of the clicking/buzzing you're describing in exactly this kind of setup.
Point ffmpeg at BlackHole instead of the mic:
bashffmpeg -f avfoundation -list_devices true -i "" # find BlackHole's index ffmpeg -f avfoundation -i ":N" -ac 2 -ar 48000 recording.wavStop it with a graceful signal (Ctrl-C / SIGINT, or send
q), notkill -9— ffmpeg needs to close the file header properly, and an abrupt kill can leave a corrupted/glitchy tail that sounds exactly like buzzing.
Once you're capturing from BlackHole, hiss and buzz from the room are simply not possible anymore — it's a digital signal, not sound traveling through air into a mic.
Even better than any live recording, when it's an option: grab the actual file instead of re-recording it. Live loopback capture means the agent has to sit and "listen" in real time and depends on getting the audio setup exactly right. Two cleaner alternatives:
yt-dlp (free, open source, very actively maintained, supports 1000+ sites): if the website's audio comes from a supported platform, skip capture entirely —
bashyt-dlp -x --audio-format mp3 --audio-quality 0 "URL"This is a byte-for-byte copy of the source stream, so buzzing/hiss simply can't occur, and there's no real-time waiting.
Browser automation with network interception (Playwright, driven headlessly by the agent): for a custom site not covered by yt-dlp, the agent opens the page and grabs the audio file straight out of the network response instead of "listening" to playback. Same benefit — it's the original bytes.
Reserve BlackHole + ffmpeg for cases with no discrete file to grab — DRM streams, live calls, WebRTC.
How the agent can verify quality without ears:
ffmpeg -i recording.wav -af astats -f null -This flags near-total silence (usually means the wrong input device got selected) or clipping at 0 dBFS. If there's still faint hum after the sample-rate fix, afftdn (denoise) or highpass=100 (cuts mains hum) can clean it up — but treat that as a last resort, since denoising can shave consonants and actually hurt transcription accuracy more than it helps.
3. Combined script: record → transcribe (or download → transcribe)
#!/usr/bin/env bash
#
# record_and_transcribe.sh
# Records website/system audio via BlackHole loopback, then transcribes it
# locally and for free with whisper.cpp. No microphone involved anywhere,
# so hiss/buzz from the room is structurally impossible.
#
# Can also download audio directly via yt-dlp (--download URL) to skip
# live capture entirely when the source is a supported site.
#
# ---------------------------------------------------------------------------
# ONE-TIME SETUP (do this before running the script):
#
# 1. Install the tools:
# brew install ffmpeg blackhole-2ch
#
# 2. Build whisper.cpp and grab a model:
# git clone https://github.com/ggml-org/whisper.cpp
# cd whisper.cpp
# cmake -B build && cmake --build build -j --config Release
# ./models/download-ggml-model.sh base.en
# whisper-cli then lives at: whisper.cpp/build/bin/whisper-cli
#
# 3. In Audio MIDI Setup (Applications > Utilities):
# - "+" -> Create Multi-Output Device
# - Check both your headphones AND "BlackHole 2ch"
# - Keep your headphones/built-in output listed at the TOP
# - Give every device in it the SAME sample rate (e.g. 48000 Hz)
# - Tick "Drift Correction" for every device except the clock source
# This is what fixes the buzzing/clicking.
#
# 4. Optional but recommended, lets this script auto-switch your output:
# brew install switchaudio-osx
#
# 5. Optional, for --download mode:
# brew install yt-dlp
#
# ---------------------------------------------------------------------------
# USAGE:
# ./record_and_transcribe.sh [name] [duration_seconds]
# ./record_and_transcribe.sh --download URL [name]
#
# name Label for the output files (default: timestamp)
# duration_seconds Auto-stop after N seconds. Omit to record until
# you press Ctrl+C.
# --download URL Download audio from URL via yt-dlp instead of
# recording from BlackHole. Skips live capture.
#
# CONFIG (optional environment variables, or just edit the defaults below):
# WHISPER_BIN, WHISPER_MODEL, WHISPER_LANG, OUTPUT_DIR, MULTI_OUTPUT_NAME
# ---------------------------------------------------------------------------
set -euo pipefail
WHISPER_BIN="${WHISPER_BIN:-whisper-cli}"
WHISPER_MODEL="${WHISPER_MODEL:-$HOME/whisper.cpp/models/ggml-base.en.bin}"
WHISPER_LANG="${WHISPER_LANG:-en}" # ponytail: "auto" for auto-detect, "en" for English-only
OUTPUT_DIR="${OUTPUT_DIR:-$HOME/Recordings}"
MULTI_OUTPUT_NAME="${MULTI_OUTPUT_NAME:-Multi-Output Device}"
SAMPLE_RATE=48000
CHANNELS=2
# ---------- Parse arguments ----------
DOWNLOAD_URL=""
if [ "${1:-}" = "--download" ]; then
DOWNLOAD_URL="${2:?Usage: $0 --download URL [name]}"
NAME="${3:-download_$(date +%Y%m%d_%H%M%S)}"
DURATION=""
else
NAME="${1:-recording_$(date +%Y%m%d_%H%M%S)}"
DURATION="${2:-}"
fi
# ---------- Preflight checks ----------
command -v ffmpeg >/dev/null 2>&1 || { echo "ffmpeg not found. Install with: brew install ffmpeg"; exit 1; }
command -v "$WHISPER_BIN" >/dev/null 2>&1 || { echo "'$WHISPER_BIN' not found on PATH. Set WHISPER_BIN to its full path, or build whisper.cpp first (see script header)."; exit 1; }
[ -f "$WHISPER_MODEL" ] || { echo "Whisper model not found at: $WHISPER_MODEL. Set WHISPER_MODEL, or run whisper.cpp's models/download-ggml-model.sh"; exit 1; }
mkdir -p "$OUTPUT_DIR"
RAW_WAV="$OUTPUT_DIR/${NAME}_raw.wav"
WHISPER_WAV="$OUTPUT_DIR/${NAME}_16k.wav"
# ---------- Cleanup trap — restores audio output even on unexpected exit ----------
ORIGINAL_OUTPUT=""
cleanup() {
if command -v SwitchAudioSource >/dev/null 2>&1 && [ -n "$ORIGINAL_OUTPUT" ]; then
SwitchAudioSource -s "$ORIGINAL_OUTPUT" >/dev/null 2>&1 || true
echo "Restored system output to '$ORIGINAL_OUTPUT'."
fi
}
trap cleanup EXIT
# ---------- Download mode (yt-dlp) ----------
if [ -n "$DOWNLOAD_URL" ]; then
command -v yt-dlp >/dev/null 2>&1 || { echo "yt-dlp not found. Install with: brew install yt-dlp"; exit 1; }
echo "Downloading audio from: $DOWNLOAD_URL"
yt-dlp -x --audio-format wav --audio-quality 0 -o "$RAW_WAV" "$DOWNLOAD_URL"
if [ ! -s "$RAW_WAV" ]; then
echo "Download failed or produced empty file."
exit 1
fi
echo ""
echo "Downloaded: $RAW_WAV"
else
# ---------- Find BlackHole's avfoundation index ----------
DEVICE_LIST="$(ffmpeg -f avfoundation -list_devices true -i "" 2>&1)"
# ponytail: only search the audio-devices section to avoid grabbing a video index.
# ceiling: if ffmpeg changes its section header text, the sed range may need updating.
AUDIO_SECTION="$(echo "$DEVICE_LIST" | sed -n '/AVFoundation audio devices/,/^$/p')"
BLACKHOLE_INDEX="$(echo "$AUDIO_SECTION" | grep -i "BlackHole" | head -1 | sed -E 's/.*\[([0-9]+)\].*/\1/')"
if [ -z "$BLACKHOLE_INDEX" ] || ! [[ "$BLACKHOLE_INDEX" =~ ^[0-9]+$ ]]; then
echo "Could not find a BlackHole audio device. Is it installed? brew install blackhole-2ch"
echo "Full audio device list:"
echo "$AUDIO_SECTION"
exit 1
fi
echo "Found BlackHole at avfoundation audio index $BLACKHOLE_INDEX"
# ---------- Try to auto-switch system output to the Multi-Output Device ----------
if command -v SwitchAudioSource >/dev/null 2>&1; then
ORIGINAL_OUTPUT="$(SwitchAudioSource -c)"
if SwitchAudioSource -s "$MULTI_OUTPUT_NAME" >/dev/null 2>&1; then
echo "Switched system output to '$MULTI_OUTPUT_NAME'."
else
echo "Could not switch to '$MULTI_OUTPUT_NAME' automatically — set it manually in System Settings > Sound before continuing."
fi
else
echo "Tip: 'brew install switchaudio-osx' lets this script switch your output device automatically."
echo "For now, make sure System Settings > Sound > Output is set to your Multi-Output Device."
fi
echo ""
if [ -n "$DURATION" ]; then
echo "Recording for ${DURATION}s from BlackHole. Start playing the website audio now."
else
echo "Recording from BlackHole. Start playing the website audio now, then press Ctrl+C when done."
fi
echo ""
# ponytail: ${DURATION:+-t "$DURATION"} is bash 3.2-safe. The old DURATION_ARGS=()
# pattern crashes on macOS stock bash under set -u (unbound variable on empty array).
set +e # ffmpeg returns non-zero on Ctrl-C; that's expected
ffmpeg -f avfoundation -i ":$BLACKHOLE_INDEX" \
${DURATION:+-t "$DURATION"} \
-ac "$CHANNELS" -ar "$SAMPLE_RATE" -y "$RAW_WAV" \
-loglevel warning
set -e
if [ ! -s "$RAW_WAV" ]; then
echo "No audio was captured. Check that BlackHole/Multi-Output routing is set up correctly."
exit 1
fi
echo ""
echo "Recording saved: $RAW_WAV"
fi
# ---------- Quick quality check (no ears required) ----------
echo "Checking audio level..."
STATS="$(ffmpeg -i "$RAW_WAV" -af astats -f null - 2>&1)"
RMS="$(echo "$STATS" | grep "RMS level dB" | tail -1 | grep -oE '\-?[0-9]+\.[0-9]+' | head -1)"
if [ -n "$RMS" ] && [ "$RMS" != "-inf" ]; then
echo "Overall RMS level: ${RMS} dB"
# ponytail: awk numeric comparison; the -inf guard above prevents awk coercing it to 0
awk -v rms="$RMS" 'BEGIN { if (rms+0 < -50) print "WARNING: this looks close to silent — double check routing before trusting the transcript." }'
else
if [ "$RMS" = "-inf" ]; then
echo "WARNING: RMS is -inf (total silence). Wrong input device or nothing was playing."
else
echo "Could not read level stats (file may be very short)."
fi
fi
# ---------- Convert to 16kHz mono for whisper.cpp ----------
echo "Converting to 16kHz mono..."
ffmpeg -y -i "$RAW_WAV" -ar 16000 -ac 1 -c:a pcm_s16le "$WHISPER_WAV" -loglevel error
if [ ! -s "$WHISPER_WAV" ]; then
echo "Conversion to 16kHz mono failed."
exit 1
fi
# ---------- Transcribe ----------
# --vad: skip silence before it hits the model (faster on recordings with dead air)
LANG_ARGS=""
if [ "$WHISPER_LANG" != "en" ]; then
LANG_ARGS="--language $WHISPER_LANG"
fi
echo "Transcribing with whisper.cpp (lang=$WHISPER_LANG)..."
"$WHISPER_BIN" -m "$WHISPER_MODEL" -f "$WHISPER_WAV" \
--vad --output-txt --output-srt $LANG_ARGS
# whisper.cpp writes "<input>.txt" / "<input>.srt" next to the input file by default
mv -f "${WHISPER_WAV}.txt" "$OUTPUT_DIR/${NAME}.txt" 2>/dev/null || true
mv -f "${WHISPER_WAV}.srt" "$OUTPUT_DIR/${NAME}.srt" 2>/dev/null || true
echo ""
# ---------- Verify outputs exist ----------
if [ -f "$OUTPUT_DIR/${NAME}.txt" ] || [ -f "$OUTPUT_DIR/${NAME}.srt" ]; then
echo "Done."
echo " Raw audio: $RAW_WAV"
[ -f "$OUTPUT_DIR/${NAME}.txt" ] && echo " Transcript: $OUTPUT_DIR/${NAME}.txt"
[ -f "$OUTPUT_DIR/${NAME}.srt" ] && echo " Subtitles: $OUTPUT_DIR/${NAME}.srt"
else
echo "WARNING: whisper ran but no .txt or .srt output was found."
echo " Check whisper-cli output above for errors."
echo " Raw audio is still at: $RAW_WAV"
exit 1
fiOne script, two modes: record (BlackHole loopback capture) or download (yt-dlp, no live capture needed). It finds BlackHole automatically, records, does a quick silence/level check, converts to 16kHz mono, and transcribes with whisper-cli — all in one call.
Before first run:
brew install ffmpeg blackhole-2ch switchaudio-osx yt-dlpplus building whisper.cpp and grabbing a model (full steps are in the script's header comments). Set up the Multi-Output Device once in Audio MIDI Setup as described earlier, matching sample rates.
Then it's just:
./record_and_transcribe.sh # records until Ctrl+C
./record_and_transcribe.sh interview 120 # named, auto-stops at 120s
./record_and_transcribe.sh --download "URL" # download instead of recording
./record_and_transcribe.sh --download "URL" podcast # download with custom name
WHISPER_LANG=auto ./record_and_transcribe.sh # auto-detect languageIf switchaudio-osx is installed, it'll flip your system output to the Multi-Output Device for you and switch it back when done (even on unexpected exit, via a cleanup trap) — so the agent can run this fully hands-off without you touching Sound settings each time.