Last active
June 24, 2026 07:18
-
-
Save mxmilkiib/916f048882ee035c2cab242b14f89e63 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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