Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.
talk-rs
⚠️ Disclaimer: this project is entirely "vibe coded".
Every line of code, test, and documentation in this repository was produced by an AI coding agent under human direction. No part of it has been written, line-by-line audited, or formally reviewed by a human engineer. It works on the author's machine and ships with tests, but treat it accordingly: read the source before trusting it with anything important, and expect rough edges, dead code paths, and the occasional architectural oddity that a human would have caught.
Voice dictation for Linux. Record, transcribe, and paste text into any application – all from a single keyboard shortcut.
talk-rs captures audio from your microphone, sends it to a
transcription API (Mistral Voxtral or OpenAI Whisper), and types the
result into the focused window. A small X11 overlay badge shows the
current state (recording / transcribing) so you always know what is
happening.
Features
- Dictation workflow – press a key to start recording, press again to stop; transcribed text is pasted automatically.
- Multiple providers – Mistral (Voxtral), OpenAI (Whisper / GPT-4o), and local on-device Parakeet for batch transcription; Mistral and OpenAI realtime streaming via WebSocket.
- Text-to-speech – the
speakcommand synthesizes text and plays it (or saves a WAV). Two providers mirror the transcription side:kokoro(local, offline, multi-language via sherpa-onnx) andmistral(remote Voxtral TTS). Language handling is config-driven and agnostic. - Speaker diarization – identify who is speaking (
--diarize); output is tagged with speaker labels. Currently supported with Mistral V2 models in batch mode. - Multi-candidate picker – with
--pick, run several providers in parallel and choose the best transcription from a GTK picker window. A waterfall spectrogram of the recording is shown above the candidate list; it loads asynchronously and adapts to window width. Non-default models are transcribed on demand via a per-candidate button to avoid unnecessary API calls. - Visual overlay – non-intrusive X11 badge at the top of the screen (works without a compositor).
- Live transcription overlay – a dynamic phase-coloured waterfall replaces the static "transcribing" badge; three independent throughput tracks (upload bytes, download bytes, paste characters) show progress through the pipeline, with a time-grid layer over the spectrogram.
- Dead audio detection – overlay badge shows a red prohibit icon and "NO SOUND" warning when no real microphone is detected (e.g. headset unplugged); a centered full-screen overlay and repeating alert tone reinforce the signal, and a notification also appears on the text panel.
- Auto-pause – automatically pauses audio forwarding during silence,
trimming dead air from transcription input. Resumes instantly with a
300 ms lookback buffer to preserve speech onset. The badge shows
yellow pause bars and "LISTENING" during pauses. Disable with
--no-auto-pause. - Audio visualizers – optional in-badge visualization during
recording (
--viz waterfall,--viz amplitude,--viz spectrum); monochrome mode with--mono. - Audio feedback – start/stop tones plus a periodic boop while the
badge shows
LISTENING(i.e. during auto-pause silence); the boop is silent while you are actively speaking, and is never heard when--no-auto-pauseis used. Fully configurable or disabled. - Bluetooth headset auto-switch – when a Bluetooth headset is
connected in A2DP mode (high-quality stereo, no microphone),
talk-rsautomatically switches it to its Hands-Free Profile (HFP) for the duration of the recording so the headset microphone is available, then restores the original profile on stop. Survives unclean termination: a state file at$XDG_RUNTIME_DIR/talk-rs/card-profile.jsonis written before the switch, and the next invocation restores the original profile from that file before starting a new recording. Works with any HFP-capable headset (uses PulseAudio's standarddevice.form_factorproperty, not vendor-specific identifiers). Disable per-invocation with--no-bt-auto-switchor globally via theaudio.bt_auto_switchconfig key. - Context bias – supply domain-specific vocabulary to improve transcription accuracy.
- Daemon toggle mode – first invocation spawns a short-lived daemon that records; second invocation signals it to stop, transcribe, paste, and exit. Ideal for global shortcuts.
- No idle daemon – between dictations, zero
talk-rsprocesses stay resident. The toggle daemon spawns on press, records, and exits after pasting. No memory footprint when idle. - Retry last – re-transcribe the last cached recording without
speaking again (
--retry-last). - Recordings browser –
record --uiopens a GTK4 window listing all recordings (OGG fromoutput_dir) and dictation cache (OGG). Imported audio dropped intooutput_dir–.m4a,.mp4, or.aac(e.g. iPhone voice memos) – is listed and playable alongside native OGG recordings, with the same waterfall and transcription workflow. Play, delete, open in the file manager, or transcribe on demand. A waterfall spectrogram is shown for every recording, even those without transcripts; sections auto-refresh via inotify when files change externally. - Per-segment timing export –
--output-yamlincludes per-segment start/end timestamps in the metadata sidecar, suitable for subtitles and post-processing. - Standalone commands –
recordandtranscribecan be used independently for scripting. - Environment overrides – every config value can be set via
TALK_RS_*environment variables.
Prerequisites
Build dependencies
# Debian / Ubuntu
# Fedora
libpulse-dev / pulseaudio-libs-devel provides the PulseAudio client
library used to switch a Bluetooth headset's PulseAudio card profile
between A2DP (high-quality stereo) and HFP (microphone-enabled) when
recording. On PipeWire systems, pipewire-pulse supplies the
libpulse.so shared object at runtime, so no PulseAudio daemon is
needed – only the development headers for compilation.
A working Rust toolchain is required (1.87+). Install via rustup if needed.
Runtime dependencies
PipeWire must be running (used for audio capture). Most modern Linux
desktops ship with PipeWire by default.
Cloud transcription providers require an API key:
Parakeet is a local, on-device ASR backend using sherpa-onnx on the CPU
and needs no API key. After a consent prompt, its model is downloaded on
first use into ~/.local/share/talk-rs/models/.
Installation
If building from a git clone, run ./autogen.sh first to resolve
version placeholders in Cargo.toml:
The binary is at target/release/talk-rs. Copy it somewhere in your
$PATH:
Configuration
talk-rs reads $XDG_CONFIG_HOME/talk-rs/config.yaml (typically
~/.config/talk-rs/config.yaml).
Copy the example and fill in your values:
Minimal working configuration:
output_dir: ~/talk-rs-output
providers:
mistral:
api_key: YOUR_MISTRAL_API_KEY
Required fields
| Field | Description |
|---|---|
output_dir |
Absolute path to a writable directory for recordings (a leading ~ is expanded to your home) |
providers.mistral.api_key |
Mistral API key (if using Mistral) |
providers.openai.api_key |
OpenAI API key (if using OpenAI) |
Optional fields
| Field | Default | Description |
|---|---|---|
providers.mistral.url |
https://api.mistral.ai |
Mistral API base URL |
providers.mistral.model |
voxtral-mini-2507 |
Mistral transcription model |
providers.mistral.context_bias |
none | Comma-separated words for accuracy |
providers.mistral.tts_model |
voxtral-mini-tts-latest |
Voxtral TTS model for speak --provider mistral (shares the STT apikey) |
providers.mistral.tts_voice |
none | Default Mistral preset voice id for speak (else pass --voice) |
providers.openai.url |
https://api.openai.com |
OpenAI API base URL |
providers.openai.model |
gpt-transcribe |
OpenAI batch model |
providers.openai.realtime_model |
gpt-live-transcribe |
OpenAI realtime model |
providers.openai.prompt |
none | Prompt for spelling, punctuation, and context hints |
providers.openai.keywords |
none | Expected vocabulary list for the new models |
providers.openai.languages |
none | Ordered expected-language list |
providers.openai.realtime_delay |
none | Realtime delay: minimal, low, medium, high, or xhigh |
providers.kokoro.model_dir |
XDG data dir | Kokoro TTS model cache dir (auto-downloaded on first speak) |
providers.kokoro.voice |
per-language default | Default Kokoro voice name (af_heart, ff_siwis, …) |
providers.kokoro.num_threads |
4 |
Kokoro inference threads |
providers.kokoro.lang |
en (model-baked) |
Default Kokoro phonemization language |
transcription.default_provider |
mistral |
Default transcription provider when unspecified |
speak.default_provider |
kokoro-if-configured | Default speak provider (kokoro or mistral) |
indicators.boop_interval_ms |
5000 |
Periodic boop interval in ms (0 disables boops; also --no-boop) |
indicators.visual_overlay |
true |
Show X11 overlay badge |
indicators.viz |
none | In-badge visualizer: waterfall, amplitude, or spectrum (also --viz; env TALK_RS_INDICATORS_VIZ) |
indicators.mono |
false |
Monochrome visualizer (also --mono) |
paste.chunk_chars |
150 |
Max chars per paste chunk (0 disables chunking; also --no-chunk-paste) |
audio.bt_auto_switch |
true |
Auto-switch a connected Bluetooth headset to HFP for the duration of a recording, then restore (also --no-bt-auto-switch; env TALK_RS_AUDIO_BT_AUTO_SWITCH) |
recording.sample_rate |
48000 |
Sample rate (Hz) of the record command's .ogg output (env TALK_RS_RECORDING_SAMPLE_RATE) |
recording.channels |
1 |
Channels for record output: 1 mono, 2 stereo (env TALK_RS_RECORDING_CHANNELS) |
recording.bitrate |
128000 |
Opus bitrate (bps) of the record command's .ogg output (env TALK_RS_RECORDING_BITRATE) |
The recording.* settings control the quality of recordings meant for a
human to listen to or share. They are independent of transcription:
audio sent to the providers is always downsampled to 16 kHz mono
internally (both Voxtral and Whisper operate at 16 kHz), so these knobs
do not affect transcription accuracy or upload size.
OpenAI model migration
The OpenAI batch default is exactly gpt-transcribe, and the realtime
default is exactly gpt-live-transcribe. Existing configuration files
remain valid: prompt, keywords, languages, and realtime_delay
are optional, and omitting them sends no hint fields.
gpt-transcribesendsprompt, repeatedkeywords[], repeatedlanguages[], andresponse_format=jsonto the batch endpoint.gpt-live-transcribenestsmodel,prompt,keywords,languages, anddelayundersession.audio.input.transcriptionin the realtimesession.update.whisper-1remains available for segment/word timestamps, subtitles, and translation workflows. It retainsresponse_format=verbose_json, acceptspromptand one unambiguous singularlanguage, and does not acceptkeywordsor multiple languages.gpt-4o-transcribeandgpt-4o-mini-transcriberemain available as legacy batch choices.gpt-realtime-whisperremains available as a legacy realtime choice, using singularlanguagefor one expected language and rejectingkeywords, multiple languages, andrealtime_delay.
Configured hints that the selected model cannot represent fail locally before HTTP or WebSocket traffic; talk-rs never silently drops them. There is no structured previous-turn configuration field in the official schema. Earlier-turn context is service-managed where supported.
Direct-OpenAI prices checked 2026-07-31: gpt-transcribe costs
$0.0045/min ($0.27/hr), while whisper-1 costs $0.006/min ($0.36/hr).
The new batch default is 25% lower, a $0.09 per hour savings.
Environment overrides
Every config value can be overridden via environment variables:
TALK_RS_PROVIDERS_OPENAI_KEYWORDS and
TALK_RS_PROVIDERS_OPENAI_LANGUAGES are comma-separated lists. talk-rs
trims whitespace and ignores empty entries deterministically;
environment values override YAML in both existing and
environment-created OpenAI sections.
See config.example.yaml for the full list.
Usage
Global options
| Flag | Effect |
|---|---|
-v |
Increase logging verbosity (-vv debug, -vvv trace) |
--log-file <PATH> |
Write logs to a file in addition to stderr (env TALK_RS_LOG_FILE); propagated to the toggle daemon |
Dictate (main workflow)
Record, transcribe, and paste into the focused application:
Toggle mode (ideal for keyboard shortcuts):
First call starts a background daemon that records. Second call stops recording, transcribes, and pastes the result.
Options:
| Flag | Effect |
|---|---|
--toggle |
Daemon toggle mode |
--provider |
Choose mistral, openai, or parakeet |
--model |
Override model for this invocation |
--diarize |
Enable speaker diarization (batch mode only) |
--timestamp |
Include timestamps in output (HH:MM:SS prefix) |
--realtime |
Stream audio via WebSocket (incremental text) |
--pick |
Show multi-candidate picker (GTK window) |
--retry-last |
Re-transcribe the last cached recording |
--replace-last-paste |
Delete previous paste before inserting new text |
--save <PATH> |
Save audio recording to a file |
--output-yaml <FILE> |
Write transcription metadata YAML |
--input-audio-file <FILE> |
Feed a pre-recorded audio file instead of live mic |
--monitor |
Mix system audio (monitor) with mic input |
--no-sounds |
Disable audio indicators |
--no-boop |
Disable periodic boop sounds (keep start/stop) |
--no-chunk-paste |
Paste all text in one shot (disable chunking) |
--no-overlay |
Disable visual overlay |
--no-auto-pause |
Disable auto-pause during silence (forward all audio) |
--no-paste |
Skip pasting transcription into the focused application |
--upload-format <FORMAT> |
Audio format for batch uploads: wav (default) or ogg |
--viz <MODE> |
In-badge visualizer: waterfall, amplitude, or spectrum |
--mono |
Monochrome visualizer (theme-aware) |
--no-bt-auto-switch |
Disable Bluetooth headset HFP auto-switch (overrides audio.bt_auto_switch) |
Record
Capture audio to an OGG/Opus file:
Toggle mode starts a background recorder on the first call. The second
call sends SIGINT; the recorder stops capture, finalizes and syncs the
audio file, then exits. Its PID remains published during finalization,
so repeated stop calls cannot start a second writer for the same toggle
slot. Toggle mode conflicts with --ui.
Foreground and toggle recording use the same feedback as dictation: the
start tone finishes before capture begins, an optional X11 badge
visualizes the captured PCM, and the periodic boop sounds only while the
badge detects silence. On stop, the boop and badge are torn down before
capture closes. The stop tone sounds only after the encoder has
finalized the file and sync_all() has made it durable. If X11 is
unavailable, recording and sound feedback continue without the badge.
Options:
| Flag | Effect |
|---|---|
--toggle |
Toggle background recording on the first/second call |
--monitor |
Mix system audio (monitor) with microphone input |
--no-sounds |
Disable start, stop, and boop sounds |
--no-boop |
Disable silence-gated boops (keep start/stop) |
--no-overlay |
Disable the recording badge |
--viz <MODE> |
In-badge visualizer: waterfall, amplitude, or spectrum |
--mono |
Monochrome visualizer (theme-aware) |
--ui |
Open GTK4 recordings browser (play, delete, open folder) |
--no-bt-auto-switch |
Disable Bluetooth headset HFP auto-switch (overrides audio.bt_auto_switch) |
Toggle state is stored under $XDG_CACHE_HOME/talk-rs/ (normally
~/.cache/talk-rs/). Dictation keeps its existing daemon.pid,
daemon.lock, and daemon.log files. Standalone recording uses the
separate record.pid, record.lock, and record.log files, so the two
commands cannot stop or overwrite each other's toggle state.
Transcribe
Transcribe an existing audio file:
Options:
| Flag | Effect |
|---|---|
--provider |
Choose mistral, openai, or parakeet |
--model |
Override model for this invocation |
--diarize |
Enable speaker diarization (tag by speaker) |
--timestamp |
Include timestamps in output (HH:MM:SS prefix) |
-
Provider overload
Cloud providers periodically answer
503 high loador429 backend_out_of_capacity, especially on long recordings.talk-rstreats every 5xx and 429 as "busy, try again later" and waits between attempts: 5 s, 15 s, 30 s, 60 s, 120 s, 120 s (about 5.5 minutes in total, seven attempts). ARetry-Afterheader from the provider overrides the schedule slot (capped at 120 s). Each wait is logged at-vasserver retry N/6. Other 4xx answers are permanent and fail immediately.Connection failures (DNS, unreachable host, TLS timeout) use a separate, shorter schedule and never consume the server-retry budget. Large uploads are given the whole request time budget on every attempt, so an 80-minute recording is not cut off by the early connection slots.
If all seven attempts fail the recording is kept in the cache; re-run
transcribeon the.ogg(ordictate --retry-last) once the provider has recovered.
Speak (text-to-speech)
The architectural mirror of transcribe: text in, speech out.
Synthesize text and play it through the speakers, or save it to a WAV
file. Two providers – kokoro (local, offline, on-device via
sherpa-onnx) and mistral (remote Voxtral TTS).
|
Text is resolved in priority order: the positional argument, then
--file, then stdin (when stdin is not a TTY).
The default provider is resolved as --provider >
speak.default_provider (config) > the local kokoro backend when a
providers.kokoro section exists, else mistral. On first use the
Kokoro model (~350 MB) is downloaded after an interactive consent prompt
(or a non-interactive proceed-with-log, exactly like the Parakeet ASR
model) into ~/.local/share/talk-rs/models/kokoro-multi-lang-v1_0/.
Options:
| Flag | Effect |
|---|---|
--provider |
Choose kokoro (local) or mistral (remote Voxtral) |
--voice |
Kokoro voice name (af_heart, am_michael, ff_siwis, …) or a Mistral preset voice id |
--lang |
Language for Kokoro phonemization; for Mistral, selects/validates the preset voice (auto-detected from the text when omitted) |
--speed |
Speech rate multiplier for Kokoro (1.0 = normal) |
-f, --file |
Read the text to speak from a file |
-o, --output |
Save synthesized audio to a WAV file instead of playing it |
--force |
Bypass the voice/language mismatch guard |
Language handling is config-driven and agnostic: the requested language
selects the phonemizer. The stock Kokoro model ships one baked language;
other languages are derived on demand by patching the model's ONNX
voice metadata, cached as model-<lang>.onnx next to the stock model.
Supported input audio formats
Commands that read audio from disk – transcribe,
dictate --input-audio-file, dictate --retry-last, and the
record --ui recordings browser – accept the following formats:
| Extension | Container / codec | How it is handled |
|---|---|---|
.ogg |
Ogg Opus | Native format written by talk-rs record |
.m4a / .mp4 |
MP4 / AAC | Decoded via symphonia; ideal for iPhone voice memos and similar imports |
.aac |
Raw AAC stream | Decoded via symphonia |
.wav |
16-bit PCM | Decoded directly; primarily used for legacy cache entries |
Imported files keep their original extension on upload, so the
transcribe command sends .m4a to the provider as-is – both Mistral
Voxtral and OpenAI Whisper accept these natively. Playback and waterfall
spectrograms in the records browser work for every listed format.
GNOME keyboard shortcut
Bind talk-rs dictate --toggle to a key (e.g. Super+/):
BASE="org.gnome.settings-daemon.plugins.media-keys"
BPATH="/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings"
CURRENT=
N=0
while | ; do
N=
done
SLOT="/custom/"
SCHEMA=".custom-keybinding:"
if [; then
else
fi
First press starts recording, second press stops, transcribes, and pastes into the focused application.
To change the key, replace <Super>slash with the desired binding (e.g.
<Super>semicolon, <Super>d). Add --realtime to use streaming
transcription instead of batch mode.
Development
Cargo feature flags
The default build enables everything (full dictation CLI). Library
consumers can opt out of the desktop stack with
default-features = false:
| Feature | Default | Pulls in | Provides |
|---|---|---|---|
| (core) | always | reqwest, tokio-tungstenite, opus, rubato, symphonia, … | Transcription providers (Mistral/OpenAI batch + realtime WS), Mistral Voxtral TTS, resampler, Opus/OGG encode, file decode, config, cache |
parakeet |
yes | sherpa-onnx (static C++), tar, bzip2 | Local ASR backend (Parakeet TDT, CPU) |
kokoro |
yes | sherpa-onnx (static C++), tar, bzip2 | Local TTS backend (Kokoro multi-lang, CPU) for speak |
playback |
yes | cpal | Local audio playback (the speak command's speakers output; shared player) |
capture |
yes | pipewire, cpal (via playback), libpulse-binding |
Live mic/monitor capture, BT headset HFP switch, indicator tones |
ui |
yes | gtk4, x11rb, png, fontdue, dark-light, … (+ playback) |
X11 overlay, visualizers, clipboard/paste, picker, recordings browser |
Example headless consumer (cloud transcription only):
= { = "../talk-rs", = false }
License
MIT – see LICENSE.
Changelog
0.7.1 (2026-09-17)
Fix
-
[dictate] reject a bad API key or model up front in
--realtimemode. [Valentin Lab]Batch dictation runs a pre-flight check (API key, model) and fails immediately on a misconfiguration. Realtime dictation skipped it:
RealtimeTranscriber::validatewas fully implemented for Mistral and OpenAI but never called, hidden behind three#[allow(dead_code)], so a bad key or model only surfaced from inside the streaming loop after the user had started talking.dictate_realtimenow callsvalidateright after creating the transcriber and before opening the streaming session, so the error is immediate and enriched with the available models; reconnects keep skipping it. Capture itself has already started at that point and buffers meanwhile: the check gates the provider session, not the microphone. The three pragmas are gone, so the compiler would flag any future regression. A unit test drivesvalidateagainst a mock that rejects the WebSocket upgrade with 401 and checks that exactly one handshake happens.
0.7.0 (2026-09-17)
New
-
[overlay, picker] show a draining countdown while the provider is busy. [Valentin Lab]
During a server backoff the badge used to sit on a static dim-blue stripe with
TRANSCRIBINGfor up to two minutes — indistinguishable from a hang, which is exactly how users reported it.The badge now enters an amber "waiting on the provider" state: the phase-line row becomes a solid amber band that drains right-to-left across the wait (refilling when the next retry fires), and the centre text reads
BUSY · RETRY N/Mso the user knows it is the provider, not their setup, and how far through the retry budget they are. The scrolling phase history records the wait as dim amber so the timeline still shows how long the provider stayed busy after the retry succeeds. Connection retries keep their previous rendering.The picker row label gains the wait as well:
server retry 1/6 in 30s….TranscriptionEvent::RetryScheduledcarries a newdelayfield (zero for connection retries) so UI consumers can render the countdown from the event's own timestamp. -
[record] add feedback parity with
dictate[Valentin Lab]Make standalone recordings provide the same start and stop tones, silence-gated boop, and optional X11 badge as dictation.
Share one feedback lifecycle so
--no-sounds,--no-boop,--no-overlay,--viz, and--monobehave consistently in foreground and--togglemodes. -
[record] support
record --togglelifecycle. [Valentin Lab]Give standalone recording the same toggle workflow as dictation while keeping each command's
daemonstate isolated.Register
SIGINTbefore startup work and retain ownership through encoder finalization andsync_all()so rapid stop requests cannot race a second recorder.
Fix
-
[transcription] cache model suggestions per API key, not only per endpoint. [Valentin Lab]
Two credentials pointing at the same
api_baseshared one cached model list, so a second key could be served the entitlements of the first for up to an hour. The in-process cache is now keyed by(api_base, api_key).This also isolates the
model_suggestionsunit tests, which previously failed under--test-threads=1because consecutiveMockServerinstances reuse the same port and therefore the same cache entry. -
[transport] wait out provider overload instead of failing within two minutes. [Valentin Lab]
Long recordings sent to Mistral regularly failed with
503 high load, please retryor429 backend_out_of_capacity: the transport retried a 5xx at most twice with no delay, never retried a 429 at all, and capped every attempt at the connection-phase budget (2, 5, 8, 11, 15, 30, 120s), so a 26 MB upload was structurally doomed on the first six slots. An 83-minute call thus needed seven uploads to succeed when the API was healthy, and gave up in about two minutes when it was not.Data-phase (server busy) retries now form their own loop, independent of the connection-phase budget: every 5xx and 429 is retried after a wait of
5, 15, 30, 60, 120, 120s (seven attempts, about 5.5 minutes), a server-suppliedRetry-Afteroverrides the schedule slot (capped at 120 s), and the wait is cancellable. The per-attempt cap is lifted to the request's ownwall_clockwhen that is larger, so large uploads get their full proportional budget on every attempt. Each backoff is logged atinfoasserver retry N/6.PipelineFailureannotates an exhausted 429 as "server-retry budget exhausted" rather than "4xx permanent, no retry".
0.6.0 (2026-08-02)
New
-
[transcription] expose
OrderedItemTranscriptto downstream consumers. [Valentin Lab]Item-aware realtime providers report transcript text as ordered conversation items rather than as the generic
TextDelta/SegmentDeltapair, and reconciling those items requires conversation ordering, provisional-to-authoritative replacement, and a terminal drain.OrderedItemTranscriptalready implements exactly that, but it was crate-private, so an external consumer ofTranscriptionEventhad no way to handle the item-aware variants without reimplementing the same ordering algorithm.Publish the type and the six methods such a consumer needs, and keep the internal ordering state and the replay helpers restricted.
-
[openai] add
gpt-transcribeand item-aware realtime support. [Valentin Lab]Integrate the new batch and live models with configurable context, keyword, language, and delay hints while retaining legacy model compatibility.
Reconcile provisional realtime deltas by item so authoritative completions replace rather than duplicate picker and dictation text.
-
[speak, synthesis] add TTS
speakcommand with Kokoro and Mistral providers. [Valentin Lab]Architectural mirror of the
transcriptionsurface on the synthesis side: aOneShotSynthesizertrait with a cfg-gated factory, a local Kokoro backend (kokorofeature, samesherpa-onnxstatic lib asparakeet, consent-gated ~350 MB model download via the sharedmodel_fetchhelper) and a remote Mistral Voxtral backend (POST /v1/audio/speech, sharing the existingproviders.mistralAPI key via newtts_modelandtts_voicefields).talk-rs speakreads text from the positional argument,--fileor stdin, then plays through theAudioPlayeror saves a WAV with-o.Language handling is automatic by default:
whichlangdetects the text language, selecting the phonemizer for Kokoro (non-default languages derive a metadata-patchedmodel-<lang>.onnx, cached next to the stock model) and the per-language voice for Mistral (tts_voicesconfig map or built-in presets).--langoverrides detection. An explicitly pinned voice whose language contradicts the resolved language is a hard error naming the voice, both languages and the remedies;--forcebypasses the guard. Voices with unknowable language (custom Mistral UUIDs) skip the guard.Config surface:
providers.kokoro(voice,num_threads,lang,model_dir),providers.mistral.tts_voices,speak.default_provider, with matchingTALK_RS_*env overrides. -
[paste] composable paste-node tree configurable per target window. [Valentin Lab]
Refactor the paste subsystem into a tree of composable nodes (router / decorator / leaf), each implementing a common
paste()interface, selectable via a recursivepaste:tree in the config.Node families:
- routers:
detect-display-serverandmatch-wm-class(first-match glob routing onWM_CLASS); - decorator:
chunk; - leaves:
clipboardandxtest-type.
This lets the user pick chunking, paste shortcut (
ctrl_vvsctrl_shift_v), or XTest typing on a per-target-window basis.Fully backward compatible: absent
paste:reproduces the previous behaviour; the old flatpastekeys are still accepted and mapped to an equivalent tree;--no-chunk-pastestrips the chunk nodes.Runtime paste behaviour is unchanged in this commit (the existing clipboard sync is moved verbatim into the
clipboardnode).Also add an
x11_get_wm_classhelper to drive thematch-wm-classrouter. - routers:
-
[transcription, config] add local Parakeet backend with consent-gated download. [Valentin Lab]
Add NVIDIA Parakeet TDT 0.6b v3 as a local, on-device, CPU transcription provider selectable via
provider: parakeet(configtranscription.default_provideror the--providerflag), running fully offline through a statically-linkedsherpa-onnx.The ~640 MB model is NEVER downloaded silently. The transcribe pipeline only checks for the model (
model::ensure_present) and errors if it is absent; the actual fetch (model::download_model) is an explicit, consented step driven per entry surface:transcribeCLI:[y/N]prompt on a TTY; a clearstderrmessage then proceed when piped (selecting parakeet is the consent).dictate --toggle: a "DOWNLOADING MODEL" overlay badge while it fetches, before recording starts.--pickpicker: a GTKAlertDialogon click; Cancel is a clean no-op. Parakeet is only listed whenproviders.parakeetis configured, and an absent default model is demoted to a click-to- download row rather than auto-erroring.
The download is atomic (extract + verify into a staging dir, then a single
renameinto the cache) with anfs2lock for concurrent invocations, so an interrupted run never leaves a half-populated model dir. Inference runs inspawn_blockingand reuses the existing 16 kHz mono decode pipeline. The backend sits behind aparakeetcargo feature (on by default;--no-default-featuresdrops the C++ dependency for a lean cloud-only build). -
[record, config] add human-quality recording profile for
.oggoutput. [Valentin Lab]The
recordcommand reusedAudioConfig::new()— the 16 kHz mono 32 kbps profile required by the transcription providers — to write recordings meant for a human to listen to. At 16 kHz the Opus encoder is capped to an 8 kHz audio bandwidth (Nyquist), so shared recordings sounded muffled and dull.Separate the two concerns: the transcription profile stays hardcoded and hidden (an API requirement, not a user preference), while a new user-facing
recording:config section controls therecordoutput quality. It exposessample_rate(default 48000),channels(default1mono) andbitrate(default 128000), each also settable viaTALK_RS_RECORDING_*env vars.The
recordpath now:- resolves its
AudioConfigfrom therecording:section; - captures via
PipeWireCapture(negotiates the requested rate / channels with the device, robust on mono-only mics) instead of the exact-matchCpalCapture; - encodes with
OggOpusWriter::new_for_recording(OpusApplication::Audiofor fuller fidelity vs the speech-optimisedVoipmode kept by the transcription path).
Verified empirically: a fresh recording now carries energy up to ~20 kHz (was a hard wall at 8 kHz). Documents the new section in
config.example.yamlandREADME.org; adds config tests for defaults, YAML parsing, partial overrides and env overrides. - resolves its
-
[config] expand a leading
~inoutput_dirto$HOME[Valentin Lab]The README minimal example and many users naturally write
output_dir: ~/talk-rs-output, but there was no tilde expansion: the value was taken literally as a relative directory named~. Combined with the new absolute-path requirement, the documented example would now fail outright.Expand a leading
~(bare) or~/…to the user's home directory (viadirectories::UserDirs) at load time, after theTALK_RS_OUTPUT_DIRoverride and before validation, so both the file value and the env override benefit. A tilde that is not the first path component (e.g./tmp/~/x) and the~userform are left untouched. Document the behavior inconfig.example.yamlandREADME.org; add tests for the bare-~,~/…, env-override, and non-leading-tilde cases. -
Add --timestamp flag for diarized transcription output. [Boris Gallet]
Adds a CLI flag --timestamp (also -t) that prefixes each speaker-attributed line with [HH:MM:SS] in the transcript output.
- CLI: --timestamp added to both transcribe and dictate commands
- Config: env var TALK_RS_TIMESTAMP also supported
- Output format: [HH:00:00] speaker_1 text...
- Tests: 5 new tests covering timestamp formatting, edge cases, and integration with diarization
Motivation: when transcribing long meetings (>30min), knowing when each speaker talked is essential for navigation and summary.
-
[paste, config] configurable paste shortcut (Ctrl+V vs Ctrl+Shift+V) [Boris Gallet]
Add
PasteShortcutenum toPasteConfigwith two variants:ctrl_shift_v(default) — primary selection pastectrl_v— regular clipboard paste for terminals/Emacs
Refactor
simulate_paste()to takePasteShortcutand resolve the correct X11 keysyms via a purepaste_keysyms()function. Propagate the shortcut throughpaste_text_to_target(), the realtime per-segment paste task, and the picker path. -
[picker, realtime] surface WS session phases as picker row status. [Valentin Lab]
-
[picker, transcription] picker Stop button via SIGUSR1 cross-process cancel. [Valentin Lab]
-
[transcription] cross-process job registry via lock-file YAML + SIGUSR1. [Valentin Lab]
Step 11 of the transport-consolidation plan at
.sisyphus/plans/transport-consolidation.md.New module
transcription::jobsextends the existing per-model lock files inrecording_cachewith a YAML payload describing the owner process and adds SIGUSR1-based cross-process cancellation. -
[transcription, picker] wire telemetry sink into realtime path. [Valentin Lab]
Step 10 of the transport-consolidation plan. Realtime transcribers now accept a telemetry sink so WS upgrade phase and retry events reach the picker UI, fixing the silent-blocking symptom.
-
[transcription, transport] introduce unified
http_request/ws_upgradeAPI. [Valentin Lab]Adds the public transport-consolidation API surface (
talk_rs::transcription::transport::{http_request, ws_upgrade}) plus the truthful-counter, growing-budget, cancellablehttp_requestimplementation.This is Steps 0-2 of the consolidation plan at
.sisyphus/plans/transport-consolidation.md. The plan resolves three long-standing bugs surfaced by the user: (a)send_oncehardcodesattempts=1, max_attempts=1in everyPipelineFailure, (b) the connect budget never grows across attempts, (c) requests cannot be cancelled. After this commit:http_requestreports truthfulattempts/max_attemptsinPipelineFailure(was1/1everywhere).- Connection-phase retries run on the growing budget
[2, 5, 8, 11, 15]seconds, wrapped in an outertokio::time::timeoutso blackhole destinations don't hang for the OS-default ~125s TCP SYN timeout. - Data-phase retries (5xx responses) run up to 3 attempts.
CancellationTokenaborts an in-flight request viatokio::select!within ms of the trigger.
The legacy
with_retry/send_oncepaths are untouched — they will be migrated in Steps 3-5 of the plan. Multipart bodies are intentionally rejected byhttp_requestfor now; Step 4 adds a body-factory shape so retries can rebuild a freshreqwest::multipart::Formper attempt.Tests:
tests/transport_integration.rspins the spec for the unified transport. Five of the ten tests pass after this commit (connection budget, data retries, both attempts-counter tests, cancellation). The remaining five remainunimplemented!()/panic!placeholders for Step 6 (ws_upgrade) and Step 11 (jobscross-process registry); they ship RED on purpose and will flip green as those steps land.The
transcription::transportmodule changes frompub(crate)topubso integration tests can pin the public surface. -
[record] m4a / mp4 / aac read support across the recordings browser. [Valentin Lab]
The recordings browser (
record --ui) silently ignored any file that was not.ogg: the directory walker filtered on a hardcoded extension, the waterfall decoder errored on unknown formats, the playback path fell through to the WAV parser (producing either an error or garbage), and theinotifylive-refresh filter listed only.wav/.oggevents. Imported audio — typically.m4avoice memos copied intooutput_dir— was therefore invisible in the UI even though every downstream consumer (the transcription providers, the GTK list, the file manager) handles those files natively.Add full read support for
.m4a,.mp4, and raw.aacacross every audio-consuming path of the browser:-
New
read_m4a_as_f32insrc/record/audio.rsuses [[https://github.com/pdeljanov/Symphonia][symphonia]] for ISO/MP4 demuxing and AAC-LC decoding. Multichannel input is averaged to mono and resampled with the existingresample_linearhelper to match the calling site's rate, mirroring theread_ogg_as_f32/read_wav_as_f32contract. The authoritative sample rate and channel count are read from the first decodedAudioBuffer'sSignalSpec, never fromcodec_params: symphonia's ISO/MP4 demuxer is known to underreport channels for AAC tracks (a stereo AAC-LC track surfaces ascodec_params.channels = 1even though the decoder produces a 2-channel buffer), which would otherwise make the stereo-to-mono mixdown walk the interleaved buffer with the wrong stride, doubling the output sample count and halving playback speed / pitch. Only theaac+isomp4symphonia feature flags are enabled, so the release binary picks up an AAC decoder- MP4 demuxer (~200 KB) and no other codecs.
-
New
m4a_duration_secswalks the MP4 box hierarchy to readmvhd.duration/mvhd.timescaledirectly — O(box-count), no full decode required. Mirrors the O(1)ogg_duration_secsprobe so the listing stays snappy on large files. Supports both v0 and v1mvhdlayouts and toleratessize = 0("to end of file") andsize = 1(64-bit extended) box headers. -
The central dispatcher
read_audio_as_i16gains the"m4a" | "mp4" | "aac"arm — this automatically extends m4a support to every caller, including the picker waterfall (src/dictate/picker/ui.rs) and the sharedaudio_player_barwidget. -
src/record/entries.rsis generalised:collect_oggs_recursive→collect_audio_recursive, with a sharedAUDIO_EXTENSIONSconstant and a case-insensitivehas_audio_extensionhelper so.M4Afiles from a camera are picked up alongside lowercased ones. Listing-time duration is dispatched through a newaudio_duration_secsthat routes to the per-format probe. Bothlist_ogg_recordings(now lists all audio underoutput_dir) andlist_cache_recordings(dictation cache) use the new collector. -
WavPlayer::playinsrc/record/player.rslearns the samem4a | mp4 | aacarm. The fallthrough toread_wav_as_f32is preserved — backwards-compatible with the legacy.wavcache entries — but is no longer hit for AAC content, which previously produced corrupted playback. -
The
GTK4FileMonitorfilter insrc/record/ui.rs:908treats every listable extension as audio. The comparison is lowercased so case-mixed imports trigger the live-refresh. -
delete_recordingextends the YAML companion +.wfwaterfall cache cleanup so deleting an.m4arow sweeps the same sidecars an.oggrow would.
Test coverage:
- Two shipped fixtures under
tests/fixtures/: the originalsine_440_0.5s_mono.m4a(5 345 B, 440 Hz sine, AAC-LC, mono, 0.5 s) and the newsine_440_0.5s_stereo.m4a(5 026 B, same signal, stereo) that mirrors the format produced by the AudioRecorder app the user imports voice memos from — both are documented intests/fixtures/README.mdwith theffmpegregeneration command. - 5 new
record::audio::testscoverm4a_duration_secsagainst the mono fixture (±0.1 s tolerance to absorb encoder rounding),read_m4a_as_f32for mono (sample-count window + non-silent peak),read_m4a_as_f32for stereo at both 16 kHz and 48 kHz (the regression test that locks in theSignalSpec-vs-codec_paramscorrectness — empirically verified to fail with a 2× sample count when the bug is reintroduced),read_audio_as_i16dispatch through to the AAC arm, and the unsupported-format error path. - 4 new
record::entries::testscover the broadened collector (.m4a/.MP4/.aacmixed with.ogg, case-insensitive extension matching, mixed flat + nested layouts) and theaudio_duration_secsdispatcher'sNone-on-unknown semantics. - Full
cargo fmt,cargo clippy --all-targets --all-features,cargo test(438 unit tests pass, 0 fail), andcargo build --releaseclean.
README.orggains a "Supported input audio formats" subsection under Usage and updates the recordings-browser feature bullet to mention.m4aimports;README.mdis regenerated byautogen.sh. -
-
[overlay] surface validate phase + retry counter on the spectrogram. [Valentin Lab]
Add a dedicated
Phase::Validatingstate to the overlay's HTTP state machine, driven by thePreflightStarted/PreflightCompletedtelemetry events emitted by the validate-cache miss path. The phase reuses the same green asPhase::Donebut at half opacity so the user can tell at a glance that the network roundtrip in flight is the cheap/v1/modelsvalidation rather than the actual transcription upload.Phase::colornow returns([u8; 4], f32)so each phase carries its own opacity; throughput tracks drop the opacity component since they always render at full strength.Also draws a small attempt counter at the top-left of the SPEC area whenever a
RetryScheduledevent fires (validate retries or transcription retries), dimmed to follow the current phase's opacity so a validate retry shows a half-opacity "2" while a transcription retry shows a full-opacity "2". The counter is cleared at the start of every fresh recording cycle and at every freshPreflightStarted/RequestStartedso a stale digit never bleeds into the next attempt.To support the dimmed stripe and counter,
PixelBuffergains ablend_pixel(x, y, color, opacity)helper that linear-blendsBGRAover the existing background and leaves the alpha channel untouched (the X11 overlay window owns transparency, we never want a hole punched through the badge layer).Tests cover:
- the new phase transitions (
PreflightStarted→Validating, success →Idle, failure →Error,RetryScheduledinside vs. outsideValidating); Phase::colorreturning green at 0.5 forValidatingand green at 1.0 forDone, plus a regression check that every non-validating phase renders at full opacity;render_retry_counterpainting nothing for attempt 0, visible pixels for attempt >= 1, and being measurably dimmer at half opacity (skip-clean if no system font is installed);PixelBuffer::blend_pixelat 0.0 / 0.5 / 1.0 / clamp / OOB.
- the new phase transitions (
-
[picker, transcription] reliable picker transcription with live status, validation cache, and timeout attribution. [Valentin Lab]
Three coupled changes that together make the picker UI usable on flaky networks (notably VPNs that drop connection attempts mid-flight):
-
RequestTimeoutPolicyenum +TranscribeOptionsstruct. Picker rows run underUserAttended: no per-request wall-clock cap, only the client-wideconnect_timeoutplus TCP-level defences (tcp_user_timeout, TCP keepalive). Slow servers no longer trip a 3-second wall-clock that fires after a successful connect. Autonomous callers (dictateend-of-recording,transcribeCLI,produce_transcript) keep the previousProportionalpolicy so an unattended pipeline cannot hang. Threaded throughtranscribe_audio,create_batch_transcriber, and thewith_policyconstructors onMistralBatchTranscriber/OpenAIBatchTranscriber. -
Disk-backed validation cache for
/v1/models(\$XDG_CACHE_HOME/talk-rs/validate-cache.yaml, 24-hour TTL, atomic tempfile + rename). Successful preflights memoize, so subsequent transcriptions for the same(provider, model, api_base)skip the network entirely within the TTL. Writes are atomic and last-writer-wins; corrupt cache files fall through to network rather than panicking. On cache miss the preflight runs a growing-budget retry of [2, 5, 8, 11, 15] seconds across 5 attempts so a transient connect blip cannot doom the whole transcription on the first attempt; permanent errors (HTTP 4xx, model-not-found) bail without retry. The user-facing error now leads with"<Provider> model validation failed (preflight to /v1/models)"so the failed concern is unambiguous (the prior lead phrase"Failed to connect to <Provider> API"falsely implied the transcription itself failed). -
Live status display in picker candidate rows. New
PickerStatusSinktranslates HTTP-pipeline telemetry into short italic strings rendered at 0.55 opacity in place of the transcript area:pre-validating model…(cache-miss only),connecting…,uploading…,waiting for server…,transcribing…,retry N/M…. Final candidate (success or error) replaces the status with the actual transcript or error. Action button tooltip mirrors the status while in-flight and clears on terminal events. Realtime rows are left alone — their partial transcript already serves as status. NewTranscriptionEvent::PreflightStarted/PreflightCompletedvariants surface the validate phase to any sink (only emitted on cache miss);PreflightCompletedis silent on the picker so the next event replaces the line without flicker.
Also includes the diagnostic logging that enabled this fix:
TimerSpecslice +format_reqwest_error_with_timersmake reqwest error messages name which timer fired (connect_timeout,request_wall_clock,validate_request, orkernel_tcp_unspecified) and quote its budget, so log readers no longer have to correlate elapsed time against client state to identify a timeout source.New module:
src/transcription/transport/validate_cache.rs. New tests: 8 cache tests, 4validate_modeltests covering schedule + cache-hit + retry semantics, 7PickerStatusSinktests covering phase transitions / dedup / cache-hit / preflight silence, plus theuser_attended_policy_omits_request_wall_clock_attributionregression detector. 389 → 404 lib tests passing, 0 failures.The stale comment at
picker/backend.rs:152-155claiming "no artificial outer timeout is needed" is updated to describe the actualUserAttendedsemantics. -
-
[audio, dictate, record] Bluetooth headset HFP auto-switch. [Valentin Lab]
When a Bluetooth headset is connected in A2DP mode (high-quality stereo output, no microphone),
talk-rsnow automatically switches it to its Hands-Free Profile (HFP) for the duration of a recording so the headset microphone is enabled, then restores the original profile on stop. This replaces the equivalent feature of the legacymemoshell script and removes the need to manually toggle profiles when dictating with a Bluetooth headset.Detection uses PulseAudio's standard
device.form_factor = "headset"property (not vendor-specific identifiers), with a fallback for stacks that omitform_factor(matchesbluez_card.*cards exposing aheadset-head-unit*profile). Profile selection prefersheadset-head-unit-msbc(16 kHz wideband) →headset-head-unit-cvsd(8 kHz narrowband) →headset-head-unit(generic), all of which are PulseAudio-standard names.Crash resilience: the saved profile is persisted to
$XDG_RUNTIME_DIR/talk-rs/card-profile.jsonBEFORE the switch, so an unclean termination (SIGKILL, power loss) leaves a recoverable state file behind. A newrecover_stale_profile()runs at the start of every live-capture command and restores the profile from that file before activating HFP for the new recording — so even after a crash mid-recording the user gets A2DP back on next launch. This is a real improvement overmemo, which overwrote its state file on start and could permanently lose the original profile after a crash.Implementation uses
libpulse-bindingv2.30 — pure Rust, nopactlsubprocess and no D-Bus. On PipeWire systems,pipewire-pulseprovideslibpulse.soas a compatibility shim, sopa_context_set_card_profile_by_namereaches the right device throughWirePlumber. This avoids stdout parsing and keeps error handling fully typed. Build dependency added:libpulse-dev(Debian/Ubuntu) /pulseaudio-libs-devel(Fedora).A new
HeadsetGuardRAII type holds the saved profile and restores it onDrop, so the original profile is restored on normal return,?-propagated errors, panics, andSIGINT-driven daemon exits — no need to thread explicit restore calls through every exit path.Configurable via:
--no-bt-auto-switchCLI flag on bothdictateandrecord(also forwarded to the spawned daemon by--toggle).audio.bt_auto_switchconfig key (defaulttrue).TALK_RS_AUDIO_BT_AUTO_SWITCHenv var (true/false/yes/no/1/0/on/off).- File-input dictation paths (
--input-audio-file,--retry-last) skip the switch since they don't touch the mic.
All
bt_profilefailures are non-fatal: they log a warning and let the recording proceed on whatever input device is currently active.Verified end-to-end:
- 14 new unit tests in
src/audio/bt_profile.rs(profile picker, headset detection, JSON round-trip, state-file path resolution, guard drop semantics). tests/bt_profile_smoke.rsintegration test (gated#[ignore]TALK_RS_BT_INTEGRATION=1) runs a real A2DP→HFP→A2DP round-trip against the running PulseAudio/pipewire-pulseserver.
- Full
cargo fmt,cargo clippy --all-targets,cargo test,cargo build,cargo build --releaseclean.
-
[transcription, record] surface cached transcripts in the recordings browser. [Valentin Lab]
Adds
read_cached_transcript()insrc/transcription/mod.rs-- a synchronous, network-free waterfall that returns a transcript for an audio file by walking, in priority order:- The pick file (
<stem>.pick.yml) -- the authoritative, possibly user-edited transcript. - The default-provider / default-model batch sidecar.
None-- nothing cached.
The recordings browser (
record --ui) insrc/record/entries.rsnow uses this waterfall in bothlist_ogg_recordingsandlist_cache_recordingsso an entry shows its transcript whenever one is cheaply available -- not only when it has been pick-finalised. TheInProgressstate from the pick lock still wins so the UI can keep displaying "transcription ongoing" for in-flight runs. - The pick file (
-
[transcription] persist per-segment timing in YAML sidecar. [Valentin Lab]
Providers already return timed transcript segments (Voxtral batch, Whisper
verbose_json, Voxtral realtime), but talk-rs was discarding everything except asegment_countinteger. This commit preserves the(start, end, text)tuples all the way from the API response to the YAML sidecar so downstream consumers such asactivity-memocan reconstruct sub-minute timelines without re-transcribing.Data model (
src/transcription/mod.rs):New
TranscriptSegmentstruct and an optionalsegmentsfield onTranscriptionResult. A sharedparse_transcript_segmentshelper extractsstart/end/textfrom rawserde_json::Valueslices, used by both Mistral and OpenAI parsers.Mistral batch (
src/transcription/mistral.rs):Both
transcribe_fileandtranscribe_streamnow callparse_transcript_segmentson the responsesegmentsarray. Voxtral returns segments by default withspeaker_id: nullwhen diarization is not requested — those were previously skipped byparse_diarization_segments(which requires a non-nullspeaker_id). Both extractions now coexist: diarization segments for--diarizeusers, transcript segments for everyone.OpenAI batch (
src/transcription/openai.rs):response_formatis nowverbose_jsonfor whisper models (which support it) and staysjsonfor GPT-4o transcribe models (which do not).verbose_jsoncauses Whisper to return asegmentsarray withstart/end/text— parsed the same way as Voxtral.Realtime (
src/dictate/realtime.rs,src/dictate/picker/backend.rs):SegmentDeltaevents are now destructured as{ text, start, end }instead of{ text, .. }. A parallelVec<TranscriptSegment>accumulator captures timed segments alongside the existing text accumulator. The picker UI state (picker/ui.rs) carries segments through candidate selection.YAML sidecar (
src/recording_cache.rs):New
CommonSegmentstruct with#[derive(Serialize)].RecordingMetadatagains an optionalsegmentsfield (skip_serializing_if = Option::is_none).write_metadataaccepts an extrasegments: Option<&[TranscriptSegment]>parameter; call sites indictate/mod.rsanddictate/picker/mod.rspassresult.segments.as_deref().Resulting YAML when segments are present:
segments: - start: 0.0 end: 1.5 text: Hello world. - start: 2.0 end: 3.8 text: This is a test.Absent when the provider returned no segments (old files, GPT-4o, realtime sessions where the server omits timing).
Backward compatibility: existing YAML files without
segments:continue to work. The field is purely additive.Tests: unit tests for
parse_transcript_segments(valid, malformed, empty, mixed); YAML serialization with/without segments; wiremock integration tests in bothmistral.rsandopenai.rsusing a newtests/fixtures/voxtral-response.jsonfixture. -
[overlay, indicator] add centered no-sound overlay and alert tones. [Valentin Lab]
When the overlay detects a dead audio device (no-sound), a large semi-transparent overlay now appears at the centre of the screen with a prohibit icon, "NO SOUND" title, and "No audio detected" subtitle. The overlay uses per-pixel ARGB alpha (80% opacity) when a compositor is available, falling back to solid black otherwise.
A periodic alert tone (triple-pulse at 550 Hz) plays every 2 seconds while no-sound is active, and the regular boop heartbeat is suppressed to avoid colliding sounds. An
AlertPlayerhandle allows the silence-notifier thread to play sounds without owning the fullSoundPlayer.Also fixes the badge prohibit icon regression introduced in commit 31d99f0: that commit enlarged
DOT_RADIUS_MAXfrom 10 to 21 for the volume-reactive dot, but the prohibit icon shared that constant, making its stroke look disproportionately thin. A dedicatedPROHIBIT_ICON_RADIUS(10.0) now restores the original proportions. The rendering logic is factored intodraw_prohibit_icon_with_stroke(parameterised stroke) withdraw_prohibit_iconas a thin wrapper. -
[overlay] add time-grid layer over the waterfall spectrogram. [Valentin Lab]
Draw vertical dotted yellow marks every wall-clock second, alpha-blended at 60% over the existing waterfall pixels. Each mark is a column of alternating 1 px dot / 1 px gap in pure yellow (BGR [0, 255, 255]). Destination alpha is left untouched to preserve window opacity.
Grid alignment uses
columns_pushed_total, an absolute column counter that resets together withspectrogram_history. Marks are placed whereabs_idx % COLUMNS_PER_GRID_MARK == 0and right-aligned the same way the waterfall itself is. The counter increments even during auto-pause empty-column pushes so the grid reflects true elapsed time including silent gaps.render_time_gridis called in both the normal recording path and the auto-pause path, after the waterfall and badges but before the pulsing dot / LISTENING indicator.Alpha was initially set to 0.3 but proved too subtle on real spectrograms; bumped to 0.6 after visual testing.
This establishes the rendering pattern — absolute-index layer drawn over the scrolling waterfall — that future event overlays (network phase colors, byte-count bars) will follow.
-
[transcription] add
ProgressBodystream wrapper for upload telemetry. [Valentin Lab]Add a
futures::Streamwrapper that sits between aVec<u8>audio payload andreqwest::Body::wrap_stream, emitting telemetry events as bytes flow through:ConnectionEstablishedon the firstpoll_next(proxy for TCP+TLS handshake completion)UploadProgress { bytes_sent, total, t }on every yielded chunkUploadComplete { total, t }exactly once at stream exhaustion
Boolean guards (
emitted_connection,emitted_complete) prevent duplicate events if the runtime re-polls a terminal position.Chunk size is controlled by
PROGRESS_BODY_CHUNK_BYTES(8 KiB). Alen()accessor exposes the total byte count so callers can setContent-Lengthon multipart parts.Six unit tests cover empty buffers, single-chunk and multi-chunk bodies, cumulative byte accounting, and the exactly-once guarantees for connection and completion events.
#[allow(dead_code)]annotations carry inline justification comments; the struct is wired intomistral.rs/openai.rsin sub-phase 1C and the pragmas will be removed at that point. -
[telemetry] add display-agnostic transcription event module. [Valentin Lab]
Introduce
src/telemetry/mod.rswith the foundational types for observing the HTTP transcription lifecycle without coupling to any display or pipeline module.Contents:
TranscriptionEventenum (13 variants covering request start through paste completion, each carrying a monotonicInstanttimestamp).TelemetrySinktrait (Send + Sync, singleemitmethod) so producers can holdArc<dyn TelemetrySink>without locking at the API boundary.NoOpSink— zero-cost default that drops every event; useful for tests and headless code paths.BroadcastSink— wrapstokio::sync::broadcastto fan events to any number of subscribers, dropping silently on lag or when no receivers are attached.- 7 unit tests covering sink behaviors,
Arc<dyn>usage, receiver lifecycle, and a Clone smoke test over all event variants.
The module enforces a strict dependency rule: it imports nothing from
crate::x11,crate::audio,crate::dictate, or any other display/pipeline module. Display adapters will depend on telemetry, never the reverse.This is sub-phase 1A of the telemetry system — pure skeleton with no consumers or producers wired yet. Later sub-phases will integrate event emission into the HTTP transcription path (1B/1C) and connect the X11 overlay as a consumer (1D).
-
[record] show waterfall spectrogram for recordings without transcripts. [Valentin Lab]
Recordings without a transcript preview now display an inline waterfall spectrogram instead of blank space. A single background worker thread computes the FFT data sequentially with yields between items to keep CPU usage low. Both WAV and OGG files are supported via a new
read_audio_as_i16helper. -
[dictate] add OGG format support with cache optimization. [Valentin Lab]
Add
--upload-format <wav|ogg>CLI flag to enable OGG Opus encoding during dictation. When OGG is selected, the encoder tees bytes to a cache file, allowing retries to use the smaller OGG file (164 KB vs 1.2 MB WAV).Also extend
--input-audio-fileto accept OGG Opus files, auto-detected by extension and decoded viaogg+opuscrates into PCM i16 chunks through theAudioCapturetrait.Changes:
src/transcription/mod.rs: addUploadFormatenum (shared,clap::ValueEnum)src/cli/def.rs: add--upload-formatargumentsrc/cli/action/mod.rs: plumbupload_formatthrough action dispatchsrc/dictate/mod.rs: compute OGG cache path, pass to streaming, use in retrysrc/dictate/streaming.rs: spawn encode pipeline that tees OGG to cachesrc/dictate/toggle.rs: forward--upload-formatto daemonsrc/audio/file_source.rs: addOggFileSourcefor OGG Opus decoding
-
[dictate] add
--no-pasteflag and pipeline timing instrumentation. [Valentin Lab]--no-pasteskips pasting the transcription into the focused application (useful for benchmarking or headless use).Add
t_stoptiming from recording stop through capture, WAV flush, transcription, and first paste, logged atinfolevel for end-to-end latency analysis. -
[picker] write companion recording metadata YAML on text changes. [Valentin Lab]
Save a metadata YAML alongside the WAV so the record UI can display transcript previews via its inotify watcher. Keyboard edits are debounced (1 s); programmatic changes (row selection, API result arrival) and window close are immediate. Also updates the copy-to-clipboard icon from U+2398 to U+29C9 for consistency with the record UI.
-
[record] add dictate button and update copy icon in recordings UI. [Valentin Lab]
Add a "Transcribe recording" button on WAV files that have no transcription yet — launches
dictate --pickwith the audio file. Change the copy-to-clipboard icon from U+2398 to U+29C9. Wire up.dictate-btnCSS class for consistent button sizing. -
[record] add copy-to-clipboard button for dictation transcripts. [Valentin Lab]
-
[transcription] add configurable base URL for Mistral and OpenAI providers. [Valentin Lab]
Allow overriding the API base URL for both Mistral and OpenAI via the
urlconfig field orTALK_RS_PROVIDERS_MISTRAL_URL/TALK_RS_PROVIDERS_OPENAI_URLenvironment variables. This lets users point at self-hosted or API-compatible endpoints.Batch transcription endpoints append
/v1/audio/transcriptionsto the base URL; realtime WebSocket endpoints convert the HTTP(S) scheme to WS(S). Trailing slashes are trimmed automatically. -
[record] add resizable window, close button, and edge resize to
record --ui[Valentin Lab]Factorize undecorated-window helpers (
build_title_bar,install_edge_resize, close-btn CSS) intogtk_themeso both the picker and the recordings browser share a single implementation. The picker is refactored to call the shared helpers; the recordings browser gains the same resize + close-button UX. -
[picker] add character-level diff highlighting, editable text area, and copy button. [Valentin Lab]
Integrate
dissimilarcrate for live character-level diff between the original transcription and each candidate label: red strikethrough for deletions, green background for insertions (both at 50% opacity). Addescape_pango()anddiff_markup()helpers for safe Pango markup generation. Include an editable text area pre-populated on row selection and a copy-to-clipboard button in the picker popup; edited text is used on confirm. -
[picker] add waterfall spectrogram with playback controls and drag-to-seek. [Valentin Lab]
Render a frequency-vs-time waterfall strip above the candidate list using
generate_waterfall_columns()computed on a background thread. A translucent cursor overlay tracks playback position with sub-buffer interpolation for smooth movement.Player gains
pause()/resume()/seek()/progress()methods. The play button switches to Adwaita media icons and a sharedplaying_flagreplaces fragile label-text checks. A rewind button andGestureDragon the waterfall provide drag-to-seek (auto-pauses during drag, resumes on release).README.orgupdated to mention the waterfall in the picker feature list.
Changes
-
[transcription] always normalize file uploads to 16 kHz mono
OGG[Valentin Lab]Batch
Fileuploads were sent to the providers verbatim. With the new high-qualityrecordoutput (48 kHz, up to 128 kbps), that meant uploading far more data than either provider can use: Mistral Voxtral andOpenAIWhisper /gpt-4o-transcribeboth downsample to 16 kHz mono internally and ignore everything above 8 kHz. Sending richer audio only wastes bandwidth, adds latency, and risksOpenAI's 25 MB cap — for zero accuracy gain.Add
normalize_file_for_upload(a single chokepoint shared by both the Mistral andOpenAIbatch transcribers): it decodes any supported file viaread_audio_as_i16(resampling to 16 kHz and downmixing to mono) and re-encodes toOGG/Opusbefore upload, advertising an.oggfile name. Decode failures fall back to the original bytes so an upload that worked before keeps working. The live streaming path is untouched — it already emits 16 kHz mono.Adds tests covering stereo-44.1kHz to mono downmix, mono passthrough, the missing-file error, and the undecodable-file raw-bytes fallback.
-
[transcription] route realtime WS through unified transport. [Valentin Lab]
Steps 6-9 of the transport-consolidation plan at
.sisyphus/plans/transport-consolidation.md.Step 6 (transport
ws_upgrade): the WS upgrade handshake now shares the same growing-budget connection-retry schedule (CONNECTION_BUDGETS_SECS = [2, 5, 8, 11, 15]) ashttp_request, wrapping eachtokio_tungstenite::connect_asyncattempt in atokio::time::timeoutso blackholes don't hang for the OS-default ~125s TCP SYN timeout. Cancellation is wired through the sametokio::selectpattern. Tungstenite errors classify into connect-retryable (Io,Tls,ConnectionClosed,AlreadyClosed) vs permanent (Http, protocol violations).Step 7 (realtime migration):
MistralRealtimeTranscriber::transcribe_realtimeandOpenAIRealtimeTranscriber::transcribe_realtimeswap theirwith_retry+connect_asyncblocks for a singlews_upgradecall each. Same for thevalidate_realtime_sessionhelpers. Provider-specific headers (OpenAI-Beta: realtime=v1) are passed verbatim to the transport.The picker's "click T on realtime, nothing happens" symptom diagnosed in plan section 1 dot 12 and Step 8 is structurally fixed by this commit: the upgrade now uses the growing-budget schedule (2+5+8+11+15 = 41s total instead of 5x15 = 75s) and emits
ConnectionEvent::RetryScheduledevents. Step 10 wires those events to the picker's UI sink to make the retries visible to the user.Step 9 (delete legacy primitives): delete
transcription/transport/retry.rsentirely (with_retry,MAX_RETRIES, and 5 unit tests). DeleteCONNECT_TIMEOUTandbuild_clientfromtransport/http.rs. DeleteWS_CONNECT_TIMEOUTfrom both realtime modules.Outside the transport module, the words "retry" and "attempt" do not appear anywhere related to network calls;
paste.rs::FOCUS_MAX_RETRIESis the only remainingMAX_RETRIESand it governs X11 window focus, an unrelated concern.Test status: 433 lib tests green (down from 438 -- the 5 retry tests are gone with the file). 6/10
transport_integrationtests green; the remaining 4 are jobs-registry placeholders explicitly deferred to Step 11.Net diff: -521 / +419 (transport surface keeps growing while realtime and
http.rsshrink). -
[transcription] migrate batch + validate + model-suggestions to
http_request[Valentin Lab]Steps 3-5 of the transport-consolidation plan at
.sisyphus/plans/transport-consolidation.md.After this commit, every batch HTTP call to a transcription provider funnels through
transport::http_request:transport::http::validate_model(validate preflight) — its in-line growing-budget loop is deleted; the new transport'sCONNECTION_BUDGETS_SECScovers the same[2, 5, 8, 11, 15]schedule.MistralBatchTranscriber::send_request—send_onceis gone, the multipart form is built by a factory closure that the transport invokes per retry (RequestBody::Multipartaccepts aBox<dyn Fn() -> reqwest::multipart::Form + Send + Sync>becausereqwest::multipart::Formis notClone).OpenAIBatchTranscriber::send_request— same treatment as Mistral.model_suggestions::fetch_transcription_models— its 5-attempt in-line retry loop is deleted; the transport handles retries. Stale-cache fallback preserved.
The notorious
attempts=1, max_attempts=1lie atmistral.rs:359andopenai.rs:316is removed: the providers no longer buildPipelineFailurethemselves, so thehttp_requesttruthful counter (already in place from Step 2) now surfaces correctly to every batch call site.transport/http.rs::build_clientandCONNECT_TIMEOUTare marked#[allow(dead_code)]with a TODO pointing at Step 9 (which deletes them entirely along with the legacywith_retrypath). They're retained transiently because the realtime modules (realtime.rs/openai_realtime.rs) still use the legacywith_retryupgrade path — Steps 6-7 migrate those tows_upgrade.RequestBody::Bytesnow wrapsArc<Vec<u8>>so the transport can.clone()the audio buffer cheaply across retries.Test changes:
validate_model_emits_one_retry_event_per_retrybecomesvalidate_model_does_not_retry_on_malformed_response_body. Behaviour change rationale documented in the test: decode is a content failure, not a transport failure; mid-body truncation was already covered by the connection-retry layer. The pin on the schedule[2, 5, 8, 11, 15]moves totransport_connection_phase_retries_with_growing_budgetintests/transport_integration.rs.
Test status: 438/438 lib tests green, 21/22 integration suites green;
transport_integrationkeeps the 5 still-RED tests for Step 6 (ws_upgrade) and Step 11 (jobs registry).Net diff: -284 lines (697 → 413) with strictly better correctness.
-
[transcription, error] consolidate HTTP pipeline failures into structured
TalkError::Pipeline[Valentin Lab]Replaces the older pattern of stuffing
reqwestfailures intoTalkError::Config(String)/TalkError::Transcription(String)with a single structured variantTalkError::Pipeline(Box<PipelineFailure>)that carries the provider, phase, attempts/max counts, URL, and a typedPipelineFailureKind(Network/HttpStatus/ModelRejected/Decode).Motivation: the prior string-format helper
format_reqwest_error_with_timersproduced messages like:Configuration error: OpenAI model validation failed (preflight to /v1/models): error sending request for url (https://...) [kind=timeout, name=connect_timeout, budget=2s, url=https://...] -> client error (Connect) -> operation timed outThree problems with that output:
Configuration error:prefix was misleading — a network timeout against the preflight endpoint is not a config issue.- The URL was printed twice (once in the
reqwest::Errorpreamble, once in the[url=...]tag). - The trailing chain layers
client error (Connect)andoperation timed outjust restated whatname=connect_timeoutalready conveyed; the verbosity drowned out useful layers like DNS lookup failures orECONNREFUSED (os error 111).
The new
PipelineFailure::Displayimpl produces a clean one-liner with structural source-chain dedup:Mistral model validation failed [name=connect_timeout, budget=2s, url=https://mistral.vps-03.0k.io/v1/models] (after 5/5 attempts)DNS / ECONNREFUSED / TLS layers are kept (they add information not in the structured fields);
client error (Connect),operation timed out, etc. are dropped.Architectural consolidation (per
architectural-consolidation.md):- Outcome A on
TalkError— extended the existing error vocabulary with one new variant rather than introducing a parallelPreflightError/RequestErrortype. Boxed to keepResult<T, TalkError>stack size sane. - Outcome B on the formatter — the responsibility "render a
reqwest failure as text" moved out of
format_reqwest_error_with_timersand intoPipelineFailure::Display. The transport layer keeps the reqwest-classification mechanics (classify_reqwest_error,build_pipeline_failure_kind,TimerSpec→TimerLabelconversion); the error layer knows how to display the structured value. No reqwest types leak intoerror.rs. - Outcome A on
TranscriptionEvent— no new events invented; the existingRetryScheduledis now emitted between attempts of the validate-cache-miss retry loop, withattempt = 1..=4/max = 4for the 5-attempt budget. Same vocabulary as the existingwith_retryfor the transcription request, so the picker UI'sretry N/M…rendering covers both phases without picker code changes. - Structural-first model-error detection — the
bail-on-permanent logic in
with_retrynow matchesPipelineFailureKind::ModelRejectedstructurally before falling back to the legacy string match. Pre-migration call sites that still produce string-stuffed errors continue to work; structural producers always win.
Scope: in scope for this change is the HTTP preflight (validate) and batch HTTP transcription request paths. Realtime/WebSocket transport (
MistralRealtimeTranscriber,OpenAIRealtimeTranscriber) stays on the legacyTalkError::Transcription(String)shape — it has different transport semantics (WS upgrade, session events) and is a separate consolidation when the user wants it.User-visible effect: picker rows now show
pre-validating model…followed byretry 1/4…,retry 2/4…,retry 3/4…,retry 4/4…during the validate-phase retries (this was the missing-progress complaint). When validation or transcription ultimately fails, the error message lacks theConfiguration error:prefix, prints the URL once, and dedups restatement layers so only novel information remains in the chain.Files touched:
src/error.rs: newPipelinevariant,PipelineFailurestruct,PipelinePhase/PipelineFailureKind/NetworkKind/TimerLabeltypes,Displayimpl with source-chain dedup heuristic. 7 unit tests for Display variants, dedup, andFrom<PipelineFailure>transparency.src/transcription/transport/http.rs: removedformat_reqwest_error_with_timersandattribute_timer; addedbuild_pipeline_failure_kindand innerclassify_reqwest_errorhelper. Reworkedvalidate_model_uncachedto take a sink, emitRetryScheduledbetween attempts, and return structuredPipelineerrors. Replaced 5 legacy string-format tests with 5 structural classifier tests; added 3 new tests covering retry-event emission and structuralis_model_error.src/transcription/mistral.rs,src/transcription/openai.rs:send_onceproducesPipelineFailure { phase: Request, .. }instead of string-stuffedTranscription(String). Updated theuser_attended_policy_omits_request_wall_clock_attributionregression test to assert on the structured shape.src/transcription/mod.rs:is_model_errorstructural fast path onPipelineFailureKind::ModelRejected; legacy string match preserved as fallback.
Verification:
cargo fmtclean,cargo clippy --all-targetsclean,cargo test428 passed / 0 failed,cargo buildandcargo build --releaseboth succeed. No newunwrap()/expect()outside#[cfg(test)]; no trailing whitespace; no dead-code pragmas. -
[audio, dictate] gate periodic boop on auto-pause
LISTENINGstate. [Valentin Lab]The periodic boop heartbeat used to play unconditionally for the entire recording, including while the user was actively speaking. The user's mental model is that boops belong to the yellow
LISTENINGbadge — i.e. the auto-pause state where audio forwarding is held back during silence. Make the implementation match that model.- Extend
SoundPlayer::start_boop_loopwith a positiveplay_whengate (in addition to the existing negativesuppressgate). Boops fire iffplay_when_ok && !suppressed. - Wire the overlay's existing
pause_flagatomic intoplay_whenat the call site indictate. No new shared state — the same flag the audio tee already consumes for sample gating. - Anchor the
intervalclock to the rising edge ofplay_when, not to loop creation. The first boop after enteringLISTENINGalways lands a fullintervallater — never sooner — and brief silences shorter thanintervalproduce zero boops. A falling edge mid-period cancels the in-flight wait so the next listening period starts a fresh clock. - Extract the loop body into a free
run_boop_loopasync function so the state machine is unit-testable without an audio device.
Side effects: with
--no-auto-pausethe boop is silent for the entire recording (the gate never opens), which is consistent with the user's mental model. The dead-signal alert path still suppresses boops in-place viasuppressand does NOT reset the phase, so the alert tone continues to stand alone.Tests cover: ungated periodic firing, gated-off silence, suppress-overrides-play-when, the phase-reset invariant on the rising edge, and the no-emit guarantee for sub-interval listening bursts.
- Extend
-
[picker] unify row order, add T/↻ action button, preserve text area. [Valentin Lab]
Three connected picker UX fixes that all live in
src/dictate/picker/ui.rs:-
Deterministic row order. Previously rows were appended in four buckets (cached, batch-pending, realtime-pending, deferred), so a given
(provider, model, streaming)triple's screen position depended on whether it had a cached transcription -- the order shifted as results arrived. Now all rows go through one unified sort on(provider_rank, model, streaming)where the config's default provider gets rank 0 and others rank 1 (alphabetical among them), and a single loop renders them. -
Unified
T/↻action button. Replaces the previous▶deferred-transcribe button (only on never-run rows) and↻retry button (only on errored rows) with a single action button present on every row in the leftmost column. Icon isTwhen the row has no transcription (first run / spinner / deferred / error / "no speech detected") and↻once a non-empty transcript exists -- so the user can always re-run a model, including successful ones. Button is sized 28x28 withpadding: 0to match the play/copy icon footprint. -
Preserve text area on non-transcribed rows. A new per-row
has_transcription: Rc<RefCell<Vec<bool>>>tracks whether the row currently holds a non-empty transcript. Inconnect_row_selectedthebuf_sel.set_text(text)call is now guarded by this flag, so selecting a spinner / deferred / error / empty-result row no longer wipes whatever the user is viewing or editing. The flag flips true on first non-emptyCandidate/StreamUpdatearrival, false on user retry-click, error, andInitialBatchDone"no response".
Verified locally with
./autogen.sh,cargo fmt,cargo clippy --all-targets,cargo test,cargo build,cargo build --release, plus a live UI smoke test confirming row order is content-independent, the action button is leftmost and square, and selecting a non-transcribed row leaves the text area intact. -
-
[transcription, dictate, record] pick-file waterfall + shared retry. [Valentin Lab]
Introduce a layered cache architecture for transcription:
- Layer 1 (
recording_cache):<stem>.pick.ymlis the authoritative transcript for a recording.get_transcriptreturnsAvailable(text)/InProgress/NotAvailable. Locks (<stem>.pick-lock.yml) coordinate producers. - Layer 2 (
produce_transcript): check pick, acquire lock, delegate to Layer 3, write pick, release lock. Used bytranscribecommand (default options) and bydictateMode C (file input, default options). - Layer 3 (
transcribe_audio): per-model sidecar cache (<stem>_<provider>_<model>_<mode>.yml) + per-model lock. Newallow_api: boolparameter lets the picker probe the cache without triggering API calls; returnsCacheOnlyon miss. - Layer 4 (
src/transcription/transport/): single retry primitivewith_retryused by both batch HTTP POST and realtime WebSocket upgrade. The 85-line retry loop insrc/dictate/mod.rsis removed.
Concept consolidation:
BatchTranscriber::transcribe_fileandtranscribe_streamcollapse into onefetch_transcription(body: TranscriptionBody)method. The trait is nowpub(crate)— outside callers go throughtranscribe_audioorproduce_transcript.RealtimeTranscriberis alsopub(crate). Its WebSocket upgrade handshake is now wrapped in the sharedwith_retry.src/transcription/http.rsmoves tosrc/transcription/transport/http.rs.
Consumer changes:
dictatewrites a pick on every successful transcription unless a specific provider/model/diarize option is given. Mode C (--retry-last/--input-audio-filewith default options) short-circuits: if a pick exists, paste it directly.transcribecommand's default branch now pollsget_transcriptonTranscriptInProgress.- Record UI reads the pick file only — no sidecar fallback.
Displays
(no text)for empty picks,(transcription ongoing)for in-progress, audio player when no pick exists. - Picker probes the sidecar cache via
transcribe_audio(allow_api=false)instead of enumerating sidecar files directly; only the default model auto-transcribes, other models show a transcribe button. - Old picker JSON cache (
~/.cache/talk-rs/picker-results/) is deleted —src/dictate/picker/cache.rsremoved.
Error variants added:
TranscriptInProgress,CacheOnly,ModelInProgress.Telemetry:
Arc<dyn TelemetrySink>threaded from CLI throughproduce_transcript/transcribe_audio/BatchTranscriber::fetch_transcription/with_retry/ProgressBody— retry events observable by the UI visualizer without any new propagation mechanism.Tests: 350 pass, up from 330. New unit tests cover the retry primitive (initial-success, transient-then-success, model-error bail, exhaustion),
get_transcriptstate machine, pick-lock acquire/release idempotence, per-model lock independence, and pick round-trip. - Layer 1 (
-
[transcription] unify all batch transcription behind one function and one cache. [Valentin Lab]
Every batch-from-file transcription now goes through a single entry point
transcription::transcribe_audiowhich checks a sidecar cache before calling the provider and stores the result after. Callers never see the cache.recording_cache::TranscriptionCacheprovides the abstract cache API (get/store). Storage is currently YAML files next to the source audio; the implementation detail is hidden. All sidecar types gainDeserializeso cached results can be read back into aTranscriptionResult.src/transcribe.rs(CLItalk-rs transcribe) collapses to config resolution + onetranscribe_audiocall + output. The manual transcriber creation, sidecar writing, and model-name resolution are gone.src/dictate/picker/backend.rsnow takesArc<Config>instead of pre-builtBox<dyn BatchTranscriber>and callstranscribe_audio. The picker no longer creates transcribers itself (picker/mod.rs,picker/ui.rs); re-opening the picker on the same audio file hits the cache and returns instantly.src/dictate/mod.rsretry loop usestranscribe_audio(cache may short-circuit repeated retries on the same OGG). The streaming and realtime paths — which cannot usetranscribe_audiobecause the audio file does not exist at transcription start — now callTranscriptionCache::storeafter completion, followed by a separatewrite_last_pointersfor dictate-specific symlinks.write_recording_metadata(picker/mod.rs) is deleted. The picker no longer overwrites provider sidecars on text-edit or selection — that is a future<stem>.ymluser-selection sidecar concern.timestamp_granularitiesis now sent unconditionally on all Mistral API requests (not just--diarize), restoring per-segment timing fromvoxtral-mini-2602+which requires the explicit parameter. -
[telemetry, transcription, overlay, paste] add three independent-scale throughput tracks with download byte streaming and paste character tracking. [Valentin Lab]
Transcription backends (
mistral,openai) now stream the HTTP response body viabytes_stream()instead of buffering the whole JSON payload, emitting per-chunkDownloadProgressevents as bytes arrive.paste_text_to_targetaccepts aTelemetrySinkand emitsPasteProgressafter each chunk so the overlay can visualise paste throughput alongside upload and download.The overlay replaces the single upload throughput bar with three independent tracks (upload, download, paste), each owning its own 16 px budget, peak tracking, and history ring buffer. Independent scales ensure a ~1 KB JSON download is visually comparable to a ~100 KB audio upload — shared-budget scaling made the smaller track invisible at a 500:1 byte ratio.
-
[dictate] keep overlay visible during paste and emit paste telemetry events. [Valentin Lab]
Move
o.hide()afterpaste_text_to_target()so the overlay (dimmed waterfall, phase colours, throughput bars) stays on screen throughout the paste phase.Emit
TranscriptionEvent::PasteStartedbefore paste andTranscriptionEvent::PasteCompletedafter it, so the overlay phase state machine shows a green band during paste (PasteStarted→ Done,PasteCompleted→ Idle). -
[overlay, transcription] add upload throughput bars and fix dead-signal gate during transcription. [Valentin Lab]
Layer 3b throughput bars: each waterfall column now draws a phase-coloured vertical bar growing downward from the phase line, proportional to the upload bytes transferred in that time slice (peak-normalised, max 48 px). New state tracks
current_upload_bytes,prev_upload_bytes,upload_peak_delta, and a parallelthroughput_historyvec. Bars render in all three display branches (transcribing, auto-paused, normal).Dead-signal gate fix: column-push was gated on
!no_sound_active, which blocked all pushes during transcription becausecapture.stop()makes the ring buffer go stale and the dead-signal detector fires. Changed to(!no_sound_active || is_transcribing)so columns keep advancing. Also reordered render branches sois_transcribingtakes priority overno_sound_active, preventing the "NO SOUND" icon from hiding the transcribing waterfall.ProgressBodywrapping for retries:transcribe_filein bothmistral.rsandopenai.rsnow reads the audio into aVec<u8>and wraps it withProgressBody+Body::wrap_stream, so retry attempts emit upload telemetry and throughput bars are visible during file-based retries. -
[overlay, dictate] replace static transcribing badge with dynamic phase-colour waterfall. [Valentin Lab]
The static "transcribing" PNG badge is replaced by a live render that keeps the 60 fps waterfall loop running through the transcription phase:
- Waterfall continues scrolling with empty columns at 30 % brightness, making elapsed transcription time visible at a glance.
- A 2-pixel phase-colour line at the top of the spectrogram area shows the HTTP lifecycle in real time: connecting (dim blue), uploading (bright blue), waiting for server response (amber), receiving (teal), done (green), error (red).
- "TRANSCRIBING" text is rendered in light blue, following the same pattern as the auto-pause "LISTENING" overlay.
- The telemetry
broadcast::Receiveris wired from theBroadcastSinkbroker (created indictate) into the overlay thread; events are drained non-blockingly each frame viatry_recv(). - Broker creation in
src/dictate/mod.rsis moved before the overlay so the receiver is available at construction time.
The static PNG path is kept as a fallback for the edge case where
Transcribingis received outside an active recording session. Layer 3b (byte-throughput bars) is deferred to a later commit. -
[transcription, dictate] wire telemetry events into batch transcription paths. [Valentin Lab]
Add
set_sinkmethod to theBatchTranscribertrait with a default no-op implementation so concrete backends can accept aTelemetrySink.In
MistralBatchTranscriber::transcribe_stream, wrap the audio buffer withProgressBodysoConnectionEstablished/UploadProgress/UploadCompleteevents flow as bytes are sent. Bothtranscribe_streamandtranscribe_filenow emitRequestStarted/ResponseHeaders/RequestCompletedboundary events on every exit path.Apply the same boundary-event pattern to
OpenAIBatchTranscriber::transcribe_file.In
dictate::mod, create anArc<BroadcastSink>before the transcriber and inject it viaset_sink. The retry loop emitsRetryScheduledand re-injects the sink into each freshly-created retry transcriber. No consumer subscribes yet — display wiring follows in sub-phase 1D.Remove the three
#[allow(dead_code)]annotations fromhttp.rsnow thatProgressBodyis used bymistral.rs. -
[overlay] keep waterfall scrolling during auto-pause and dim history. [Valentin Lab]
Three visual improvements to the X11 recording/listening badge:
-
Waterfall continues scrolling during auto-pause by pushing empty columns (
vec![0.0; SPEC_H]) instead of freezing. The x-axis now represents wall-clock time: silence appears as a growing empty hole beside the dimmed audio history, which is useful for diagnosing pause behavior. -
During auto-pause the spectrogram/amplitude/spectrum renderers accept a
dimparameter. Normal mode passes 1.0; auto-pause passesDIM_FACTOR_PAUSED(0.3) before overlaying the LISTENING text and pause bars at full opacity. -
Column advance rate decoupled from render frame rate via
COLUMN_PERIOD_FRAMES = 2: the waterfall advances at 30 cols/sec whileFPSstays at 60 so the red-dot pulse remains smooth. The visible time window roughly doubles from ~4.4 s to ~8.8 s. Peak tracking still runs every frame for stable normalization.
New unit tests cover the dim path and the zero-column ("hole") rendering.
-
-
[record] match
memoaudio filename scheme. [Valentin Lab]Audio filenames produced by
talk-rsnow follow the exact scheme used by thememotool:YYYY-MM-DDTHH-MM-SS+ZZZZ.ogg-- an ISO 8601 local timestamp with a numeric timezone offset, colons in the time portion replaced by dashes to stay filesystem-safe. Recordings from both tools can now coexist in the same directory and sort chronologically.src/record/mod.rs--default_filename()drops the legacymemo-prefix and switches the chrono format to%Y-%m-%dT%H-%M-%S%z.ogg. The user-facingrecordcommand therefore writes<output_dir>/YYYY/MM/2026-04-11T13-15-52+0200.oggfor auto-named recordings.src/cli/def.rshelp text is updated to reflect the new default path.src/recording_cache.rs--generate_recording_path()now includes%zin the timestamp used for the dictate cache at~/.cache/talk-rs/recordings/. The paired metadata YAML sidecar inherits the new stem automatically, so dictate now writes e.g.2026-04-11T13-15-52+0200_mistral_voxtral-mini-2507_batch.yml.write_last_paste_state()picks up the same format for thetimestampfield inlast_paste.ymlfor internal consistency.The existing unit test
test_resolve_output_path_no_args_...and the integration testtest_record_default_filename_formatare rewritten to parse the generated filename throughchrono::DateTime::parse_from_strand assert a round-trip instead of string-matching a prefix, because the real specification is "the stem must parse back as a local datetime with timezone offset".Backward compatibility: existing cached recordings and legacy
memo-*record files still list, play, delete, and rotate correctly because the entries reader and cache rotation sort by basename rather than by format. Old files gradually age out of the dictate cache viarotate_cache(). -
[record] namespace auto-generated recordings by
YYYY/MM[Valentin Lab]Auto-generated recording filenames are now placed under
YYYY/MMsubdirectories of the configuredoutput_dir, mirroring the layout used by thememotool so long-running users do not end up with thousands of files in a single directory.src/record/entries.rsnow walks nestedYYYY/MMdirectories when listing recordings for therecord --uibrowser. Mixed flat plus nested layouts are supported by sorting entries on basename so existing and newly created recordings interleave chronologically.src/record/ui.rsnow installs itsinotifywatch recursively so newly createdYYYY/MMsubdirectories are tracked as soon as they appear. The GTK browser refreshes rows under the correct subtree when files land in freshly created month directories, fixing a regression where the UI stopped auto-updating after the first recording of a new month. -
Harden HTTP timeouts and add
--log-filesupport. [Valentin Lab]Replace per-request wall-clock timeouts with TCP-level dead-connection detection so that legitimate long uploads and slow server processing are no longer killed prematurely:
- Upgrade
reqwest0.11 → 0.13 (hyper-rustlsTLS backend). - Configure
tcp_user_timeout(Linux),tcp_keepalive, keepalive interval/retries in the sharedbuild_client(). - Remove
BATCH_FILE_TIMEOUT,RETRY_TIMEOUT, and alltokio::time::timeout/tokio::select!wrappers around transcription calls — stalls are now caught at the TCP layer within a few seconds. - Centralise
model_suggestionsto use the shared HTTP client. - Switch error formatting to
{:#}for reqwest error chains.
Add persistent file logging behind
--log-file/$TALK_RS_LOG_FILE:log::setup()accepts an optional file path; a secondferndispatch appends plain-text Info+ logs with timestamps.- Auto-truncate at 2 MiB to prevent unbounded growth.
- Propagate the flag via environment so child processes inherit it.
- Add
chronotimestamps to stderr output as well. - Enable clap
envfeature for$TALK_RS_LOG_FILEsupport.
- Upgrade
-
[picker] defer non-default model transcription with on-demand button. [Valentin Lab]
Only the default transcription model is transcribed immediately when opening a recording in picker mode. Other configured models are deferred and presented with a transcribe button (▶) for on-demand transcription, reducing unnecessary API calls.
-
[record] show transcribe button on all recordings. [Valentin Lab]
Allow users to re-transcribe recordings that already have a transcript. Previously the button was only shown for recordings without transcripts.
-
[dictate] migrate dictation cache from WAV to OGG/Opus format. [Valentin Lab]
Unified cache format across realtime and batch dictation paths. Both now write
.oggcache files usingOggOpusWriter. Removed redundant batch-mode OGG tee. Updated cache management (rotation, last-recording pointer, listing, retry) to use.oggpaths. Recordings browser UI now shows OGG cache entries. Delete operation is backward-compatible for legacy.wavfiles. Removedprune_ogg_cache()sincerotate_cache()handles all cache maintenance.Files changed:
src/dictate/realtime.rs—ogg_recording_taskreplaceswav_recording_tasksrc/dictate/streaming.rs— usesogg_recording_task, removed OGG teesrc/dictate/mod.rs— unified cache path, removed redundant prune callsrc/recording_cache.rs—.oggpaths, pointers, rotationsrc/record/entries.rs—list_cache_recordings()scanning.oggsrc/record/ui.rs— updated imports/referencessrc/record/audio.rs— minor updateREADME.org,sample-metadata*.yml,src/cli/def.rs— doc updates
-
[record] extract
audio_player_barwidget and improve recordings UI. [Valentin Lab]Move waterfall spectrogram, playback cursor, and play/pause/rewind controls into a shared
audio_player_barwidget undersrc/widgets/. Replace the inline waterfall worker pool inrecord --uiwith the new widget. Restore a simple play button for entries with transcripts, read OGG transcript previews from companion YAML metadata, and add incremental FileMonitor row updates to avoid full section rebuilds. -
[dictate] always cache OGG format and prune old files. [Valentin Lab]
OGG cache files are now saved alongside WAV during dictation, regardless of
--upload-formatsetting. Old OGG files are automatically pruned to keep only the 10 most recent, preventing unbounded cache growth. -
[record] defer recording list loading to after window paint. [Valentin Lab]
Show the recording browser window immediately with a "Loading recordings…" indicator; populate data and set up file watchers in an idle callback triggered on the first
mapsignal so the user never stares at a blank wait.Also add
log::debug!timing traces tolist_ogg_recordings,list_wav_recordings, and the window lifecycle. -
[overlay] make badge background opaque black. [Valentin Lab]
- Change
BG_COLORalpha from 0x00 to 0xFF for solid black background - Force spectrogram pixels to full opacity (
color[3] = 0xFF) so they don't create transparent holes over the black background - Apply rounded shape mask in ARGB visual path so corners stay rounded with opaque background
- Update two tests to assert against
BG_COLORinstead of alpha=0
- Change
-
[record] optimize OGG duration to O(1) seek-from-end. [Valentin Lab]
Replace sequential packet iteration with a tail-read approach: seek to the last ~64 KB, scan backward for the final
OggSpage header, and read the granule position directly. Constant time regardless of file size.Add tests for valid, too-small, and non-OGG files.
Fix
-
[audio] consume PCM buffer with a cursor in
write_pcmto avoid quadratic drain. [Valentin Lab]OggOpusWriter::write_pcmdrainedsamples_per_framefrom the front ofpcm_bufferinside the encode loop.Vec::drainfrom the front memmoves the whole remaining tail down on every iteration, making the function quadratic in the number of buffered samples.This was harmless for the streaming call sites (
src/dictateandsrc/record), which feed small chunks as they arrive so the buffer never holds more than about one frame. It was severe forencode_16k_mono_ogginsrc/transcription/mod.rs, reached vianormalize_file_for_upload: that path decodes a whole file to PCM and passes it in a single call, so the buffer starts at full file length and is drained 320 samples at a time. A long recording could spend hours spinning on memmove before contacting the provider at all.Consume the buffer with an index cursor and drain once at the end of the call. Behaviour-preserving by construction: the leftover tail after the single
drain(..pos)is exactly the samples after the last full frame, which is whatfinalize()pads and flushes.Measured, release build, one call with 20 minutes of 16 kHz mono audio: 455.6 s before, 15.6 s after. The residual is the Opus encode itself, which is linear and unchanged.
Tests keep a reference copy of the old draining implementation and assert the new one is byte-identical to it, for both the bulk and the irregularly-chunked call shapes. The regression guard asserts on a test-only counter of samples relocated by front-drains rather than on wall-clock time: at test-affordable sizes the linear Opus encode dominates and the measured ratio between the two was only 1.2x, too close to noise to discriminate, whereas the counter separates them by three orders of magnitude deterministically.
-
[paste, clipboard] gate chunk advance on target client re-fetch with retry. [Valentin Lab]
Chunked clipboard paste split long text and advanced to the next chunk as soon as the
served_countsignal went positive. That counter is incremented by ANY X11 client fetching theCLIPBOARDselection — including clipboard managers — so the selection was routinely overwritten before the target application had actually pasted a chunk, dropping that chunk and (via a lingering serve thread) duplicating the last one.Gate each chunk on the TARGET window's X11 client re-fetching it, identified by client-base (
requestor & !resource_id_mask) rather than the ephemeral requestor window id:- The serve thread now records
UTF8_STRINGfetches per client-base (our own read-back client is excluded), and theclipboardnode waits for the target client specifically. Chunk 1 LEARNS the target's per-paste fetch count through a quiescence window; later chunks CONFIRM the same count before the selection is overwritten. - Transient focus failures (the paste keystroke sent before keyboard
focus was effective) are recovered by re-focusing the target window
and re-sending the keystroke up to
target_fetch_retriestimes (default 2) before giving up. - When the target never fetches within
chunk_fetch_timeout_ms(default raised 300 -> 500, now a per-attempt deadline) after all retries, the paste ABORTS rather than silently corrupting the document, surfacing a red overlay and an alert sound; the user's original clipboard is still restored. settle_before_restoreis removed — the target-confirmation makes the restore race impossible by construction.
Blind pastes with no known target window (the realtime per-segment path) fall back to the previous
served_countgate unchanged.New knobs on the
clipboardnode:target_fetch_retriesandtarget_quiescence_ms;restore_settle_msis now a no-op kept for backward compatibility. - The serve thread now records
-
[paste, clipboard] wait for paste target to fetch before overwriting clipboard. [Valentin Lab]
Long pastes split text into chunks and, after pasting, restore the user's original clipboard. Both the inter-chunk overwrite and the final restore happened after a FIXED sleep rather than waiting for the target window to actually fetch the offered content. A slow target could then pull the WRONG clipboard generation — typically grabbing the restored value instead of the last chunk — leaking the old clipboard into the document and dropping the final chunk.
Block on the existing
served_countsignal instead of guessing with a sleep:X11Clipboard::wait_until_servedpolls until the offered content is fetched (or a timeout), sopaste_oneno longer overwrites a chunk the target has not pulled yet.settle_before_restorewaits for the last chunk's fetch activity to stay stable forrestore_settle_msbefore restoring, closing the restore race. Same treatment for the realtime per-segment path.- On timeout a
WARNis emitted so the corruption-prone case is no longer silent.
Requestor-identity matching is intentionally avoided: the target window id never appears as the
SelectionRequestrequestor, so the served-count signal is the reliable discriminator.The two timings are config-tunable via
paste.restore_settle_ms(default 200) andpaste.chunk_fetch_timeout_ms(default 400). -
[log] let
--log-filehonor-vvvso trace reaches the file. [Valentin Lab]Previously the
--log-filesink was pinned to Info, so the paste-diagnostic traces (emitted at Trace) never reached the file even with-vvv— they only went to stderr. In daemon mode that made them effectively unreachable from the user-chosen log file.Move the per-sink level filters onto the child dispatches and leave the root dispatch at its pass-all default. In
ferna parentDispatch::level()is a hard gate children cannot exceed in verbosity, so the old structure (root pinned to the stderr level, file child nominally at Info) silently dropped everything below the root level before the file child ever saw it — the "file always captures at least Info" promise was in fact never kept at the default Warn verbosity.The file now captures
max(base_level, Info): at least Info so it stays useful at the default verbosity, and as deep as Trace under-vvvso the paste diagnostics land in the file. Extractbase_level_forandfile_level_foras pure helpers with unit tests covering the floor and the-vvvcase. -
[config] reject relative
output_dirwith a clear error. [Valentin Lab]The generated
config.example.yamland the README state thatoutput_dir"Must be an absolute path", butvalidate_configonly checked that it was non-empty. A relative value was silently accepted and then resolved against the process working directory at runtime, which is unpredictable — the toggle daemon spawns with an inherited, effectively arbitrary CWD, so recordings could land anywhere.Enforce the documented contract:
validate_confignow returns aoutput_dir must be an absolute patherror for any non-absolute value (whether from the file or theTALK_RS_OUTPUT_DIRenv override). Document the requirement on the struct field, and add regression tests covering both the YAML and env paths.Fixes #7
-
[transport] extend connection retry budget for large uploads. [Boris Gallet]
The connect_budget timeout wraps the entire HTTP send request (TCP + TLS + body upload) via tokio::time::timeout, not just the connection establishment. For large audio files (>10 MB), the upload to the Mistral API takes ~36s at ~530 KB/s, exceeding even the 15s maximum budget on the 5th attempt.
Add two generous retry slots (30s, 120s) at the end of the growing-budget schedule [2, 5, 8, 11, 15, 30, 120] so realistic upload latencies (500-2000 KB/s) are accommodated.
Tested: 19.5 MB / 1h audio file transcribed successfully (~71s total, succeeds on the 30s or 120s attempt depending on network conditions).
-
[recording_cache, dictate] persist diarization segments in YAML sidecar. [Boris Gallet]
TranscriptionCache::store()was silently discarding diarization segments when writing the YAML metadata sidecar. TheRecordingMetadatastruct had nodiarizationfield, anddictate/mod.rsexplicitly setdiarization: Nonewhen building the cache entry.This meant that
talk-rs dictate --diarize --timestamp --no-pastewould print the correct timestamped speaker output to stdout, but the YAML sidecar next to the OGG file would lose all speaker attribution — making it impossible to reconstruct who spoke when from the cache alone.Changes:
- Add
CommonDiarizationSegmentwithspeaker, start, end, text - Add
diarizationfield toRecordingMetadata - Add
common_diarization_from_result()converter - Wire
diarizationthroughwrite_metadata_to_dir,write_metadata,TranscriptionCache::store, andinto_transcription_result - Fix
dictate/mod.rsto preserveresult.diarizationinstead of overwriting withNone - Update
transcribe.rstest to pass diarization - Add
diarization: Noneto all existing testRecordingMetadatainitializers
TDD:
test_transcription_cache_round_trip_with_diarizationverifies that SPEAKER_00 / SPEAKER_01 survive store → YAML → read. - Add
-
[dictate, toggle] forward all missing flags to daemon. [Boris Gallet]
`toggle_spawn() was silently dropping several dictate flags when spawning the daemon process:
- --timestamp (new in PR)
- --no-paste
- --pick
- --retry-last
- --replace-last-paste
- --input-audio-file
- --output-yaml
This caused
talk-rs dictate --toggle --no-pasteto paste anyway, andtalk-rs dictate --toggle --timestampto produce untimestamped output.Refactor: extract
build_daemon_args()as a pure function, pass&DictateOptsinstead of individual parameters, add 10 unit tests covering every forwarded flag. -
[overlay] flag NO SOUND only on stuck-at-rail, not on silence. [Valentin Lab]
Replace the variance-based dead-signal heuristic with a stuck-at-rail detector. A disconnected device pins every sample at the
i16rail (constant ~-1.0); only that flat-and-near-rail signature now triggers the NO SOUND warning.The old variance ceiling mis-flagged the exact-zero digital silence that Bluetooth HFP mics emit between speech (variance == 0) as a dead device, producing spurious NO SOUND warnings whenever the user paused talking.
Add
is_stuck_at_rail()helper with 7 unit tests covering a disconnected device (both rails), zero silence, a quiet noise floor, loud speech, a constant mid-level DC offset, and empty input. -
[openai] migrate realtime to GA API. [Valentin Lab]
short body.
-
[picker, error, telemetry] retry labels, row off-by-one, 4xx-permanent annotation. [Valentin Lab]
Three user-reported bugs from the post-Step-12 review.
-
Picker T button triggers wrong row when a primary cached entry shares the same (provider, model) with a deferred candidate.
The button click handler searched
local_candidatesfor the first row matching(provider, model, streaming). Because the primary entry (the cached pre-selected row) is inserted first and shares those fields with a deferred candidate for the same model,Vec::positionreturned the primary row's index — the spinner appeared on the row ABOVE the one the user clicked.Fix: include the
is_primaryflag in the button's captured identity tuple and match on all four fields in the click handler. -
Retry counter ambiguity (
retry N/4vsN/5, unlabeled which phase).TranscriptionEvent::RetrySchedulednow carries akind: RetryKindfield (ConnectionorData). The picker rendersconnect retry N/M…for connection-phase retries andserver retry N/M…for data-phase retries. -
HTTP 4xx errors show
(after 1/5 attempts)with no indication that retrying was deliberately skipped.PipelineFailureKind::HttpStatusrendering now annotates 4xx as— 4xx permanent, no retryand 5xx with exhausted budget as— server-retry budget exhausted, so users understand the counter is correct rather than premature.
The known-flaky SIGUSR1 lib test
cancel_remote_via_sigusr1_triggers_owner_tokenis marked#[ignore]to match its integration-test cousin (SIGUSR1 is process-wide; the test passes in isolation but races against other registered jobs in the parallel test runner).Tests: 440 lib green (1 ignored), 9 transport_integration green (1 ignored), 11 other integration suites green. Clippy clean. Release build green.
-
-
[error] dedup reqwest URL restatement from rendered failure chain. [Valentin Lab]
-
[transcription] guard validate-cache writes with POSIX flock. [Valentin Lab]
The validate-cache write path was a read-modify-write race: a process recorded its first entry, serialised only its own in-process map, and atomically renamed onto the shared file — clobbering any sibling-process entries it had never read. Observed in production as the real cache being repeatedly truncated to a single entry whenever a fresh
talk-rsprocess (picker spawn,dictatedaemon,record --ui) recorded a validation.persist_to_disk()now:- Acquires an exclusive
fs2::FileExt::lock_exclusiveon a siblingvalidate-cache.yaml.lockfile. - Re-reads the on-disk YAML unconditionally and merges entries
into the in-process map (newer
validated_atwins). - Writes the merged set via the existing tempfile + atomic rename.
- Releases the lock via an
RAIILockGuard.
Readers stay lock-free; the atomic rename gives them either the previous-complete or new-complete file.
Also adds the
TALK_RS_VALIDATE_CACHE_PATHenv override so tests can redirect the cache at a tempfile, plus a process-wide__TEST_LOCKand__test_reset()exposed to sibling test modules so cache-touching tests inhttp::testsdon't race with the ones here on the sharedOnceLockstatics.Coverage:
record_merges_with_sibling_disk_entriesreproduces the production failure (sibling entry on disk, fresh process records a different key, both must survive).- The four
validate_model_*tests inhttp::testsnow hold aCacheTestGuardso they cannot pollute the dev's real cache.
Refs: production cache truncation observed after recent picker /
dictateparallelisation work. - Acquires an exclusive
-
[audio, dictate] restore BT headset profile immediately on stop. [Valentin Lab]
Previously the
HeadsetGuardwas dropped at the end ofdictate(), which meant the Bluetooth headset stayed in HFP (lower-quality voice profile) for the entire transcription + paste pipeline — sometimes tens of seconds — before flipping back to A2DP. The user-visible effect was that music / system audio came back in degraded HFP quality for a noticeable window after they pressed the toggle to stop recording.The fix: drop the guard the moment the microphone capture stops, in parallel with the rest of the dictation pipeline.
A new
HeadsetGuard::restore_now_async()method takes the saved profile out of the guard and dispatches the restore ontokio::task::spawn_blocking. This off-loads the libpulse mainloop driver (which can block on BlueZ profile renegotiation for ~1 s) from the async runtime, so transcription and paste continue in parallel without waiting.The guard is now moved by value into
dictate_streaminganddictate_realtimeandrestore_now_async()is called the momentcapture.stop()returns — right next to the stop sound and the "Transcribing" overlay swap. After the restore is dispatched the guard is empty so its eventualDropat function exit is a no-op.The guard's RAII
Dropremains the safety net for code paths between activation and dispatch (panic, early?-return, etc.): those will still trigger a synchronous restore.The standalone
recordcommand was already correct — itscapture.stop()is on the last line ofrecord()so the guard drop already happened immediately. Untouched here.Two new unit tests in
bt_profile.rscover the new method:restore_now_asyncon a guard with no saved profile is a cheap no-op (does not spawn any blocking task).restore_now_asynctakes the saved profile out of the guard so the eventualDropdoes not double-restore.
Verified end-to-end:
cargo fmt,cargo clippy --all-targets,cargo test(370 tests pass),cargo build,cargo build --releaseclean.- Live
cargo test --test bt_profile_smokeagainst the running PulseAudio /pipewire-pulseserver still detects the headset correctly.
-
[overlay, dictate] pause recording pipeline on dead signal and skip transcription. [Valentin Lab]
Dead-signal detection (
no_sound_active) explicitly excluded auto-pause, so a dead microphone kept recording useless constant-value frames into the OGG cache. After the user stopped, those silent seconds were sent to the transcription API — wasting time, tokens, and showing a misleading "transcribing" badge.Now the overlay sets
pause_flag = truewhen dead signal triggers, stopping the audio tee from forwarding frames to the encoder. A newhad_live_audioflag onOverlayHandletracks whether any frame with real variance was ever seen during the recording session. After recording stops,dictate_streamingchecks bothbuffer.is_empty()andhad_live_audio()— if no usable audio exists, the transcription pipeline is aborted immediately with an empty result. -
[overlay] enlarge red dot and fix its centering. [Valentin Lab]
Scale
DOT_RADIUS_MAXfrom 10 to 21 andDOT_RADIUS_MINfrom 3 to 6 so the volume indicator fills the spec area and is readable at a glance. ShiftDOT_CXfrom 20 to 26 so the larger dot plus its gap clears the rounded corners.Side-effect: the pause icon (derived from
DOT_RADIUS_MAX) also grows, and its vertical centering improves becausebar_his now odd (29 px), eliminating the 0.5 px asymmetry of the old even value. -
[transcription] add per-request wall-clock timeout to HTTP calls. [Valentin Lab]
Mistral transcription was observed hanging for 168s when the server accepted the TCP connection but stalled at the application layer. The existing
build_client()defences (tcp_user_timeout,tcp_keepalive,connect_timeout) only cover TCP-level failures and cannot detect a slow-but-alive server.Add
proportional_timeout(audio_bytes)inhttp.rs:max(3s, kb / 10)— scales with payload size so large files are not killed prematurely.Apply
.timeout()at each call site:mistral::transcribe_file— uses file metadata lengthmistral::transcribe_stream— uses drained audio buffer lengthopenai::transcribe_file— adds a metadata read for file length
openai::transcribe_streamis deliberately left unchanged: it usesBody::wrap_streamwhere total size is unknown at request-build time; a wall-clock cap would kill long recordings.CONNECT_TIMEOUT(2s) is unchanged — log analysis across 81 sessions showed 100% cumulative success within the existing 5 retries. -
[record] copy full transcript to clipboard instead of truncated preview. [Valentin Lab]
The copy-to-clipboard button in the recordings browser was cloning the
transcript_previewfield (200 chars with trailing ellipsis) instead of the full transcript. This caused users to lose data when copying recordings to paste elsewhere.The fix adds a
transcript_fullfield toRecordingEntryholding the complete single-line transcript (newlines collapsed to spaces, never truncated). The copy button now clonestranscript_fullwhile the display label continues usingtranscript_previewfor brevity.Also introduces a
TRANSCRIPT_PREVIEW_CHARSconstant and atranscript_variants()helper to extract both values from a single YAML read, eliminating duplication betweenlist_ogg_recordingsandlist_cache_recordings.Includes 8 unit tests covering edge cases: empty input, short text, newline collapse, exact-200-char boundary, 201-char truncation, very long text, multibyte CJK characters, and long text with embedded newlines.
-
[record] remove waterfall spectrogram cache when deleting recordings. [Valentin Lab]
The
delete_recording()function now also removes the.wfwaterfall spectrogram cache file when deleting a recording. Previously only.ymlcompanion files were cleaned up, leaving orphaned.wffiles behind. -
[record] make file monitor events incremental instead of rebuilding. [Valentin Lab]
Previously, any audio file event (Created/Deleted/ChangesDoneHint) triggered a full
populate_section()rebuild, which cleared all rows, re-read all entries from disk, and always selected row 0. This caused scroll position reset and visible flashing.Now each event type is handled incrementally:
- Deleted: finds matching row by widget_name, selects adjacent row, removes just that row, updates expander count
- Created/ChangesDoneHint: builds one new row or refreshes existing one in-place at correct sorted position
- YAML events: extracted into shared
update_row_for_ymlhelper - Unhandled events: logged at debug level for future debugging
populate_section()no longer called from inotify path (only at init)
Fixes scroll reset and flashing on file deletion.
-
[transcription] buffer audio before upload to provide explicit Content-Length. [Valentin Lab]
Mistral's API rejects chunked Transfer-Encoding with 411 Length Required. Collect all audio bytes from the mpsc channel into a buffer first, then send with known length via
Part::stream_with_length(). This eliminates the 411 failure + retry overhead, reducing transcription time from ~4.4s to ~2.8s (-36%).Also removed now-unused imports:
futures::StreamExtandtokio_stream::wrappers::ReceiverStream. -
[overlay] freeze visualization data during auto-pause and no-sound. [Valentin Lab]
Prevent silence gaps from appearing in the waterfall spectrogram when auto-pause is active or no sound is detected. Wrap all per-viz-mode data updates (
spectrogram_history,amp_history,spectrum_peak) in a condition that skips accumulation when!auto_paused && !no_sound_activeis false. -
[record] extend
FileMonitorlifetime pastmain_loop.run()[Valentin Lab]Move
_keep_monitorsbinding out of the inner block so the inotify file watches remain alive for the entire duration of the GTK main loop. Previously they were dropped beforemain_loop.run()was called, silently losing notifications. -
[gtk-theme] recompute edge in click handler to fix intermittent resize. [Valentin Lab]
The
install_edge_resizeclick controller was reading a cached edge from the motion controller's sharedRefCellrather than computing it from its own(x, y)coordinates. Hand jitter between the last motion event and the press event caused the cached edge to be stale, producing two failure modes:- Window moves instead of resizing (cached edge was
None, gesture denied,WindowHandledrag took over). - Nothing happens (cached edge was set, gesture claimed, but
begin_resizesilently failed).
Extract
detect_edge()andedge_cursor()helpers, calldetect_edgefrom the pressed handler with a slightly larger threshold (10 px vs 6 px cursor zone) to forgive press-time jitter. - Window moves instead of resizing (cached edge was
-
[transcription] provide explicit
Content-Lengthin Mistral batch uploads. [Valentin Lab]The Mistral API now rejects multipart requests without an explicit Content-Length header (HTTP 411).
transcribe_file()was usingPart::stream()which sends chunked TE without Content-Length. Switched toPart::stream_with_length()with the file size from metadata. -
[picker] defer
WavPlayerinitialization to avoid blocking UI on cpal device probing. [Valentin Lab]Move
WavPlayer::new()from synchronous construction (which blocks ~1-2 s on PipeWire while probing audio devices) to aglib::idle_add_local_oncecallback fired after the window is presented. The picker and recordings windows now appear instantly; the play button is enabled once the player is ready. -
[picker] cap message loop and reuse labels to prevent cursor stutter. [Valentin Lab]
The transcription poll timer drained all queued
StreamUpdatemessages in an unboundedloop, rebuilding the label widget (destroy + create) on every streaming delta. When a burst of results arrived the GTK main loop was blocked, starving the 16 ms cursor timer and causing visible jumps.Two fixes:
- Limit the loop to
MAX_MSGS_PER_TICK(5) iterations per 50 ms tick so other main-loop sources stay responsive. - Reuse the existing
gtk4::Labelviaset_text()instead of tearing down and recreating the widget on every delta. The full widget swap now only happens once (first update replacing the spinner).
- Limit the loop to
0.5.0 (2026-03-10)
New
-
[overlay] auto-pause recording during silence to trim dead air. [Valentin Lab]
When the user stops speaking, recording pauses after 0.5s of silence (RMS below 0.003). Silent segments are not sent to the transcription API, reducing noise and improving accuracy.
Recording resumes instantly when speech is detected. A 500ms lookback buffer in the audio tee preserves the speech onset so the beginning of words is not clipped.
Visual indicator: yellow pause bars replace the red pulsing dot, with "LISTENING" text in the viz area.
Dead-signal detection (NO SOUND) takes priority over auto-pause. The dead-signal trigger is increased from 1 frame to 0.5s to avoid false flashes from momentary PipeWire glitches.
The pause mechanism uses a shared
Arc<AtomicBool>between the overlay thread and the audio tee task. -
[overlay] detect dead audio device and display NO SOUND warning. [Valentin Lab]
Unified audio capture: the overlay now reads from the same PipeWire stream as the recording pipeline via a new audio tee task (
src/audio/tee.rs), replacing the independent CPAL capture that could pick a different device or miss the dead-device condition.Dead-signal detection uses sample variance rather than an RMS threshold. A dead or missing PipeWire device sends constant
i16::MIN(-32768) samples, which yields RMS ~1.0 (indistinguishable from quiet speech) but variance = 0. A real microphone in a silent room still produces random quantisation noise with variance > 0, so the check reliably separates "no device" from "quiet room".Visual warning: when a dead signal is detected the recording badge switches from the red dot to a prohibit icon (circle + diagonal bar) with "NO SOUND" text, and the text panel shows a descriptive message explaining the likely cause.
Font and glyph rendering utilities (
draw_text,measure_text, glyph helpers) moved fromsrc/x11/visualizer.rsto the sharedsrc/x11/render_util.rsso both the visualizer and the overlay can use them without duplication. -
[x11] add
--bwmonochrome mode for audio visualizers with theme detection. [Valentin Lab]Add a
--bwflag that switches the amplitude and spectrum visualizer panels to monochrome rendering. The foreground colour is chosen automatically based on the desktop theme (dark-lightcrate via freedesktop D-Bus portal, withGTK_THEMEenv-var fallback): white-on-black for dark themes, black-on-white for light themes.Also fixes visualizer panel positioning to use the exported
BADGE_Wconstant instead of a hardcoded 182, which caused overlap after the badge was widened to 273 px. -
[transcription] add 2-second TCP connect timeout to HTTP clients. [Valentin Lab]
Add a CONNECT_TIMEOUT of 2 seconds to all reqwest::Client builders in Mistral, OpenAI, and model-suggestion modules. This fails fast when the server is unreachable without shortening the overall transcription timeout (reverted to 5 seconds). Constructors for MistralBatchTranscriber and OpenAIBatchTranscriber now return Result to propagate client-build errors.
-
[x11] replace static recording badge with live spectrogram waterfall. [Valentin Lab]
Overlay now renders a real-time FFT spectrogram with a pulsing red
recdot, dynamic frequency scaling, and all-time volume peak tracking. Extract sharedPixelBuffer,RingBuffer, FFT, and shape helpers intorender_utilmodule fromvisualizer.
Changes
-
[overlay] improve FFT resolution and fix visual gaps in visualizers. [Valentin Lab]
Double
FFT_SIZEfrom 1024 to 2048, raising frequency resolution from 46.9 Hz/bin to 23.4 Hz/bin.Rewrite
map_spectrum_to_column()to use midpoint-boundary bin ranges with peak (max) aggregation instead of truncating to a single bin index with 3-neighbor averaging. This eliminates visual holes in the waterfall caused by integer truncation skipping bins.In
render_spectrum_badge(), remove the hard-coded 1 px gap between bars and distribute bins proportionally across the full badge width so no frequency bins are dropped. -
Rename
--bwflag to--mono[Valentin Lab] -
Consolidate audio visualizers into recording badge with
--vizflag. [Valentin Lab]Replace
--amplitudeand--spectrumside-panel flags with a single--viz <waterfall|amplitude|spectrum>option that renders the selected visualization inside the recording badge. No visualizer by default.- Add
VizModeenum toconfig.rswith config file + env var support - Overlay badge switches renderer per viz mode (waterfall/amplitude/spectrum)
- Strip dead audio panel code from
visualizer.rs(text-only now, -756 lines) - Audio capture in overlay only when viz mode is active
- Add
-
[x11] add transparent gap around pulsating red dot on recording badge. [Valentin Lab]
Clear a circle slightly larger than the red dot to transparent before drawing the dot itself, creating a 2-pixel gap that visually separates the dot from the spectrogram waterfall underneath. The gap scales with the dot radius (which varies from 3 to 10 px based on volume). Edge pixels are anti-aliased by blending towards transparent.
-
[x11] widen recording badge and make spectrogram fill full width. [Valentin Lab]
Badge width increased from 182 to 273 pixels (~50% wider). Spectrogram waterfall now spans the full badge width (margin 4px each side). Red dot moved left (DOT_CX 28→20) and draws on top of the spectrogram instead of beside it.
-
[x11] use 32-bit ARGB visual for compositor alpha transparency on recording badge. [Valentin Lab]
Replace opaque background + Shape extension approach with true ARGB transparency. Add
find_argb_visual(),create_argb_overlay_window(), anddraw_rounded_border()with SDF-based premultiplied alpha rendering. The badge now shows a visible rounded border and is see-through to the desktop. Falls back to the old opaque+shape approach when no 32-bit visual is available. -
[dictate] reduce transcription timeouts from 5s to 2s. [Valentin Lab]
Lower
TRANSCRIPTION_TIMEOUTandRETRY_TIMEOUTfrom 5 seconds to 2 seconds to shorten the wait during dictation. -
[dictate, x11] decouple text panel lifecycle from audio visualizer panels. [Valentin Lab]
The text panel is now created once at visualizer thread start and persists for the entire thread lifetime, independently of the audio panels (amplitude/spectrum).
Hidenow only hides audio panels; the text panel self-manages its visibility based on pending messages.This removes the need for callers to explicitly
hide()the visualizer after showing error/status messages — the text panel stays visible until its TTL messages expire, then unmaps itself.- Remove
HideAudiocommand;Hidetakes its semantics - Remove
destroy_windows(); onlydestroy_audio_windows()needed - Remove
enable_textparameter fromnew()/visualizer_thread() - Remove explicit
viz.hide()calls from error paths indictate - Event loop now has three states: audio-active, text-only, and idle
- Remove
-
[dictate, x11] replace
set_textwith TTL-basedpush_messagefor status messages. [Valentin Lab]Error and retry messages now stack in the visualizer text panel and fade out after a configurable TTL instead of overwriting the live transcription line.
hide_audiodismisses only the amplitude and spectrum panels while keeping status messages visible. -
[paste] reduce inter-paste sleep delays. [Valentin Lab]
The native
x11rb/XTestpaste path is synchronous and no longer needs the generous delays that accommodatedxdotoolprocess-spawn overhead.- Clipboard-set → paste delay: 50 ms → 5 ms
- Inter-paste settle: 100 ms → 15 ms
- Final settle before clipboard restore: 100 ms → 50 ms
-
[dictate, x11] always create visualizer for error/retry feedback. [Valentin Lab]
The visualizer is now initialised even when
--amplitudeand--spectrumare not passed. When neither audio panel is enabled the visualizer thread skips CPAL audio-device initialisation and only manages the text window — very lightweight.The text panel is created unconditionally but mapped on demand: it appears when a message is set and hides when cleared. This lets the dictation pipeline display transcription errors, reconnection attempts, and retry status directly below the overlay badge so the user is never left wondering why nothing is happening.
Realtime mode shows: transcription errors, "reconnecting", and "reconnect failed". Batch (streaming) mode shows: retry count, pipeline failure reason, and "will retry after recording".
-
[dictate, audio] decouple WAV recording from transcription pipeline. [Valentin Lab]
WAV recording now writes to a shared
AudioBufferthat is completely independent of the transcription pipeline. If transcription fails mid-recording, the audio is never lost — thebuffer_feederreplays all chunks from cursor 0 into a fresh transcriber.Key changes:
-
Replace
audio_tee_to_wavwithwav_recording_task+buffer_feeder: the WAV task writes every chunk to disk and to the shared buffer unconditionally; feeder tasks read from the buffer and can be killed/restarted without affecting the recording. -
Realtime mode: on
TranscriptionEvent::Erroror unexpected channel closure, abort the feeder, create a new transcriber, and replay all buffered audio from the beginning. -
Batch (streaming) mode: same decoupling; retry the encode + transcribe pipeline up to 3 times during live recording. If all retries fail, recording continues and the WAV is intact for post-recording retry.
-
PipeWire capture: flush residual samples after the mainloop exits so the last partial chunk is not silently dropped.
-
Play the start-sound before starting capture so the tone is never recorded in the audio.
-
Fix
-
[overlay] implement color rendering for visualizers. [Valentin Lab]
Add
heat_map_color(blue→cyan→green→yellow→red) for waterfall andlevel_color(green→yellow→red) for bar/wave. All three visualizers now render in color by default;--bwcorrectly switches to monochrome instead of being a no-op.
0.4.0 (2026-02-26)
New
-
[picker] add play button to preview recorded audio. [Valentin Lab]
Add a ▶/■ toggle button at the top of the
--pickwindow so users can listen to their recorded audio before selecting a transcription. Reuses the existingWavPlayerfrom the recordings browser by widening its visibility topub(crate). Playback stops automatically on window close or escape.
Fix
-
[dictate, transcription] stop
toggle_validatefrom killing daemon on network errors. [Valentin Lab]Proactive
toggle_validatewas sendingSIGINTto the daemon on unrelated network errors, causing unexpected recording stops. Remove proactive validation entirely from the toggle/recording path. Model suggestions are now lazy: only fetched (with cache + retries) when transcription fails with amodel-not-founderror.Move provider-specific intelligence (error detection patterns, API base URLs, model filters) from
model_suggestions.rsintomistral.rsandopenai.rs.model_suggestions.rsis now a pure cache utility with no provider knowledge. Factory dispatchers intranscription/mod.rsroute calls to the correct provider module.
0.3.0 (2026-02-24)
Changes
-
[clipboard, x11] replace
xclipwith nativex11rbclipboard. [Valentin Lab]Use
x11rbdirectly for CLIPBOARD selection get/set instead of shelling out toxclip.set_textspawns a background thread that servesSelectionRequestevents until the next write or drop.No external runtime tools are required for clipboard operations.
-
[paste, x11] replace
xdotoolwith nativex11rb/XTestcalls. [Valentin Lab]Remove the
xdotoolruntime dependency by reimplementing all window-management and key-simulation helpers withx11rband the XTest extension:get_active_windowandfocus_windownow usex11rbdirectlysimulate_pasteandsimulate_backspaceuse XTest key eventsxdotoolremoved from README prerequisites
Fix
-
[record] save recordings in configured
output_dirby default. [Valentin Lab]Previously
talk-rs record(with no explicit path) wrotememo-<timestamp>.ogginto the current working directory. Now it loads the user config and usesoutput_diras the parent directory for default recordings, matching the behavior ofdictate.Extracts a testable
resolve_output_path()helper and updates CLI help text and config docs to reflect the new default.
0.2.0 (2026-02-23)
New
-
[dictate] add
--no-chunk-pasteflag andpaste.chunk_charsconfig. [Valentin Lab]Allow users to control the 150-character paste chunking behaviour.
--no-chunk-pastedisables chunking entirely (pastes in one shot).paste.chunk_charsin the config file sets a custom chunk size;0also disables chunking. The CLI flag overrides the config value. -
[dictate] add
--no-boopflag and honorboop_interval_msconfig. [Valentin Lab]Adds a
--no-boopCLI flag that disables only the periodic boop heartbeat during recording while keeping start/stop sounds. Also wires up the existingindicators.boop_interval_msconfig field which was defined but never read — the boop interval was hardcoded to 5 seconds. Settingboop_interval_msto0in config now disables boops permanently. -
[dictate] add 5 s transcription timeout with 5-attempt retry. [Valentin Lab]
dictate_streamingnow aborts the transcription task if it does not complete within 5 seconds — this prevents zombie daemon processes caused by the API hanging indefinitely.On failure (timeout, API error, network issue through VPN), the batch branch retries up to 5 times using
transcribe_fileagainst the saved WAV. Each retry creates a freshBatchTranscriberand applies the same 5 s timeout. Progress is shown in the visualizer overlay: "Transcription failed: {reason}. Retrying ({N}/5)..."If all retries are exhausted the error is displayed for 3 seconds, then the daemon exits cleanly — YAML metadata is skipped, paste is skipped, and the WAV is preserved for the picker.
-
[record-ui] add GTK4 recordings browser with
--uiflag. [Valentin Lab]talk-rs record --uiopens a two-section window:- Dictation cache — WAV files from
~/.cache/talk-rs/recordings/ - Recordings — OGG files from
config.output_dir
Each row shows date, duration, size, and transcript preview with play (native
cpal), open-in-file-manager (FileLauncher), and delete buttons. Sections auto-refresh viagio::FileMonitor(inotify) when files are added or removed externally, and the expander counter updates on deletion.Enables
gtk4featurev4_10forFileLaunchersupport. - Dictation cache — WAV files from
-
[transcription] add
--diarizespeaker diarization. [Valentin Lab]Add modular speaker diarization support. Each provider can optionally populate a
diarizationfield onTranscriptionResultwith per-segment speaker labels (DiarizationSegmentstruct).Mistral V2 batch mode: sends
diarize=trueandtimestamp_granularities=segmentform fields, parsessegments[].speaker_idfrom the API response. OpenAI returnsNone(interface ready for future implementation).CLI:
--diarizeflag ontranscribeanddictatecommands. Client-side error when combined with--realtime(Mistral WebSocket has no diarize parameter). Output formatting merges adjacent same-speaker segments into[SPEAKER_XX]-tagged lines viaformat_transcription_output().Also fixes stale docs: default model name in
README.organdconfig.example.yaml,--pickhelp text (rofi to GTK), and adds missing OpenAI provider section to config example.
Changes
-
[dictate] move stop-sound and overlay feedback into
dictate_streaming[Valentin Lab]Immediate audible + visual feedback (stop sound, "Transcribing" badge) now fires inside
dictate_streamingright aftercapture.stop(), so the user gets feedback the instant they toggle off — before the API call finishes. Batch-mode overlay is kept visible until paste or empty-transcription exit. Also appliescargo fmt. -
[dictate] register early
SIGINThandler and add debug tracing. [Valentin Lab]Move the
CancellationToken+ctrl_chandler above the capture and sound-indicator setup. Without this there is a ~1 s race window where SIGINT has no handler and the daemon becomes an unkillable orphan. Also append[DBG]traces todaemon.logintoggle_dispatchandtoggle_stopfor signal-flow observability.
0.1.0 (2026-02-21)
New
-
[audio] add
--monitorflag for mic+system audio mixing. [Valentin Lab]Add
MonitorCapturethat combines microphone input with system audio (PipeWire monitor source) into a single stream. The--monitorflag is available on bothrecordanddictatecommands, and is forwarded through toggle-mode daemon spawning. -
[monitor] add GDK4-based monitor geometry module. [Valentin Lab]
Query the GDK display for the largest monitor (by physical pixel area) and return
(x, y, width, height)scaled by the monitor'sscale_factor. GDK4 dropped the "primary" flag, so largest-area is used as heuristic.This replaces the
xrandrsubprocess calls previously embedded inoverlay.rsandvisualizer.rswith a shared, type-safe module that returns physical pixel coordinates suitable for direct X11 window placement. -
[dictate] integrate realtime transcription into picker. [Valentin Lab]
The
--pickflag now accepts realtime (WebSocket) transcribers alongside batch ones. Each realtime candidate streams incremental text updates into the GTK picker viaPickerMessage::StreamUpdateand displays a ⚡ indicator in the provider column.Key additions:
OPENAI_REALTIME_MODELS/MISTRAL_REALTIME_MODELSconstants andadd_known_realtime_modelsto populate retry candidates.read_wav_pcm_sampleshelper to feed recorded WAV data into realtime transcribers as chunked PCM.split_into_word_chunkssplits paste text into word-bounded chunks so large transcriptions are pasted incrementally, avoiding overwhelming the target application.- Retry button correctly re-spawns either batch or realtime transcription depending on the candidate type.
- Removes the
--pick is currently supported only in batch modeguard.
-
[daemon] add ownership-safe
signal_daemonandstop_if_ownerhelpers. [Valentin Lab]signal_daemonsends SIGINT to a daemon process group and removes the PID file immediately so the next toggle-on seesNotRunning. The exiting daemon usesremove_pid_file_if_ownerto avoid clobbering a PID file written by a newly spawned replacement.stop_if_ownerre-acquires the lock and only performs a full graceful stop when the PID file still belongs to the expected process. -
[audio] add native PipeWire capture and
rubatoresampler. [Valentin Lab]Add two new audio modules:
-
pipewire_capture: captures audio directly from PipeWire using the Rustpipewirebindings, matchingpw-cat --recordrouting (including Bluetooth devices). Runs on a dedicated thread with fixed-size chunk emission. -
resample: sinc-interpolated downsampler (48 kHz → 16 kHz) usingrubatowith Blackman2 windowing and 128-tap anti-aliasing filter. Includesspawn_resample_task()that bridges twompscchannels, passing through unchanged when rates match.
Also adds
preferred_capture_rate()toCpalCapturefor querying the default input device's best mono rate (capped at 48 kHz).Dependencies:
pipewire,rubato,audioadapter-buffers,gdk4-x11. -
-
[dictate] add transcription metadata capture and GTK multi-provider picker. [Valentin Lab]
Enrich transcription responses with structured
TranscriptionMetadatacapturing latency, token usage, detected language, and provider-specific diagnostics (rate-limit headers, realtime session/event counters).The
BatchTranscribertrait now returnsTranscriptionResult(text + metadata) instead of a bareString, and the recording cache YAML includes the full metadata payload for post-hoc analysis.Add a GTK4 picker window (
--pick) that fires parallel transcription requests across all known provider/model combinations and displays candidates progressively as they complete. Results are cached per audio file in~/.cache/talk-rs/picker-results/so reopening the picker skips API calls entirely.Supporting changes:
--retry-last: reuse the last cached recording as input audio--replace-last-paste: delete previously pasted text before inserting the new selection (tracked vialast_paste.yml)last_recording.wav/last_metadata.ymlsymlink pointers in the recording cache for quick access to the most recent entrysimulate_backspacehelper andpaste_text_to_targetrefactoring to share paste logic between batch mode and pickerx11_centre_and_raisefor single-instance picker detection and monitor-aware centring via RandR- New
TranscriptionEventvariants (SessionInfo,RateLimitsUpdated,TransportMetadata) for realtime metadata collection - Mistral realtime validation now skips the REST
/v1/modelscheck (realtime-only models are not listed there)
-
[dictate] add
--output-yamlflag for metadata export. [Valentin Lab]Copy the recording cache metadata YAML to a user-specified path after transcription completes. Reuses the existing cache metadata file written by
write_metadatarather than generating a separate one. -
[dictate] add
--input-audio-fileflag for file-based transcription. [Valentin Lab]Feed a pre-recorded WAV file through the transcription pipeline instead of live microphone capture. Works with both batch and realtime modes, enabling reproducible benchmarks across providers and models.
The
WavFileSourceimplementsAudioCaptureand validates the file is 16 kHz / mono / 16-bit PCM (withffmpegconversion hints on mismatch). Batch mode races Ctrl+C against natural file completion via a oneshot signal from the encode task. Realtime mode handles file exhaustion through the existing channel cascade.Also removes the now-redundant
save_fileparameter fromdictate_streaming()since--savecopies from the recording cache. -
[dictate] add
--saveflag and recording cache. [Valentin Lab]Replace the positional
FILEargument with--save <PATH>on thedictatecommand. The last 10 recordings are now always cached in~/.cache/talk-rs/recordings/as timestamped WAV files with companion YAML metadata (provider, model, realtime flag, transcript).Both batch and realtime paths tee raw PCM to the cache. When
--saveis specified, the cache WAV is copied to the user path after recording. Oldest entries beyond 10 are automatically rotated out. -
[transcription] add multi-provider support with OpenAI backend. [Valentin Lab]
Add
OpenAIas a second transcription provider alongsideMistral, supporting both batch (REST) and realtime (WebSocket) modes.Infrastructure:
Providerenum (Mistral|OpenAI) withDeserialize/FromStrBatchTranscriberandRealtimeTranscribertraits with factory functions and lazy API-key validation--providerand--modelCLI flags ondictateandtranscribeTranscriptionConfigwithdefault_providerin config fileOpenAIConfigwithmodel(batch) andrealtime_modelfields- Environment variable overrides for all provider settings
OpenAI batch (
openai.rs):OpenAIBatchTranscriberwith streaming upload viareqwest::Body::wrap_stream- Wiremock-based unit tests for success, error, streaming, and edge cases
OpenAI realtime (
openai_realtime.rs):OpenAIRealtimeTranscriberusing WebSocket withsession.type: "transcription"- 16 kHz → 24 kHz PCM resampling (linear interpolation, 3:2 ratio)
server_vadturn detection,input_audio_buffer.appendstreaming- Post-commit timeout for completion detection (no explicit "done" event)
- Default model:
gpt-4o-realtime-preview
Preflight validation:
validate()method on both traits, called before audio capture- REST
GET /v1/modelscheck: verifies API key and model existence, lists available transcription models on bad model name - WebSocket session check (realtime only): catches "model not supported
in realtime mode" before daemon spawn in
--togglemode - Validation in
toggle_start(),dictate(), andtranscribe()
-
[visualizer] add live transcription text overlay. [Valentin Lab]
Render live transcription text below the recording badge using
fontduefor glyph rasterisation. Features include:- Async font loading from system paths (
/usr/share/fonts) to avoid blocking the render loop at startup - CJK-aware font fallback (Noto Sans CJK)
- Centered, single-line text with automatic left-clip on overflow
- Pulsing "..." dots while waiting for speech
- Rounded-corner text background via
XShape dictate_realtime()pushesTextDelta/SegmentDeltatext to the overlay in real time
- Async font loading from system paths (
-
[cli] add
--amplitudeand--spectrumflags todictate[Valentin Lab]Integrate the visualizer module into the dictate command lifecycle: init on start, show alongside the recording badge, hide on stop. Both flags are forwarded through
--toggledaemon mode. -
[visualizer] add real-time audio visualizer module. [Valentin Lab]
Amplitude history (RMS-based bar chart) and
FFTspectrum panels rendered via X11put_imageat 60 fps. Each panel is independently toggleable, positioned on either side of the recording badge, and runs its ownCPALcapture stream decoupled from the recording pipeline.Includes ring buffer, radix-2 Cooley-Tukey
FFT, pixel buffer helpers, multi-monitor geometry detection, and comprehensive unit tests. -
[cli] add debug WAV capture for realtime transcription. [Valentin Lab]
Every realtime
dictatesession now saves a copy of the raw PCM audio to$XDG_CACHE_HOME/talk-rs/debug-capture.wav(or to the user-specified file path). This tees the audio stream so exactly what is sent to Voxtral is also written to disk, allowing the user to verify that:- recording starts when expected
- recording stops when expected
- the full audio content is captured correctly
The WAV header is patched with the final data size on completion. The tee task continues writing even if the transcriber channel closes early.
-
[cli] add structured logging with
-v/-vv/-vvvverbosity. [Valentin Lab]Replace unused
tracing/tracing-subscriberwithlog+fern+coloredfollowing thefylproject pattern. Alleprintln!diagnostic messages are now routed throughlog::macros at appropriate levels:log::error!for failures (WebSocket errors, encode errors)log::warn!for degraded operation (missing overlay, stream drops)log::info!for user-visible events (start/stop, transcription result)log::debug!for pipeline steps (connect, session, clipboard, overlay)log::trace!for high-frequency data (audio chunks, WS frames)
The
-vflag is forwarded to daemon subprocesses so--togglemode inherits verbosity. Refactorsdictate()arguments intoDictateOptsstruct to satisfyclippy::too_many_arguments. -
[overlay] add X11 visual overlay indicator for
dictatecommand. [Valentin Lab]Pure Rust X11 overlay using
x11rbwith Shape extension for binary transparency (works without compositor). Displays embedded PNG badges (recording/transcribing) centered on primary monitor.src/core/overlay.rs: background thread with command channel,OverlayHandlefor show/hide/quit, Shape mask from alpha channel, pixel drawing grouped by color viapoly_point--no-overlayflag ondictate(passed through--toggle)- Overlay shows "Recording" badge on start, hides on stop
- PNG assets embedded via
include_bytes!, decoded withpngcrate - Screen position from
xrandr --querywith multi-monitor support - 8 unit tests covering PNG decode, geometry parsing, type invariants
-
[audio] add sound indicators to
dictatecommand. [Valentin Lab]Synthesize short tones via
cpaloutput to give audible feedback during recording: ascending major-third on start, periodic soft boop as heartbeat, descending major-third on stop. A single-channelSoundPlayerwith preemption ensures sounds never overlap.indicator.rs: tone synthesis,SoundPlayer, boop loop viatokiotask withCancellationToken--no-soundsCLI flag to disable indicators- Flag forwarded through
--toggledaemon spawn
-
[cli] add
--toggledaemon mode todictatecommand. [Valentin Lab]First invocation spawns a background daemon (
--daemon) that records and transcribes via WebSocket. Second invocation sendsSIGINTto stop recording, complete transcription, and paste the result.Uses kernel-level
flockon a lock file to prevent races between concurrent toggle calls. PID file at$XDG_CACHE_HOME/talk-rs/daemon.pidwith stale-PID detection viakill(pid, 0). Graceful shutdown waits up to 10 s before escalating toSIGTERM.Active window is captured by the toggle caller and forwarded to the daemon via the hidden
--target-windowargument so paste targets the correct window. -
[transcription] add
realtimeWebSocket module for Voxtral Realtime API. [Valentin Lab]Implement
MistralRealtimeTranscriberthat connects to the Voxtral Realtime API via WebSocket, streams base64-encoded PCM audio, and receives incrementalTranscriptionEventvariants (text deltas, segment boundaries, language detection, errors).The module includes event parsing, sender/receiver loops, and comprehensive unit tests for all event types and PCM encoding.
Adds dependencies:
tokio-tungstenite,base64,url; movesserde_jsonfrom dev to main dependencies. -
[config] add
modelandcontext_biastoMistralConfig[Valentin Lab]Add configurable model name (defaulting to
voxtral-mini-latest) and optionalcontext_biasfield for improved transcription accuracy of proper nouns and technical terms.Both fields support environment variable overrides via
TALK_RS_PROVIDERS_MISTRAL_MODELandTALK_RS_PROVIDERS_MISTRAL_CONTEXT_BIAS.The
MistralTranscribernow passes these fields through to the Mistral API in both file-based and streaming transcription requests. -
[audio] add
AudioWritertrait withOggOpusWriterandWavWriter[Valentin Lab]Replace raw
OpusEncoderusage with container-aware writers that produce validOGG/Opus (RFC 7845) and WAV output.OggOpusWriter: encodes PCM → Opus, wraps in OGG pages with properOpusHead/OpusTagsheaders and granule positionsWavWriter: wraps raw PCM in a 44-byte WAV header, with finalize returning a corrected header for seekable filesrecordcommand dispatches writer by file extension (.wavvs default.ogg)dictatestreaming and chunked modes now produce self-contained OGG payloads per chunk (each with its own header), fixing transcription API compatibility- Chunked mode buffers raw PCM instead of pre-encoded Opus, enabling per-chunk OGG encapsulation
- New dependencies:
ogg0.9,byteorder1
-
[cli] add
--chunkedmode todictatecommand. [Valentin Lab]Add
--chunkedflag and-n/--chunk-secondsoption to split recording into time-based chunks, each transcribed separately viaMistralTranscriber. Results are accumulated and pasted at the end.Chunk duration is resolved from: CLI
-nflag >dictate.chunk_secondsin config > error. AddsClonetoMistralConfigfor per-chunk transcriber instantiation. -
[cli] add
dictatecommand with streaming transcription and clipboard paste. [Valentin Lab]Record audio, stream it to Mistral API for transcription via
transcribe_stream(), then paste the result into the focused application usingxclip+xdotool:- Capture active window before recording (
xdotool getactivewindow) - Encode PCM → Opus and stream to API during recording
- On stop: refocus window, save clipboard, set text, paste via
xdotool key ctrl+shift+v, restore clipboard - Optional
--filearg to save audio alongside transcription
- Capture active window before recording (
-
[transcription] add
transcribe_stream()for streaming audio upload. [Valentin Lab]Extend
Transcribertrait withtranscribe_stream()method that accepts atokio::sync::mpsc::Receiver<Vec<u8>>for incremental audio upload.MistralTranscriberconverts the receiver to afutures::Streamviatokio-streamand usesreqwest::Body::wrap_stream()for chunked HTTP transfer encoding — audio is uploaded as it's recorded, so transcription completes near-instantly after recording stops.Adds
tokio-streamdependency forReceiverStreamwrapper. -
[clipboard] add
Clipboardtrait withX11ClipboardandMockClipboard[Valentin Lab]Introduce clipboard module for Phase 2 dictate functionality:
Clipboardtrait withget_text()andset_text()async methodsX11Clipboardimplementation usingxclipcommand-line tool (matches 0k-memo reference implementation)MockClipboardwithArc<Mutex<String>>for thread-safe testingTalkError::Clipboarderror variant for clipboard operations- Unit tests for
MockClipboard(4 tests) - Integration tests:
xclipbinary check, X11 roundtrip, save/restore pattern (2 ignored, require X11 display)
-
[cli] add
transcribecommand withMistralTranscriberbackend. [Valentin Lab]Transcribes audio files via Mistral API (
voxtral-mini-latestmodel). Outputs to stdout or file. Config refactored withprovidersnamespace for future backend extensibility.Includes:
Transcribertrait withMockTranscriberandMistralTranscribertranscribeCLI command with arg parsing- Integration tests: mock pipeline, error handling, real Mistral API call
-
[cli] add
recordcommand withCpalCaptureandOpusEncoderpipeline. [Valentin Lab]Captures audio from system microphone, encodes with Opus, writes to file. Supports optional output path (defaults to
memo-YYYY-MM-DD-HH-MM-SS.ogg). Graceful shutdown via SIGINT (Ctrl+C) with encoder flush.Includes integration tests verifying:
- Mock capture pipeline creates valid output files
- Real hardware capture creates non-empty Opus files
- Default filename format generation
-
[audio] add
AudioEncodertrait withMockEncoderandOpusEncoderimplementations. [Valentin Lab]Implement audio encoding trait with two implementations:
- MockEncoder: Pass-through encoder for testing (converts i16 to little-endian bytes)
- OpusEncoder: Real Opus codec encoder using the opus crate with configurable bitrate
Features:
- AudioEncoder trait with encode() and flush() methods
- Stateful encoding with internal buffering for frame-based codecs
- Configuration from AudioConfig (sample_rate, channels, bitrate)
- Support for mono and stereo channels
- Comprehensive unit tests including encode/decode roundtrip verification
- Proper error handling with TalkError::Audio variant
Changes:
- Created src/core/audio/encoder.rs with trait and implementations
- Added Clone derive to AudioConfig for test flexibility
- Exported AudioEncoder, MockEncoder, OpusEncoder in src/core/audio/mod.rs
- All tests pass (10 audio tests)
-
[audio] add
AudioCapturewithCpalCaptureand full sample format support. [Valentin Lab]Includes:
AudioCapturetrait for swappable backendsMockAudioCapturefor testingCpalCapturewith support for all CPAL sample formats (I8, U8, I16, U16, I32, U32, I64, U64, F32, F64)- Integration tests verifying real audio device compatibility
All unit and integration tests pass on real hardware.
-
[error] add
TalkErrorenum withthiserrorderives. [Valentin Lab]Implements comprehensive error handling with variants for:
Config- Configuration errorsAudio- Audio capture/encoding errorsTranscription- API transcription errorsIo- IO operations (with#[from] std::io::Error)Session- Session management errors
Includes unit tests for error conversions and documentation.
Refs #1.3
-
[config] add
Configstruct withYAMLloading. [Valentin Lab]Implements configuration loading with:
Configstruct with nestedMistralConfig,AudioConfigConfig::load()with optional custom path parameterXDGdirectory support viadirectoriescrate- Environment variable overrides (
TALK_RS_*prefix) - Fail-fast on missing required fields
- Unit tests for loading and validation
Uses patterns from
insight-clifor directory handling.Refs #1.4
Changes
-
[visualizer] render amplitude as symmetrical waveform. [Valentin Lab]
Replace bottom-anchored vertical bars with a mirror-image waveform centred on the vertical midline. Each column extends equally upward and downward, producing a classic audio waveform look.
Pre-fill the amplitude history buffer with zeros so the waveform starts at the right edge and scrolls leftward — instead of stretching a few early samples across the whole panel width.
-
[visualizer] skip text panel when not in realtime mode. [Valentin Lab]
The text bar with pulsing dots below the recording badge is only useful in realtime mode where live transcription text streams in. In batch mode it just showed empty dots.
Add an
enable_textflag toVisualizerHandle::new()andcreate_windows(), gated onopts.realtime. The error overlay path keeps text enabled so validation failures remain visible. -
[dictate] switch paste chunking to character-based and init GTK4 in daemon path. [Valentin Lab]
Replace word-based
split_into_word_chunkswith character-basedsplit_into_char_chunks(limitPASTE_CHUNK_CHARS= 150). Splits on word boundaries to avoid cutting words. The 150-char threshold keeps each paste below the point where terminal applications collapse it into an opaque summary block.Also call
gtk4::init()before creating the overlay and visualizer in the daemon code path. Without this, GDK4 monitor queries silently fail and the overlay/visualizer never appear whentalk-rsruns as a toggle-mode daemon. -
[overlay,visualizer] replace
xrandrwith GDK4 monitor geometry. [Valentin Lab]Remove the per-module
xrandr --querysubprocess calls and their associated parsing helpers (parse_geometry,parse_primary,parse_geom_word) along with their tests.Both modules now call
monitor::primary_monitor_geometry()from the main thread before spawning their X11 worker threads, passing theMonitorGeometrytuple in. This is required because GDK must be queried from the thread that calledgtk4::init(). -
[picker-cache] add
streamingflag to cache entries. [Valentin Lab]SelectedEntryandCachedResultnow carry astreamingbool (defaulting tofalsefor backwards compatibility) so the picker cache can distinguish batch from realtime transcription results.write_selectedtakes the flag as a new parameter. -
[dictate] improve picker UX and integrate PipeWire capture. [Valentin Lab]
Picker improvements:
- Sort entries by (provider, model) for stable display order.
- Add retry button (↻) on error rows via
PickerMessageenum, allowing re-transcription without reopening the picker. - Centre window using
gdk4_x11::X11Surface::xid()in themapsignal, eliminating the_NET_CLIENT_LISTpolling race. - Refactor
x11_centre_and_raiseintox11_centre_and_raise_xid(direct XID) and a title-search wrapper. - Record selected (provider, model) via
picker_cache::write_selectedso the choice persists across picker reopens.
Focus and paste fixes:
- Add
ensure_focus()with exponential-backoff retries to confirm the target window is active before pasting. - Prefer
read_last_paste_state()over recording metadata for replacement character count (correct across successive picker selections). - Pass
--delay 0toxdotool keyinsimulate_backspace.
Audio capture:
- Replace
CpalCapturewithPipeWireCapturefor live recording at 48 kHz, piped throughresample::spawn_resample_taskto downsample to 16 kHz before encoding.
-
[picker-cache] add selection tracking and legacy format migration. [Valentin Lab]
Introduce
PickerCachestruct wrapping results + an optionalSelectedEntryso the picker remembers which (provider, model) the user last chose.read()now returnsPickerCacheand transparently migrates the old flat-array JSON format.- Split
write()intowrite_results()(preserves selection) andwrite_selected()(preserves results). selectedfield is omitted from JSON whenNone(skip_serializing_if).- Updated tests for new format, legacy migration, and serialisation.
-
[config] update default Mistral model to
voxtral-mini-2507[Valentin Lab]voxtral-mini-latestnow aliases tovoxtral-mini-2602; pin to the explicitvoxtral-mini-2507version so users can compare results between model generations. -
[audio] hardcode
AudioConfigparameters. [Valentin Lab]The
audioconfig section (sample_rate,channels,bitrate) exposed Opus encoder internals as user-facing configuration. The only sensible values for voice dictation are 16 kHz / 1 channel / 32 kbps — any other combination either fails or wastes bandwidth with no transcription quality gain.AudioConfignow has anew()constructor returning hardcoded defaults. Theaudiofield is removed fromConfig, along withTALK_RS_AUDIO_*env var overrides and theenv_var_u32/env_var_u8helpers that were only used for audio. -
[cli] make batch mode the default for
dictate[Valentin Lab]Replace
--batchflag with--realtime. Streaming upload (batch) is now the default behavior; pass--realtimeto get incremental WebSocket transcription instead. -
[cli] paste transcription per segment in
dictaterealtime mode. [Valentin Lab]Instead of accumulating all segments and pasting once after recording stops, each segment is now pasted into the focused application as it arrives. This provides real-time feedback while dictating.
dictate_realtime()accepts an optional segment channel. A spawned paste consumer reads from it and does clipboard-set +ctrl+shift+vper segment (~150 ms cadence). Clipboard is saved before recording and restored after the paste task drains.Batch mode (
--batch) retains the previous single-paste behavior. -
[cli] replace chunked mode with realtime default and
--batchflag. [Valentin Lab]The
dictatecommand now uses WebSocket-based realtime transcription by default, streaming audio to the Voxtral Realtime API for incremental results. The previous--chunked/--chunk-secondsflags andDictateConfigare removed in favor of a simpler--batchflag that falls back to the original upload-after-stop workflow.Add client-side
flush_sentences()that splits the live transcription buffer on sentence-ending punctuation (.!?and CJK equivalents。!?), printing completed sentences to stdout as they arrive. Includes unit tests for Latin and CJK punctuation, trailing partials, and no-punctuation edge cases.
Fix
-
[picker] switch retry channel to
tokio::sync::mpsc[Valentin Lab]Replace
std::sync::mpscwithtokio::sync::mpsc::unbounded_channelfor the picker retry channel. The std channel's blocking.recv()holds a tokio worker thread and prevents the runtime from shutting down cleanly when the picker window closes. -
[picker] use theme foreground color for selected rows. [Valentin Lab]
Pin
coloronrow:selectedtotheme_fg_colorso text stays readable on light GTK themes. Without this, some themes switch selected text to white which is invisible against our translucent accent selection background. -
[transcription] use
transcription_session.updatefor OpenAI realtime. [Valentin Lab]The OpenAI Realtime API requires a different endpoint and event format for transcription-only sessions:
- URL uses
?intent=transcriptioninstead of?model= OpenAI-Beta: realtime=v1header is required- Client event is
transcription_session.update(notsession.update) - Session schema uses flat
input_audio_format/input_audio_transcriptionfields instead of nested GAaudio.inputstructure - Server responds with
transcription_session.created/.updated
Also fix the default
realtime_modelfromgpt-4o-realtime-preview(a session model) togpt-4o-mini-transcribe(a transcription model). - URL uses
-
[audio] fire stop sound immediately on toggle. [Valentin Lab]
Pass
SoundPlayerandCancellationTokenintodictate_realtime()so the stop sound plays the instant SIGINT is caught — beforecapture.stop()and before the WebSocket finishes collecting transcription results.Previously the stop sound played only after
dictate_realtime()returned, adding 100-500ms of perceived delay on toggle.Batch mode still plays the stop sound in
dictate()after the recording completes. -
[audio] use deterministic flush signal on capture stop. [Valentin Lab]
Replace the heuristic 50ms sleep in
CpalCapture::stop()with a deterministicstd::sync::mpscsignal from the callback thread.The callback stores its final partial buffer in a shared
final_bufferand signals completion via a one-shot channel.stop()waits for this signal (with a 2-second safety timeout for unresponsive hardware), then injects the final samples into thetokio::sync::mpscchannel before closing it.This guarantees the last audio chunk is never silently dropped, regardless of callback timing or system load.
-
[audio] wait for output device warmup before playing start sound. [Valentin Lab]
SoundPlayer::new()now blocks until the CPAL output callback has actually fired at least once, proving the audio pipeline is live. ACondvarbridges the callback thread and the caller — no arbitrary sleep.Previously,
stream.play()returned before the device was ready, so the first few milliseconds of the start sound were lost to device startup latency, making the sound appear clipped. -
[audio] flush partial buffer on capture stop. [Valentin Lab]
The CPAL callback was discarding all incoming audio once
runningbecame false — any samples accumulated in its internal buffer that had not yet reachedsamples_per_chunkwere silently lost. This caused the end of every recording to be chopped off.Now the callback flushes its partial buffer exactly once when it detects the stop signal. A 50ms grace period before dropping the stream ensures the callback has time to see the flag and flush.
-
[transcription] harden networking against hangs and silent drops. [Valentin Lab]
The realtime WebSocket and batch HTTP paths had several unhandled failure modes that could hang the app indefinitely:
connect_asyncandwait_for_session_createdhad no timeout, hanging forever on unreachable servers or unresponsive sessionstranscribe_file(batch) had no request timeout at all- No WebSocket ping keepalive, making silent network drops undetectable
- Sender and receiver tasks ran independently with no coordination; one dying left the other hanging
- Cleanup task silently swallowed panics from spawned tasks
- Silent WS stream end (TCP RST) was not logged
Fixes:
- 15s timeout on WS connect and
session.createdhandshake - 300s timeout on batch
transcribe_filerequest - 30s periodic Ping frames in sender loop for keepalive
- Shared
CancellationTokenbetween sender/receiver for coordinated shutdown on either side's failure - Cleanup task now logs panics from
JoinHandleresults - Unexpected stream end logged and cancellation propagated
Other
-
Test: [audio] add closed-loop loopback integration tests. [Valentin Lab]
5 tests using a PipeWire null sink as a virtual audio loopback:
loopback_start_sound_is_captured: start sound produces non-silent audio through the loopback pipeline.loopback_stop_sound_is_captured: same for the stop sound.loopback_start_sound_has_two_notes: RMS energy envelope confirms two distinct tone bursts (sound completeness, not clipped).loopback_preemption_replaces_sound: at most 3 tone onsets when stop preempts start (4 would mean both played fully).loopback_capture_receives_played_audio_i16: f32-to-i16 conversion path verified through loopback.
Uses the
"pulse"ALSA device (not"default") so cpal routes through PulseAudio where$PULSE_SINK/$PULSE_SOURCEare respected — tests are completely silent (no sound on real speakers).Infrastructure: shared null sink via
OnceLockwith stale cleanup, serial lock with poison recovery,SoundPlayer::from_device()targeting.Run with:
cargo test --test audio_loopback -- --ignored --test-threads=1