A long clip used to die on a fixed 30 s timeout and the audio went with it. Now every conditioned clip is written to spool/ before upload and only removed once its text is on the clipboard; failures keep it, and a tray item retries spooled clips (copied together, never dispatched). Requests the API rejects outright (4xx other than 408/429) are not kept. Timeouts scale with the audio (30 s + 30 s/min, capped at 5 min). Clips over 5 min are cut inside pauses into ~4 min chunks uploaded three at a time and stitched in order; failed chunks are spooled individually and the partial transcript is copied with a gap marker. The tray disc fills clockwise as chunks return. mic_check.py passes the new timeout tuple. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016rLFNPygDpRkRwPQakdmYh
4.9 KiB
Blurt
Tray-based push-to-talk voice-to-text for Linux and Windows. Toggle recording with a hotkey, speak, toggle off — Blurt transcribes the clip and copies the text to your clipboard.
If a transcript starts with to my agent, the rest is dispatched as an agent
task instead of copied.
Setup
Requires uv and Python 3.11+.
uv sync
API key
Blurt reads the key for its current model's provider in this order: environment variable → OS keyring → local file.
| Provider | Env var | Keyring (service / user) |
File |
|---|---|---|---|
| OpenAI | OPENAI_API_KEY |
openai-api-key / felixm |
api_key.txt |
| OpenRouter | OPENROUTER_API_KEY |
openrouter-api-key / felixm |
openrouter_key.txt |
Key files and runtime config stay local (gitignored).
Running
Linux — uv run blurt starts the tray icon. Bind a hotkey to
./blurt-toggle, which signals the running instance to start/stop recording.
Windows — launch blurt.vbs (runs Blurt with no console window). The
global hotkey is F20.
Configuration
Set from the tray menu, or via files / env vars:
- Model —
model.txt/BLURT_TRANSCRIPTION_MODEL. For lowest latency useopenai/whisper-large-v3-turbo(OpenRouter, served by Groq). - Language —
language.txt/BLURT_TRANSCRIPTION_LANGUAGE(en,de). - Input device — tray menu (persisted by name in
device.txt/BLURT_INPUT_DEVICE). Pick an explicit mic: PortAudio device indices shift when USB gear comes and goes, and the OS default source can change under you. The menu hides playback endpoints and raw ALSAhw:devices that the sound server already owns;BLURT_INPUT_DEVICEcan still name one directly. Devices that refuse 16 kHz (raw ALSA and PipeWire's JACK endpoints do) are captured at their native rate and resampled.
Every clip is conditioned before upload — the stream-open click is trimmed off the front (only as much of it as is actually there, up to 150 ms), DC bias is filtered out, and the level is normalised towards -20 dBFS. Near-silent clips are refused instead of being sent, since transcription models answer silence with confident hallucinations.
Every clip is written to spool/ (gitignored) before it is uploaded, and
deleted again as soon as its text reaches the clipboard. If transcription fails —
timeout, connection error, server error, empty response — the WAV stays there
and the notification says so; Retry failed transcriptions (N) in the tray
menu re-uploads the spooled clips oldest first, so a network hiccup never costs
you the words. A rejected request (any 4xx other than 408 and 429) is the
exception: the same audio can only be rejected again, so it is not kept, and the
notification says that too. Retried transcripts are copied to the clipboard
together, oldest first, once the whole run is done — and never dispatched to an
agent, since a spooled clip may be one part of a longer recording. The upload
timeout scales with the clip: 30 s plus 30 s per minute of audio, capped at
5 minutes. Clips longer than 5 minutes are split into chunks of about
4 minutes — each cut lands in a pause, not mid-word — and the chunks are
uploaded in parallel and stitched back together in order. The tray icon fills
clockwise as each chunk comes back, so you can see how far along the clip is.
If only some chunks come back, the transcript is copied with […] where the
gaps are, and just the failed chunks are kept in the spool as …-partNN.wav
for the retry.
Nothing else is cut: every second of the recording is uploaded. If a transcript comes
back missing words, check blurt.log — each clip logs its length, speech level,
noise floor, and dead=, the longest run of digital silence inside it. A
non-zero dead= means the input dropped out mid-clip, which is what following
the system default source invites; pin a device instead. Set
BLURT_SAVE_CAPTURES=1 (or to a directory) to keep the raw and uploaded WAVs
for every clip and hear exactly what the API got.
Checking the microphone
uv run mic_check.py [device] [seconds] records from Blurt's configured input,
prints levels before and after conditioning, saves the WAV it would have
uploaded, and transcribes it with the current model. Use it when transcripts
come back wrong: bad audio shows up as a DC bias, clipping, or a speech level
far below -30 dBFS.
Logs are written to blurt.log in your temp directory (openable from the tray
menu).
Moving to another machine
uv run export.py writes a zip of just the source (no 200 MB+ .venv, no
caches, no key files) to your temp directory. It's pure Python, so it runs the
same on Linux and Windows — no shell needed — and it only reads, so it's safe
while Blurt is recording. Unzip on the other machine and uv sync to rebuild
.venv for that OS. (Or simply git clone the repo fresh.)