Skip to content

Instantly share code, notes, and snippets.

@mxmilkiib
Last active June 24, 2026 07:18
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
# 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="${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"
LOGFILE="/tmp/whisper-dictate.log"
FIFO="/tmp/whisper-dictate.fifo"
SILENCE_TIMEOUT="${WHISPER_SILENCE_TIMEOUT:-30}"
# 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 gracefully — kill whisper-stream only,
# let the while loop drain remaining transcribed text, then exit.
# 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
# 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
rm -f "$PIDFILE" "$STREAM_PIDFILE" "$FIFO"
notify-send -u low "Whisper Dictation" "Stopped"
exit 0
else
# Stale PID file (process died or PID was recycled by something else)
rm -f "$PIDFILE" "$STREAM_PIDFILE" "$FIFO"
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
# 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 1: Digital microphone capture device
# -vth 0.6: voice activity detection threshold
# --step 3000: process 3s chunks
# --length 5000: 5s audio window
# --keep 200: keep 200ms from previous step for context
# GPU (ROCm/Vulkan) is used by default with the whisper.cpp-vulkan package
whisper-stream \
-m "$MODEL" \
-t 8 \
-l en \
-c 1 \
--step 3000 \
--length 5000 \
--keep 200 \
-vth 0.6 \
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 silence threshold
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
# 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
# Type the transcribed text
ydotool type --key-delay 1 "$text "
last_activity=$(date +%s)
done < "$FIFO"
rm -f "$FIFO" "$STREAM_PIDFILE"
}
export -f dictation_pipeline
export MODEL LOGFILE PIDFILE STREAM_PIDFILE FIFO SILENCE_TIMEOUT
# Clean up on signal: kill the process group, remove PID files, exit
cleanup() {
if [ -f "$PIDFILE" ]; then
kill -- -"$(cat "$PIDFILE")" 2>/dev/null || true
rm -f "$PIDFILE" "$STREAM_PIDFILE" "$FIFO"
fi
exit 130
}
trap cleanup TERM INT HUP
notify-send -u low "Whisper Dictation" "Listening..."
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
rm -f "$PIDFILE" "$STREAM_PIDFILE" "$FIFO"
# ──────────────────────────────────────────────────────────────────────────────
# 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, and
# SILENCE_TIMEOUT are exported alongside it so the function has access
# to them in the child shell.
#
# 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.
# ──────────────────────────────────────────────────────────────────────────────
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment