Last active
August 19, 2026 12:39
-
-
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 | |
| # 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