Skip to content

Instantly share code, notes, and snippets.

@mxmilkiib
Last active August 19, 2026 12:39
Show Gist options
  • Select an option

  • Save mxmilkiib/916f048882ee035c2cab242b14f89e63 to your computer and use it in GitHub Desktop.

Select an option

Save mxmilkiib/916f048882ee035c2cab242b14f89e63 to your computer and use it in GitHub Desktop.
#!/bin/bash
# Whisper real-time dictation toggle script
# Starts/stops whisper-stream, piping transcribed text to ydotool
# Bind to a KDE hotkey to toggle dictation on/off
# Usage: whisper-dictate --list-mics to list available microphones
# Gist: https://gist.github.com/mxmilkiib/916f048882ee035c2cab242b14f89e63
#
# SPDX-License-Identifier: CC0-1.0
# This work is released to the public domain under the terms of the
# Creative Commons CC0 1.0 Universal license.
# https://creativecommons.org/publicdomain/zero/1.0/
set -uo pipefail
MODEL="${WHISPER_MODEL:-${HOME}/media/models/whisper/ggml-large-v3.bin}"
PIDFILE="/tmp/whisper-dictate.pid"
STREAM_PIDFILE="/tmp/whisper-dictate-stream.pid"
LOCKFILE="/tmp/whisper-dictate.lock"
STATEFILE="/tmp/whisper-dictate.state"
STOPFILE="/tmp/whisper-dictate.stop"
LOGFILE="/tmp/whisper-dictate.log"
FIFO="/tmp/whisper-dictate.fifo"
MICFILE="/tmp/whisper-dictate.mic"
ACTFILE="/tmp/whisper-dictate.activity"
NOTIFYIDFILE="/tmp/whisper-dictate.notifyid"
TRAYPIDFILE="/tmp/whisper-dictate.traypid"
SILENCE_TIMEOUT="${WHISPER_SILENCE_TIMEOUT:-30}"
DEBOUNCE_MS="${WHISPER_DEBOUNCE_MS:-400}"
STOP_GRACE_MS="${WHISPER_STOP_GRACE_MS:-6000}"
VAD_THOLD="${WHISPER_VAD_THOLD:-0.75}"
MIC_SOURCE=""
MIC_DESC=""
CAPTURE_DEV=-1
# --- Microphone detection ---
# Uses pactl (PipeWire/PulseAudio) to find unmuted input sources.
# SDL with the pulseaudio driver enumerates capture devices in the same
# order as pactl list sources (non-monitor only). So the Nth non-monitor
# source corresponds to SDL capture device N. We compute that index and
# pass it to whisper-stream via -c.
detect_mic() {
# Parse all non-monitor sources, find first unmuted one, and compute
# its index among all non-monitor sources (that index = SDL device ID)
local idx=0
local found_idx=-1
local found_name=""
local found_desc=""
while IFS=$'\t' read -r name desc mute; do
if [ "$mute" = "no" ] && [ "$found_idx" -lt 0 ]; then
found_idx=$idx
found_name="$name"
found_desc="$desc"
fi
idx=$((idx + 1))
done < <(pactl list sources 2>/dev/null | awk '
function flush() {
if (name != "" && monitor == 0) {
print name "\t" desc "\t" mute
}
name=""; desc=""; mute=""; monitor=0
}
/^Source #/ { flush() }
/^\tName:/ { name=$2; if (name ~ /\.monitor$/) monitor=1 }
/^\tDescription:/ { sub(/^\tDescription:[[:space:]]+/, ""); desc=$0 }
/^\tMute:/ { mute=$2 }
END { flush() }
')
if [ "$found_idx" -lt 0 ]; then
# No unmuted source — unmute the first non-monitor source and use it
idx=0
while IFS=$'\t' read -r name desc mute; do
if [ "$found_idx" -lt 0 ]; then
found_idx=$idx
found_name="$name"
found_desc="$desc"
pactl set-source-mute "$name" 0 2>/dev/null || true
fi
idx=$((idx + 1))
done < <(pactl list sources 2>/dev/null | awk '
function flush() {
if (name != "" && monitor == 0) {
print name "\t" desc "\t" mute
}
name=""; desc=""; mute=""; monitor=0
}
/^Source #/ { flush() }
/^\tName:/ { name=$2; if (name ~ /\.monitor$/) monitor=1 }
/^\tDescription:/ { sub(/^\tDescription:[[:space:]]+/, ""); desc=$0 }
/^\tMute:/ { mute=$2 }
END { flush() }
')
fi
if [ "$found_idx" -lt 0 ]; then
MIC_SOURCE=""
MIC_DESC="no microphone"
CAPTURE_DEV=-1
return 1
fi
MIC_SOURCE="$found_name"
MIC_DESC="$found_desc"
CAPTURE_DEV=$found_idx
pactl set-default-source "$MIC_SOURCE" 2>/dev/null || true
export SDL_AUDIODRIVER=pulseaudio
return 0
}
list_mics() {
echo "Available microphone sources:"
echo ""
pactl list sources 2>/dev/null | awk '
function flush() {
if (name != "" && monitor == 0) {
printf " %-8s %s (%s)\n", mute == "no" ? "[ON]" : "[MUTED]", desc, name
}
name=""; desc=""; mute=""; monitor=0
}
/^Source #/ { flush() }
/^\tName:/ { name=$2; if (name ~ /\.monitor$/) monitor=1 }
/^\tDescription:/ { sub(/^\tDescription:[[:space:]]+/, ""); desc=$0 }
/^\tMute:/ { mute=$2 }
END { flush() }
'
}
# Handle --list-mics
if [ "${1:-}" = "--list-mics" ]; then
list_mics
exit 0
fi
# --- Debounce ---
# Ignore rapid re-presses (keyboard bounce or accidental double-tap)
now_ns=$(date +%s%N 2>/dev/null || echo 0)
if [ -f "$STATEFILE" ] && [ "$now_ns" -gt 0 ]; then
last_ns=$(cat "$STATEFILE" 2>/dev/null || echo 0)
if [ "$last_ns" -gt 0 ]; then
delta_ms=$(( (now_ns - last_ns) / 1000000 ))
if [ "$delta_ms" -lt "$DEBOUNCE_MS" ]; then
exit 0
fi
fi
fi
echo "$now_ns" > "$STATEFILE" 2>/dev/null || true
# Acquire exclusive lock to serialize toggle invocations
exec 9>"$LOCKFILE"
if ! flock -n 9; then
notify-send -u low "Whisper Dictation" "Already toggling"
exit 0
fi
# Toggle: if running, stop — mic muted immediately so no further audio
# is captured, then wait dynamically (bounded by a safety timeout) for
# whisper-stream to finish processing whatever audio it already had
# buffered, so the last thing said still gets transcribed and typed.
# Only then is whisper-stream killed and the while loop drains the FIFO.
# Guard against PID recycling: verify the process is actually ours
if [ -f "$PIDFILE" ]; then
PID="$(cat "$PIDFILE")"
if kill -0 "$PID" 2>/dev/null && grep -qa 'dictation_pipeline' /proc/"$PID"/cmdline 2>/dev/null; then
# Mute the mic instantly — no new audio enters the buffer from here on
STOP_MIC="$(cat "$MICFILE" 2>/dev/null || true)"
[ -n "$STOP_MIC" ] && pactl set-source-mute "$STOP_MIC" 1 2>/dev/null || true
# Tray icon reflects capture state, not transcription drain — remove
# it now since no more audio is being recorded
[ -f "$TRAYPIDFILE" ] && kill "$(cat "$TRAYPIDFILE")" 2>/dev/null
rm -f "$TRAYPIDFILE"
NOTIFY_ID="$(cat "$NOTIFYIDFILE" 2>/dev/null || true)"
notify-send -u low ${NOTIFY_ID:+-r "$NOTIFY_ID"} -i audio-input-microphone -t 0 "Whisper Dictation" "Finishing up..." >/dev/null 2>&1 || true
# Dynamically wait for one more processing cycle: the pipeline
# bumps ACTFILE every time it reads a line from whisper-stream,
# so once it increases past its pre-mute value, the audio that
# was already captured has been through a full step and any
# trailing speech has had a chance to be transcribed. Bounded by
# STOP_GRACE_MS in case VAD finds nothing new to emit.
start_count="$(cat "$ACTFILE" 2>/dev/null || echo 0)"
deadline_ns=$(( $(date +%s%N) + STOP_GRACE_MS * 1000000 ))
while [ "$(date +%s%N)" -lt "$deadline_ns" ]; do
cur_count="$(cat "$ACTFILE" 2>/dev/null || echo 0)"
[ "$cur_count" -gt "$start_count" ] && break
kill -0 "$PID" 2>/dev/null || break
sleep 0.1
done
# Set stop flag for the read-timeout path
touch "$STOPFILE"
# Graceful stop: SIGTERM whisper-stream, let the while loop drain
if [ -f "$STREAM_PIDFILE" ]; then
kill "$(cat "$STREAM_PIDFILE")" 2>/dev/null || true
# Wait up to 5s for the session to exit naturally
for _ in $(seq 1 50); do
kill -0 "$PID" 2>/dev/null || break
sleep 0.1
done
fi
# Fallback: if still alive, kill the entire process group
if kill -0 "$PID" 2>/dev/null; then
kill -- -"$PID" 2>/dev/null || true
fi
# Last resort: pkill any orphaned whisper-stream
pkill -f "whisper-stream.*$(basename "$MODEL")" 2>/dev/null || true
# Restore the mic to its unmuted baseline now that the session is over
[ -n "$STOP_MIC" ] && pactl set-source-mute "$STOP_MIC" 0 2>/dev/null || true
rm -f "$PIDFILE" "$STREAM_PIDFILE" "$FIFO" "$STOPFILE" "$MICFILE" "$ACTFILE" "$NOTIFYIDFILE" "$TRAYPIDFILE"
notify-send -u low ${NOTIFY_ID:+-r "$NOTIFY_ID"} -i audio-input-microphone "Whisper Dictation" "Stopped" >/dev/null 2>&1 || true
exit 0
else
# Stale PID file (process died or PID was recycled by something else)
[ -f "$TRAYPIDFILE" ] && kill "$(cat "$TRAYPIDFILE")" 2>/dev/null
rm -f "$PIDFILE" "$STREAM_PIDFILE" "$FIFO" "$STOPFILE" "$MICFILE" "$ACTFILE" "$NOTIFYIDFILE" "$TRAYPIDFILE"
fi
fi
# Check model exists
if [ ! -f "$MODEL" ]; then
notify-send -u critical "Whisper Dictation" "Model not found: $MODEL"
echo "Model not found: $MODEL" >&2
exit 1
fi
# Kill any orphaned whisper-stream or tray icon from a crashed session
pkill -f "whisper-stream.*$(basename "$MODEL")" 2>/dev/null || true
pkill -f "yad --notification.*Whisper Dictation" 2>/dev/null || true
# Detect an unmuted microphone and compute its SDL device ID
detect_mic
if [ -z "$MIC_SOURCE" ]; then
notify-send -u critical "Whisper Dictation" "No microphone found"
echo "No unmuted microphone source found" >&2
list_mics >&2
exit 1
fi
echo "$MIC_SOURCE" > "$MICFILE"
echo 0 > "$ACTFILE"
# Pipeline runs in a new session (setsid) so the session leader PID
# doubles as the process group ID. A named pipe (FIFO) separates
# whisper-stream from the while loop, allowing the stop path to kill
# only whisper-stream and let the while loop drain remaining text.
dictation_pipeline() {
rm -f "$FIFO"
mkfifo "$FIFO"
# -t 8: CPU threads for mel preprocessing (GPU handles encode/decode)
# -l en: English language
# -c $CAPTURE_DEV: SDL capture device ID (mapped from pactl source index)
# -vth: voice activity detection threshold — raised above the whisper.cpp
# default (0.6) since the energy-based VAD still let enough near-silent
# audio through for the model to hallucinate stock phrases on
# -nf: disable temperature fallback — fallback decoding is what produces
# confident-sounding hallucinated filler ("Thank you.", "you") when the
# audio is unclear, rather than a low-confidence/empty result
# --step 3000: process 3s chunks
# --length 5000: 5s audio window
# --keep 200: keep 200ms from previous step for context
# SDL_AUDIODRIVER=pulseaudio forces SDL to use the PulseAudio backend,
# which enumerates devices in pactl source order (set by detect_mic)
whisper-stream \
-m "$MODEL" \
-t 8 \
-l en \
-c "$CAPTURE_DEV" \
--step 3000 \
--length 5000 \
--keep 200 \
-vth "$VAD_THOLD" \
-nf \
2>"$LOGFILE" > "$FIFO" &
echo $! > "$STREAM_PIDFILE"
while true; do
if ! IFS= read -r -t 5 line; then
rc=$?
if [ "$rc" -gt 128 ]; then
# read timed out (no output for 5s) — check stop and silence
if [ -f "$STOPFILE" ]; then
break
fi
now=$(date +%s)
if [ $((now - last_activity)) -ge "$SILENCE_TIMEOUT" ]; then
notify-send -u low "Whisper Dictation" "Stopped after ${SILENCE_TIMEOUT}s of silence"
break
fi
continue
else
# EOF — whisper-stream exited or crashed
break
fi
fi
# Heartbeat: bump ACTFILE on every line read from whisper-stream so
# the stop path can detect that at least one more processing step
# has completed since it muted the mic
echo $(( $(cat "$ACTFILE" 2>/dev/null || echo 0) + 1 )) > "$ACTFILE"
# Strip ANSI escape codes and trim whitespace
clean="$(echo "$line" | sed 's/\x1b\[[0-9;]*m//g; s/\x1b\[2K//g' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
# Skip status messages and empty lines
case "$clean" in
*BLANK_AUDIO*|*Start\ speaking*|"") continue ;;
esac
# Strip [timestamp --> timestamp] prefix if present
text="$(echo "$clean" | sed 's/^\[[^]]*\] //')"
# Skip if nothing left after stripping
[ -z "$text" ] && continue
# Sanitize embedded newlines — ydotool type sends a literal newline
# in the input as an Enter keypress, which would submit forms or
# insert unwanted line breaks in whatever has focus
text="$(echo "$text" | tr '\n' ' ')"
# Skip common Whisper hallucinations produced on near-silent/low-energy
# audio (stock phrases the model falls back to when it has nothing
# real to transcribe), and punctuation-only output (e.g. a lone ".")
# which reduces to an empty string here and would otherwise fall
# through and get typed as a bare full stop
text_lc="$(echo "${text,,}" | sed 's/[.!?[:space:]]*$//')"
case "$text_lc" in
""|you|"thank you"|"thank you for watching"|"thanks for watching"|"thank you so much"|bye|"bye bye"|bye-bye) continue ;;
esac
# Whisper auto-capitalizes the first word of every chunk it emits,
# treating each as a new sentence. Lowercase just that first letter
# so dictated text doesn't get capitalized mid-sentence.
text="${text,}"
# Type the transcribed text
ydotool type --key-delay 1 "$text "
last_activity=$(date +%s)
done < "$FIFO"
rm -f "$FIFO" "$STREAM_PIDFILE" "$STOPFILE"
}
export -f dictation_pipeline
export MODEL LOGFILE PIDFILE STREAM_PIDFILE FIFO SILENCE_TIMEOUT CAPTURE_DEV STOPFILE ACTFILE VAD_THOLD
# Clean up on signal: kill the process group, remove PID files, exit
cleanup() {
if [ -f "$PIDFILE" ]; then
kill -- -"$(cat "$PIDFILE")" 2>/dev/null || true
pactl set-source-mute "$MIC_SOURCE" 0 2>/dev/null || true
[ -f "$TRAYPIDFILE" ] && kill "$(cat "$TRAYPIDFILE")" 2>/dev/null
rm -f "$PIDFILE" "$STREAM_PIDFILE" "$FIFO" "$STOPFILE" "$MICFILE" "$ACTFILE" "$NOTIFYIDFILE" "$TRAYPIDFILE"
fi
exit 130
}
trap cleanup TERM INT HUP
notify-send -u low -p -i audio-input-microphone -t 0 "Whisper Dictation" "Recording... ($MIC_DESC)" > "$NOTIFYIDFILE" 2>/dev/null || true
# Tray icon: yad --notification creates a legacy XEmbed tray icon. On
# Wayland this only appears in Plasma's system tray because xembedsniproxy
# bridges XEmbed to the StatusNotifierItem protocol — GDK_BACKEND=x11
# routes yad through XWayland so the XEmbed icon exists for it to proxy.
GDK_BACKEND=x11 yad --notification --image=audio-input-microphone \
--text="Whisper Dictation: Recording ($MIC_DESC)" --no-middle \
>/dev/null 2>&1 &
echo $! > "$TRAYPIDFILE"
setsid --wait bash -c 'echo $$ > "$PIDFILE"; last_activity=$(date +%s); dictation_pipeline' &
DICTATE_PID=$!
# Wait for the session leader to write the PID file before releasing the lock
for _ in $(seq 1 50); do
[ -f "$PIDFILE" ] && break
sleep 0.1
done
# Release lock so the stop toggle can acquire it while dictation runs
flock -u 9
exec 9>&-
# Clean up PID files when the session exits
wait "$DICTATE_PID" 2>/dev/null || true
[ -f "$TRAYPIDFILE" ] && kill "$(cat "$TRAYPIDFILE")" 2>/dev/null
rm -f "$PIDFILE" "$STREAM_PIDFILE" "$FIFO" "$STOPFILE" "$MICFILE" "$ACTFILE" "$NOTIFYIDFILE" "$TRAYPIDFILE"
# ──────────────────────────────────────────────────────────────────────────────
# RATIONALE
#
# setsid + process group + named pipe (FIFO)
# A pipeline like `whisper-stream | while read ...` has two processes.
# $! in bash returns the PID of the last element (the while subshell),
# not whisper-stream. Killing only the subshell leaves whisper-stream
# running as an orphan. A pkill -f fallback matching the binary name is
# fragile — it depends on the process cmdline containing a predictable
# string and could match unrelated processes.
#
# Running the pipeline inside setsid creates a new session. The session
# leader's PID doubles as the process group ID. kill -- -$PID sends the
# signal to every process in the group — whisper-stream and the while
# loop die together in one call. No orphans, no pattern matching.
#
# A named pipe (FIFO) separates whisper-stream from the while loop so
# whisper-stream's PID can be captured independently. The stop path
# SIGTERMs only whisper-stream, letting the while loop drain remaining
# transcribed text from the FIFO before exiting on EOF. If the session
# doesn't exit within 5s, the stop path falls back to killing the entire
# process group.
#
# setsid --wait
# Without --wait, the setsid parent exits immediately after forking the
# child. $! captures that short-lived parent PID, wait returns instantly,
# and the PID file is deleted while dictation is still running — the
# stop toggle would be unable to find the session.
#
# --wait keeps the setsid parent alive until the child exits, so wait
# blocks correctly and the PID file persists for the lifetime of the
# session.
#
# flock-based toggle serialization
# A toggle script invoked by a hotkey is susceptible to rapid
# double-presses: two stop invocations both reading the PID file before
# either removes it, or two start invocations both passing the "not
# running" check and spawning competing pipelines that fight over the
# microphone.
#
# A non-blocking flock on a separate lock file serializes the critical
# section (the check-and-act decision). The lock is held only during the
# decision and startup, then released before wait so the stop toggle can
# acquire it while dictation is running. A second invocation during the
# critical section gets "Already toggling" and exits.
#
# PID file poll before lock release
# The session leader writes its PID to the PID file as its first action.
# The parent polls for the file's appearance (up to 5s) before releasing
# the lock, ensuring a stop toggle can always find the PID file once the
# lock is free. Without this, a stop toggle acquiring the lock immediately
# after release could see no PID file and miss the running session.
#
# PID recycling guard
# PIDs wrap around at a kernel-defined maximum. If a session dies and the
# OS later reuses that exact PID for an unrelated process, kill -0 would
# return true and the stop path would kill the wrong process. The guard
# checks /proc/$PID/cmdline for 'dictation_pipeline' before killing. If
# the cmdline doesn't match, the PID file is treated as stale and removed
# silently.
#
# Silence-based auto-stop
# A fixed timeout (e.g. 30 minutes) cuts off mid-dictation if one talks
# that long, and wastes GPU cycles transcribing ambient noise if one
# walks away. Silence detection is more natural: if no real transcribed
# text is produced for SILENCE_TIMEOUT seconds (default 30, override via
# WHISPER_SILENCE_TIMEOUT env var), the session stops itself.
#
# read -t 5 polls whisper-stream's stdout every 5 seconds. On timeout
# (rc > 128), the silence threshold is checked. On EOF (rc == 1),
# whisper-stream has exited or crashed — the loop breaks immediately
# without spinning. Only real transcribed text (not BLANK_AUDIO or
# status messages) resets last_activity.
#
# Signal trap
# If the waiting parent receives SIGTERM, SIGINT, or SIGHUP (logout,
# session end, external kill), the cleanup trap kills the process group,
# removes the PID file, and exits with code 130 (128 + SIGINT). Without
# the trap, the session would be orphaned until the next toggle or the
# silence timeout.
#
# The trap is set before setsid so there is no gap where a signal could
# orphan the session. notify-send fires before setsid so the user sees
# "Listening..." immediately rather than after the PID file poll.
#
# Exported function
# dictation_pipeline is exported via export -f so setsid's bash -c can
# invoke it. MODEL, LOGFILE, PIDFILE, STREAM_PIDFILE, FIFO, SILENCE_TIMEOUT,
# and CAPTURE_DEV are exported alongside it so the function has access
# to them in the child shell.
#
# Microphone auto-detection
# detect_mic queries pactl for non-monitor sources that are unmuted.
# It sets the PipeWire default source so whisper-stream's -c -1 (default
# device) captures from the selected microphone. If all inputs are muted,
# it unmutes the first non-monitor source it finds. The notification
# shows which microphone was selected. Use --list-mics to see all
# available sources and their mute state.
#
# Debounce
# A state file records the timestamp of each invocation. If two
# invocations arrive within DEBOUNCE_MS (default 400ms), the second is
# silently dropped. This prevents keyboard bounce and accidental
# double-taps from spawning competing sessions or stopping a session
# that was just started. The debounce fires before the flock, so a
# bounced press does not even acquire the lock.
#
# Orphan cleanup
# Before starting a new session, any orphaned whisper-stream process
# matching the model filename is killed. On stop, after the graceful
# and process-group kill paths, a final pkill sweeps any stragglers.
# This handles cases where a previous session crashed and left
# whisper-stream running without a parent.
#
# Known limitations
# Whisper may hallucinate transcribed text from ambient noise during
# silence, resetting last_activity and preventing the silence auto-stop
# from firing. This is a model limitation — the hotkey toggle remains
# the manual fallback.
#
# ydotool failures (no display, ydotoold not running, insufficient
# permissions) cause text to be silently lost. last_activity still
# updates, so the session continues. The user would notice missing
# text and stop manually.
#
# If /proc is not mounted, the PID recycling guard's cmdline check
# fails. A live session would be treated as stale, potentially allowing
# a double start. Only relevant in broken container environments.
#
# notify-send fires before setsid, so if whisper-stream fails to start
# (e.g., audio device busy, model corrupt), the user sees "Listening..."
# followed by a silent exit within seconds. No error notification is
# sent for startup failures.
#
# A double notification ("Stopped after 30s of silence" + "Stopped")
# can appear if the silence timeout fires at the same moment as a
# hotkey press. Cosmetic only.
#
# detect_mic relies on pactl being available. If PipeWire/PulseAudio is
# not running, it falls back to -c -1 (SDL default device), which may
# not be the desired microphone. The user can override by setting the
# PipeWire default source manually before launching.
# ──────────────────────────────────────────────────────────────────────────────
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment