Skip to content

Instantly share code, notes, and snippets.

@s1awek
Last active April 20, 2026 13:55
Show Gist options
  • Select an option

  • Save s1awek/f536beda31525abb25d19abd2fbd6c34 to your computer and use it in GitHub Desktop.

Select an option

Save s1awek/f536beda31525abb25d19abd2fbd6c34 to your computer and use it in GitHub Desktop.
Global speech-to-text hotkey for Linux Wayland (KDE Plasma, GNOME) — faster-whisper + CUDA, parallel model loading, pw-record, ydotool auto-paste. Wayland-native alternative to nerd-dictation.

Whisper dictation hotkey for Linux Wayland (KDE Plasma, GNOME)

Press a hotkey, talk, press again — your speech is transcribed with faster-whisper on CUDA and auto-pasted into the focused window. Works on Wayland where most existing dictation tools (nerd-dictation, xdotool-based scripts, whisper-input) either require X11 or fail silently.

Keywords: linux voice typing, wayland speech to text, KDE dictation hotkey, whisper global hotkey, faster-whisper dictation, nerd-dictation wayland alternative, pipewire speech recognition, ydotool whisper paste, Plasma Wayland voice input.

Tested on Ubuntu 24.04 + KDE Plasma Wayland + NVIDIA (CUDA 12). Should also work on GNOME Wayland, Hyprland, sway, and any compositor that supports system-tray icons and has ydotool working.

Why this vs. alternatives?

Tool Wayland GPU Parallel model load Global hotkey Auto-paste
this script yes CUDA yes (model loads while you talk) yes yes
nerd-dictation X11 only CPU (VOSK) n/a (streaming) yes via xdotool (X11)
whisper-input partial varies no yes via pyautogui
Speech Note yes yes no no (GUI app) manual
Plasma "Dictation" yes cloud yes depends

Killer features

1. Truly system-wide — no copy/paste dance. Focus any text field in any window (browser, editor, chat app, terminal, Slack in a Snap, your CAD tool, whatever), press the hotkey, talk, press again — the transcription appears in-place. No dedicated dictation window, no intermediate textarea, no "copy from here and paste there". The hotkey is registered at the compositor level, so it works regardless of which app is focused.

2. Clipboard as a safety net — you never lose a transcription. Even when the auto-paste fails (window lost focus mid-recording, permission issue, whatever), the text is always placed in the clipboard first via wl-copy. So:

  • Lost focus during recording? Click back into the text field and hit Ctrl+V. Done.
  • Using a clipboard history manager (Klipper, clipman, CopyQ, Pano…)? Every transcription gets its own entry in history. You can scroll back to a transcription from ten minutes ago and paste it again.
  • Multi-step workflow? Dictate a paragraph, then paste it into three different places without re-recording.

This has saved hours of re-dictation in real use — speak freely without worrying whether the target window is still active.

3. Parallel model load — transcription finishes almost instantly. Pressing the hotkey launches pw-record AND faster-whisper model loading at the same time, so by the time you stop talking the model is already warm and transcription finishes in ~1 s even for large-v3-turbo. No persistent GPU memory cost — the preloader exits after one transcription and frees VRAM.

Privacy & security

Everything runs locally. faster-whisper loads the model into your GPU's VRAM and transcribes on-device. No audio, no text, no metadata ever leaves your machine — there is no network code in this script. Works fully offline after the initial model download.

Audio files are ephemeral. The recording is written to /tmp/stt-recording.wav only for the duration of transcription, then deleted on the same line (rm -f "$TMPFILE" ... in stt.sh) — whether the transcription succeeded, returned empty, or hit an error. No leftover voice recordings for anyone to replay later.

On most modern Linux distros /tmp is a tmpfs, i.e. RAM-backed, so the audio never touches disk at all — not even for a moment. A reboot wipes everything. (Check yours with mount | grep ' /tmp '; if it says tmpfs, you're RAM-only. If it says ext4/btrfs, files do hit disk briefly but are still deleted after transcription.)

Transcribed text does live in the clipboard after a successful run — that's the "safety net" feature above. Anything in the clipboard is visible to other apps that request it (including clipboard history managers like Klipper). If you dictated something sensitive and want to purge it, either copy something else over it or run wl-copy --clear.

Logs don't contain your speech or transcriptions. /tmp/stt-events.log records only metadata (timestamps, audio file size, result text length, per-step return codes) — never the text itself.

How it works (under the hood)

  • Recording: pw-record (PipeWire native) → follows system default mic. Switching mics in KDE audio settings or pavucontrol just works — no config file to edit. (This matters: sox rec on Ubuntu is ALSA-only and silently ignores PipeWire's default source.)
  • Transcription: faster-whisper (CTranslate2 backend) with large-v3-turbo on CUDA float16. ~6 GB VRAM. Polish, English, German, ~100 other languages — anything Whisper supports.
  • Clipboard: wl-copy (wl-clipboard package).
  • Paste: ydotool key ctrl+v via the ydotoold daemon over /dev/uinput (virtual keyboard, no X11, no compositor-specific hacks).
  • Indicator: PyQt5 QSystemTrayIcon — chosen because GTK/yad tray icons render unreliably on Plasma Wayland. Red dot during recording, spinner during transcription, error flash on failure.
  • Locking: flock on /tmp/stt.lock so rapid double-presses don't race.

Files

File Purpose
stt.sh Main hotkey entry point — toggles recording, pastes result
stt-preload.py Preloads the Whisper model in parallel with recording, transcribes once, exits
stt-tray.py Qt tray icon, driven via FIFO
ydotoold.service systemd user unit for the ydotool daemon

Dependencies

System packages (Ubuntu / Debian / Mint)

One-shot install command:

sudo apt install \
    pipewire pipewire-bin \
    wl-clipboard \
    ydotool ydotoold \
    python3 python3-venv python3-pyqt5

Breakdown of what each package does and whether you probably already have it:

Package Role Usually preinstalled?
pipewire, pipewire-bin Audio server + pw-record for recording Yes on Ubuntu 22.10+, Fedora 34+, KDE Neon, most modern KDE/GNOME distros
wl-clipboard wl-copy / wl-paste for clipboard on Wayland No — needs explicit install on Ubuntu
ydotool CLI client that sends Ctrl+V to the focused window No — needs install
ydotoold Daemon that opens /dev/uinput and runs queued keystrokes No — separate package from ydotool on Ubuntu, easy to miss
python3 Python 3 interpreter Yes
python3-venv python3 -m venv support — without this the install step fails silently No on minimal Ubuntu / Debian installs (common gotcha)
python3-pyqt5 Qt bindings for the tray icon script No on most distros

Verify everything is present:

command -v pw-record wl-copy ydotool ydotoold python3 && \
    python3 -c "import PyQt5.QtWidgets" && \
    echo "all system deps OK"

NVIDIA driver + CUDA

  • A working NVIDIA driver must be installed (Ubuntu: sudo ubuntu-drivers install, or pick a specific version from Software & Updates → Additional Drivers). Verify with nvidia-smi — it should list your GPU.
  • You do NOT need to install cuda-toolkit system-wide. The faster-whisper Python package pulls in the necessary CUDA runtime libraries (nvidia-cublas-cu12, nvidia-cudnn-cu12) as wheels into the venv. stt.sh points LD_LIBRARY_PATH at them automatically.

Python packages (in a venv)

Installed in step 2 of the Install section below:

  • faster-whisper — pulls in the NVIDIA CUDA runtime wheels as transitive dependencies. ~500 MB into the venv.

Hardware

  • CUDA-capable NVIDIA GPU (tested on RTX A3000 12 GB).
  • The default model large-v3-turbo uses ~6 GB VRAM in float16. Smaller models (base, small, medium) work fine with less VRAM; set via the STT_MODEL env var.
  • CPU-only inference is possible by editing stt-preload.py (device="cpu", compute_type="int8") but will be ~20× slower — transcription of a 20 s clip may take 30 s instead of 1 s.

Install

  1. Clone the gist somewhere in your $HOME, e.g. ~/scripts/stt/. The scripts locate each other relative to their own directory — no hardcoded paths.

  2. Create the Python venv:

    cd ~/scripts/stt
    python3 -m venv stt-venv
    ./stt-venv/bin/pip install faster-whisper
    chmod +x stt.sh stt-preload.py stt-tray.py
  3. Pre-download the Whisper model (strongly recommended — otherwise the model silently downloads on first hotkey use, and you'll see nothing but a spinning icon for 5–10 minutes on a slow connection, which looks like a hang):

    ./stt-venv/bin/python3 -c "from faster_whisper import WhisperModel; WhisperModel('large-v3-turbo', device='cuda', compute_type='float16')"

    This runs the download in the foreground with a visible progress bar. Model sizes (cached under ~/.cache/huggingface/hub/):

    Model Size on disk Quality
    tiny ~75 MB rough
    base ~150 MB OK for English, weak on other languages
    small ~500 MB good for casual dictation
    medium ~1.5 GB very good
    large-v3 ~3 GB best quality, slower
    large-v3-turbo (default) ~1.6 GB ~best quality, faster than large-v3

    Repeat with a different model name if you want to try alternatives — they're all cached independently.

  4. Grant /dev/uinput access to your user so ydotoold (running as your user, not root) can create a virtual keyboard:

    sudo tee /etc/udev/rules.d/80-uinput.rules <<'EOF'
    KERNEL=="uinput", MODE="0660", TAG+="uaccess"
    EOF
    sudo udevadm control --reload && sudo udevadm trigger

    Log out and back in, then verify: ls -la /dev/uinput should show a + at the end of the permission string and getfacl /dev/uinput should list your user with rw-.

  5. Enable ydotoold as a user service:

    mkdir -p ~/.config/systemd/user
    cp ydotoold.service ~/.config/systemd/user/
    systemctl --user daemon-reload
    systemctl --user enable --now ydotoold.service

    Verify: ls -la /tmp/.ydotool_socket — should be owned by your user.

  6. Bind stt.sh to a global hotkey in KDE:

    System Settings → Shortcuts → Custom Shortcuts → Edit → New → Global Shortcut → Command/URL. Set the action to the absolute path of stt.sh.

    Recommended binding: Meta+Z — ergonomic (both keys under the left hand, reachable without moving), and in default KDE it doesn't collide with anything (unlike Meta+D which toggles "show desktop" on some setups). Pick whatever suits you, but something involving Meta is wise because Meta shortcuts rarely clash with application-level bindings.

    On GNOME: Settings → Keyboard → Custom Shortcuts. On Hyprland / sway: add a bind directive to the config.

Configuration

Environment variables — set in the hotkey command or your shell init:

Var Default Notes
STT_MODEL large-v3-turbo Any faster-whisper model: tiny, base, small, medium, large-v2, large-v3, large-v3-turbo, or a HuggingFace path
STT_LANG en ISO code: en, pl, de, fr, es, ja, … Leave empty for auto-detect

Example custom-shortcut command for Polish dictation: STT_LANG=pl /home/you/scripts/stt/stt.sh

Usage

  1. First press of the hotkey: a red-dot tray icon appears, recording starts, and the Whisper model begins loading in the background.
  2. Talk for up to 20 minutes (configurable via MAX_WAIT_SECONDS in stt-preload.py).
  3. Second press: icon switches to a spinner while the audio is transcribed. The result is copied to the clipboard (via wl-copy) and auto-pasted into the focused window (via ydotool key ctrl+v). The icon disappears on success.
  4. On failure: red error icon flashes for 3 s. Even when auto-paste fails, the text is still in the clipboard — hit Ctrl+V manually. Check /tmp/stt-events.log for what went wrong.

Multi-language tips

Whisper is multilingual by one single modellarge-v3-turbo handles ~99 languages with no extra download needed (only the .en-suffixed model variants are English-only).

The model also handles code-switching within a single utterance quite well. If you naturally mix languages (e.g. Polish sentences with English technical terms — "Ponglish"), it will usually transcribe both correctly as long as the mix is intentional and the dominant language is set via STT_LANG.

However, if you force one language (STT_LANG=pl) but speak entirely in another (English), Whisper will try to interpret the English audio as Polish, producing garbled phonetic transcriptions or partial translations.

Recommended setup for bilingual users — bind two hotkeys:

Hotkey Command Use for
Meta+Z /home/you/scripts/stt/stt.sh Your primary language (STT_LANG in the script, e.g. pl)
Meta+Shift+Z env STT_LANG=en /home/you/scripts/stt/stt.sh Dictating purely in English (emails, Slack, GitHub)

Add both via KDE System Settings → Custom Shortcuts → New → Global Shortcut → Command/URL. Same script, different language prior — cleaner output than relying on auto-detect for short utterances. Add more hotkeys for more languages (German, Spanish, etc.) following the same pattern with STT_LANG=de, STT_LANG=es, …

For maximum flexibility on unpredictable content, leave STT_LANG="" (empty) — Whisper will auto-detect per recording. Works well on longer recordings (30 s+), may misidentify the language on short/ambiguous clips.

Troubleshooting

All pipeline events log to /tmp/stt-events.log with per-step return codes. Example of a healthy run:

2026-04-20 07:34:10 start: recording
2026-04-20 07:34:56 stop: requested, rec_duration=46s
2026-04-20 07:34:57 stop: audio_size=1458220B
2026-04-20 07:34:59 transcribe: wait=2s outcome=result_file
2026-04-20 07:34:59 transcribe: OK text_len=447
2026-04-20 07:34:59 paste: wl-copy rc=0 len=447
2026-04-20 07:35:00 paste: ydotool rc=0

Other logs: /tmp/stt-preload.log (model load + transcription) and /tmp/stt-rec.log (pw-record stderr).

Common issues

  • "recording did not start" → default audio source is muted or missing. Check pactl get-default-source and your desktop's sound settings.
  • "no speech detected" → Whisper's VAD stripped everything as silence. Usually the mic gain is too low, or you spoke into a different mic than the system default. Confirm pactl get-default-source matches the mic you actually used.
  • Clipboard has the text but nothing pastesydotoold.service is not running, or your user has no access to /dev/uinput. Re-check install steps 3 and 4. systemctl --user status ydotoold should say active.
  • Recording icon never appearsstt-tray.py crashed at launch. Run it manually: mkfifo /tmp/test.fifo && python3 stt-tray.py /tmp/test.fifo media-record test and look for Python errors.
  • Transcription takes forever → check that faster-whisper is actually using CUDA. /tmp/stt-preload.log should show the model load without errors. The CPU fallback is ~20× slower.

Why a shell script, not a proper app?

Because it's ~250 lines that solves one problem well, depends on widely available tools (pw-record, wl-copy, ydotool, faster-whisper), and can be modified by anyone who reads bash. If you want it to be a proper app, fork it and go wild.

License

CC0 / public domain. Do whatever you want.

#!/usr/bin/env python3
"""Single-shot STT preloader for faster-whisper.
Loads the Whisper model (intended to run in parallel with recording),
waits for a trigger file to appear, then transcribes the given audio file
and writes the result to a result file. Exits after one transcription —
VRAM is freed automatically.
Usage:
stt-preload.py <audio_path> <trigger_path> <result_path> [rec_pid]
Intended to be launched in the background by stt.sh at the start of
recording so the model loads while the user is still dictating. When
recording stops, stt.sh creates the trigger file to signal transcription
should begin immediately.
If rec_pid is provided, the preloader monitors the recording process and
exits immediately if it dies without creating the trigger file, freeing
VRAM instead of waiting for the full timeout.
Environment:
STT_MODEL faster-whisper model name (default: large-v3-turbo)
STT_LANG ISO language code (default: en; unset for auto-detect)
"""
import os
import sys
import time
from faster_whisper import WhisperModel
if len(sys.argv) < 4:
print("Usage: stt-preload.py <audio> <trigger> <result> [rec_pid]", file=sys.stderr)
sys.exit(2)
AUDIO_PATH = sys.argv[1]
TRIGGER_PATH = sys.argv[2]
RESULT_PATH = sys.argv[3]
REC_PID = int(sys.argv[4]) if len(sys.argv) > 4 else None
MODEL = os.environ.get("STT_MODEL", "large-v3-turbo")
LANG = os.environ.get("STT_LANG", "en") or None # empty STT_LANG -> auto-detect
MAX_WAIT_SECONDS = 1200
def rec_alive():
if REC_PID is None:
return True
try:
os.kill(REC_PID, 0)
return True
except OSError:
return False
print(f"[stt-preload] Loading model {MODEL}...", flush=True)
model = WhisperModel(MODEL, device="cuda", compute_type="float16")
print("[stt-preload] Model loaded, waiting for trigger.", flush=True)
start = time.monotonic()
while not os.path.exists(TRIGGER_PATH):
if time.monotonic() - start > MAX_WAIT_SECONDS:
print("[stt-preload] Timeout waiting for trigger, exiting.", flush=True)
sys.exit(1)
if not rec_alive():
time.sleep(0.5)
if not os.path.exists(TRIGGER_PATH):
print("[stt-preload] Recording process gone, no trigger, exiting to free VRAM.", flush=True)
sys.exit(1)
break
time.sleep(0.05)
try:
os.unlink(TRIGGER_PATH)
except FileNotFoundError:
pass
for _wait in range(10):
if os.path.exists(AUDIO_PATH) and os.path.getsize(AUDIO_PATH) > 44:
break
time.sleep(0.1)
if not os.path.exists(AUDIO_PATH):
print(f"[stt-preload] Audio file not found: {AUDIO_PATH}", flush=True)
sys.exit(1)
print("[stt-preload] Transcribing...", flush=True)
segments, _ = model.transcribe(
AUDIO_PATH,
language=LANG,
beam_size=5,
vad_filter=True,
)
text = " ".join(s.text.strip() for s in segments)
with open(RESULT_PATH, "w", encoding="utf-8") as f:
f.write(text)
print("[stt-preload] Done.", flush=True)
#!/usr/bin/env python3
"""STT system-tray icon for KDE Plasma Wayland (Qt native).
Spawned by stt.sh. Reads commands from a FIFO and updates a QSystemTrayIcon
accordingly. Exits on 'quit'. This uses PyQt5 instead of yad/notify-send
because yad --notification tray icons don't render reliably on Plasma
Wayland (GTK StatusNotifierItem quirk).
Commands (one per line):
icon:NAME — change icon to the freedesktop icon NAME
tooltip:TEXT — change tooltip
quit — exit
Usage: stt-tray.py FIFO_PATH INITIAL_ICON INITIAL_TOOLTIP
"""
import os
import signal
import sys
from PyQt5.QtCore import QSocketNotifier, QTimer
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QApplication, QSystemTrayIcon
if len(sys.argv) < 4:
print("Usage: stt-tray.py FIFO ICON TOOLTIP", file=sys.stderr)
sys.exit(2)
FIFO = sys.argv[1]
initial_icon = sys.argv[2]
initial_tooltip = sys.argv[3]
app = QApplication(sys.argv)
app.setQuitOnLastWindowClosed(False)
tray = QSystemTrayIcon()
tray.setIcon(QIcon.fromTheme(initial_icon))
tray.setToolTip(initial_tooltip)
tray.show()
fd = os.open(FIFO, os.O_RDWR | os.O_NONBLOCK)
buf = b""
def on_readable():
global buf
try:
data = os.read(fd, 4096)
except OSError:
return
if not data:
return
buf += data
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
cmd = line.decode("utf-8", "replace").strip()
if not cmd:
continue
if cmd == "quit":
app.quit()
return
if cmd.startswith("icon:"):
tray.setIcon(QIcon.fromTheme(cmd[5:]))
elif cmd.startswith("tooltip:"):
tray.setToolTip(cmd[8:])
notifier = QSocketNotifier(fd, QSocketNotifier.Read)
notifier.activated.connect(on_readable)
signal.signal(signal.SIGINT, lambda *_: app.quit())
signal.signal(signal.SIGTERM, lambda *_: app.quit())
_sigtimer = QTimer()
_sigtimer.start(200)
_sigtimer.timeout.connect(lambda: None)
try:
sys.exit(app.exec_())
finally:
try:
os.close(fd)
except OSError:
pass
#!/bin/bash
# Global Speech-to-Text using faster-whisper, single-shot parallel preload.
# Works on KDE Plasma Wayland. Bind to a global hotkey to toggle recording;
# transcription is auto-pasted into the focused window.
set +m
STT_DIR="$(dirname "$(readlink -f "$0")")"
VENV="$STT_DIR/stt-venv"
PRELOAD_SCRIPT="$STT_DIR/stt-preload.py"
TRAY_SCRIPT="$STT_DIR/stt-tray.py"
TMPFILE="/tmp/stt-recording.wav"
PIDFILE="/tmp/stt-recording.pid"
LOCKFILE="/tmp/stt.lock"
PRELOAD_PIDFILE="/tmp/stt-preload.pid"
PRELOAD_LOG="/tmp/stt-preload.log"
TRIGGER_FILE="/tmp/stt-trigger"
RESULT_FILE="/tmp/stt-result"
TRAY_FIFO="/tmp/stt-tray.fifo"
TRAY_PIDFILE="/tmp/stt-tray.pid"
TRAY_MIN_VISIBLE=0.8
MODEL="${STT_MODEL:-large-v3-turbo}"
STT_LANG="${STT_LANG:-en}"
REC_LOG="/tmp/stt-rec.log"
EVENT_LOG="/tmp/stt-events.log"
MAX_LOG_LINES=1000
log_event() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$EVENT_LOG"; }
exec 9>"$LOCKFILE"
flock -n 9 || exit 0
# ---- Tray helpers (PyQt5 QSystemTrayIcon) ----
tray_alive() {
[[ -f "$TRAY_PIDFILE" ]] && kill -0 "$(cat "$TRAY_PIDFILE")" 2>/dev/null
}
tray_cmd() {
[[ -p "$TRAY_FIFO" ]] || return 0
( printf '%s\n' "$1" > "$TRAY_FIFO" ) 2>/dev/null &
disown 2>/dev/null || true
}
tray_start() {
local icon="$1" tooltip="$2"
if tray_alive; then
tray_cmd "icon:$icon"
tray_cmd "tooltip:$tooltip"
return
fi
rm -f "$TRAY_PIDFILE" "$TRAY_FIFO"
mkfifo "$TRAY_FIFO"
date +%s.%N > "${TRAY_PIDFILE}.start"
nohup /usr/bin/python3 "$TRAY_SCRIPT" "$TRAY_FIFO" "$icon" "$tooltip" >/dev/null 2>&1 9>&- &
echo $! > "$TRAY_PIDFILE"
disown
}
tray_stop() {
if [[ -f "${TRAY_PIDFILE}.start" ]]; then
local started elapsed remaining
started=$(cat "${TRAY_PIDFILE}.start" 2>/dev/null)
if [[ -n "$started" ]]; then
elapsed=$(awk -v s="$started" -v now="${EPOCHREALTIME:-$(date +%s.%N)}" 'BEGIN{printf "%.3f", now - s}')
remaining=$(awk -v e="$elapsed" -v m="$TRAY_MIN_VISIBLE" 'BEGIN{r=m-e; printf "%.3f", (r>0)?r:0}')
if awk -v r="$remaining" 'BEGIN{exit !(r>0)}'; then
sleep "$remaining"
fi
fi
fi
if tray_alive; then
tray_cmd "quit"
for _ in 1 2 3 4 5 6 7 8; do
tray_alive || break
sleep 0.05
done
tray_alive && kill "$(cat "$TRAY_PIDFILE")" 2>/dev/null
fi
rm -f "$TRAY_PIDFILE" "${TRAY_PIDFILE}.start" "$TRAY_FIFO"
}
tray_error_flash() {
local tooltip="$1" seconds="${2:-3}"
tray_start "dialog-error" "$tooltip"
( sleep "$seconds"; tray_stop ) >/dev/null 2>&1 &
disown
}
# ---- Paste & logging ----
paste_text() {
local clip="/tmp/stt-clipboard.txt"
local wl_rc yd_rc yd_err
printf '%s' "$1" > "$clip"
wl-copy -n < "$clip" 1>/dev/null 2>>"$EVENT_LOG" 9>&-
wl_rc=$?
rm -f "$clip"
log_event "paste: wl-copy rc=$wl_rc len=${#1}"
sleep 0.5
yd_err=$(ydotool key --delay 200 ctrl+v 2>&1 >/dev/null)
yd_rc=$?
log_event "paste: ydotool rc=$yd_rc${yd_err:+ err=$yd_err}"
}
trim_log() { tail -n "$MAX_LOG_LINES" "$1" 2>/dev/null > "$1.tmp" && mv "$1.tmp" "$1" 2>/dev/null || true; }
# ---- Main toggle ----
if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
# STOP + transcribe
rec_start=$(stat -c %Y "$PIDFILE" 2>/dev/null || echo 0)
rec_dur=$(( $(date +%s) - rec_start ))
log_event "stop: requested, rec_duration=${rec_dur}s"
kill "$(cat "$PIDFILE")" 2>/dev/null || true
rm -f "$PIDFILE"
sleep 0.3
if [[ ! -f "$TMPFILE" ]]; then
[[ -f "$PRELOAD_PIDFILE" ]] && kill "$(cat "$PRELOAD_PIDFILE")" 2>/dev/null
rm -f "$PRELOAD_PIDFILE" "$TRIGGER_FILE" "$RESULT_FILE"
log_event "ERROR: no audio file after stop (rec failed)"
tray_error_flash "STT: recording failed (log: $REC_LOG)"
exit 1
fi
audio_size=$(stat -c %s "$TMPFILE" 2>/dev/null || echo 0)
log_event "stop: audio_size=${audio_size}B"
touch "$TRIGGER_FILE"
sync
tray_start "view-refresh" "STT: transcribing..."
preload_pid=""
[[ -f "$PRELOAD_PIDFILE" ]] && preload_pid=$(cat "$PRELOAD_PIDFILE")
wait_start=$(date +%s)
wait_outcome="result_file"
for i in $(seq 1 6000); do # max ~600s
[[ -f "$RESULT_FILE" ]] && break
if [[ -n "$preload_pid" ]] && ! kill -0 "$preload_pid" 2>/dev/null; then
wait_outcome="preloader_died"
break
fi
sleep 0.1
done
[[ -f "$RESULT_FILE" ]] || wait_outcome="${wait_outcome%_file}_timeout"
wait_dur=$(( $(date +%s) - wait_start ))
log_event "transcribe: wait=${wait_dur}s outcome=$wait_outcome"
text=""
[[ -f "$RESULT_FILE" ]] && text=$(cat "$RESULT_FILE")
rm -f "$TMPFILE" "$RESULT_FILE" "$TRIGGER_FILE" "$PRELOAD_PIDFILE"
if [[ -n "$text" ]]; then
log_event "transcribe: OK text_len=${#text}"
tray_stop
paste_text "$text"
else
log_event "ERROR: empty result (outcome=$wait_outcome)"
tray_error_flash "STT: no speech detected (log: $PRELOAD_LOG)"
fi
else
# START recording + launch preloader in parallel
rm -f "$TRIGGER_FILE" "$RESULT_FILE" "$TMPFILE"
if [[ -f "$PRELOAD_PIDFILE" ]]; then
old_pid=$(cat "$PRELOAD_PIDFILE")
kill -0 "$old_pid" 2>/dev/null && kill "$old_pid" 2>/dev/null
rm -f "$PRELOAD_PIDFILE"
fi
trim_log "$REC_LOG"
trim_log "$PRELOAD_LOG"
trim_log "$EVENT_LOG"
printf '\n--- %s ---\n' "$(date '+%Y-%m-%d %H:%M:%S')" >> "$REC_LOG"
printf '\n--- %s ---\n' "$(date '+%Y-%m-%d %H:%M:%S')" >> "$PRELOAD_LOG"
log_event "start: recording"
tray_start "media-record" "STT: recording..."
# pw-record (PipeWire native) — follows the system default source.
pw-record --rate=16000 --channels=1 --format=s16 "$TMPFILE" 2>>"$REC_LOG" 9>&- &
rec_pid=$!
echo $rec_pid > "$PIDFILE"
# Verify recording actually started — catch busy-device / no-mic failures
# before the user dictates into a dead process.
sleep 0.5
if ! kill -0 $rec_pid 2>/dev/null && [[ ! -s "$TMPFILE" ]]; then
log_event "ERROR: rec died on startup (device busy / no mic?)"
rm -f "$PIDFILE" "$TMPFILE"
tray_error_flash "STT: recording did not start (log: $REC_LOG)"
exit 1
fi
# CUDA libs shipped with faster-whisper wheels; glob to survive Python version bumps.
for p in "$VENV"/lib/python*/site-packages/nvidia/cublas/lib "$VENV"/lib/python*/site-packages/nvidia/cudnn/lib; do
[[ -d "$p" ]] && LD_LIBRARY_PATH="$p:${LD_LIBRARY_PATH:-}"
done
export LD_LIBRARY_PATH
export STT_MODEL="$MODEL"
export STT_LANG="$STT_LANG"
rec_pid=$(cat "$PIDFILE")
nohup "$VENV/bin/python3" "$PRELOAD_SCRIPT" "$TMPFILE" "$TRIGGER_FILE" "$RESULT_FILE" "$rec_pid" >> "$PRELOAD_LOG" 2>&1 9>&- &
echo $! > "$PRELOAD_PIDFILE"
disown
fi
[Unit]
Description=ydotool daemon (virtual input for Wayland)
[Service]
Type=simple
ExecStart=/usr/bin/ydotoold
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment