12 Commits
Author SHA1 Message Date
Laan Tungir 0dafd869d7 v0.0.9 - Fix increment script to auto-commit dirty worktree before version bump 2026-03-27 09:43:19 -04:00
Laan Tungir 4d0b638f7d prep: Fix increment script to auto-commit dirty worktree before version bump 2026-03-27 09:43:19 -04:00
Laan Tungir e6de105478 v0.0.8 - Implement voice commands tab with key combo capture and runtime trigger processing 2026-03-26 22:40:05 -04:00
Laan Tungir bc4fc50c7d Implement voice commands tab with key combo capture and runtime trigger processing 2026-03-26 22:39:59 -04:00
Laan Tungir e1cfd7fc98 v0.0.7 - Add tabbed Whisper settings page with persisted decode params and live runtime updates 2026-03-26 21:51:07 -04:00
Laan Tungir e83da30048 Add tabbed Whisper settings page with persisted decode params and live runtime updates 2026-03-26 21:51:02 -04:00
Laan Tungir 4dbd14f423 v0.0.6 - Fix doubled transcript output by skipping autotype when the app window has focus 2026-03-26 20:52:19 -04:00
Laan Tungir 652d775c8c Prevent duplicate self-injection by disabling autotype when voice_linux window is focused 2026-03-26 20:52:19 -04:00
Laan Tungir 6c91e0650a v0.0.5 - Release unified GUI startup flow with model selector and download automation 2026-03-26 20:41:23 -04:00
Laan Tungir 5889c1f1e1 Unify voice_linux into single GUI binary with startup CPU/GPU model selection and multi-model download support 2026-03-26 20:41:14 -04:00
Laan Tungir 402a7757cb v0.0.4 - Finalize NVIDIA GPU enablement guidance and rollout documentation 2026-03-26 18:53:29 -04:00
Laan Tungir 48fddfe1c6 Document NVIDIA GPU passthrough/CUDA rollout and update runtime GPU config path 2026-03-26 18:53:26 -04:00
23 changed files with 3033 additions and 193 deletions
+7 -26
View File
@@ -17,43 +17,24 @@ LIBS += -lwhisper -lggml
endif
SRC := \
./src/main.c \
./src/gui_main.c \
./src/config.c \
./src/hotkey.c \
./src/audio.c \
./src/transcribe.c \
./src/typer.c
./src/typer.c \
./src/voice_cmd.c
OBJ := $(SRC:.c=.o)
TARGET := ./voice_linux
GUI_TARGET := ./voice_linux_gui
.PHONY: all clean run run-gui cpu gpu
.PHONY: all clean run
all: $(TARGET) $(GUI_TARGET)
all: $(TARGET)
$(TARGET): $(OBJ)
$(CC) $(OBJ) -o $@ $(LDFLAGS) $(LIBS)
./src/%.o: ./src/%.c
$(CC) $(CFLAGS) -c $< -o $@
$(GUI_TARGET): ./src/gui_main.c ./src/config.c ./src/audio.c ./src/transcribe.c ./src/typer.c
$(TARGET): $(SRC)
$(CC) $(CFLAGS) $(GTK_CFLAGS) $^ -o $@ $(LDFLAGS) $(LIBS) $(GTK_LIBS)
cpu:
$(MAKE) clean
$(MAKE) WITH_WHISPER=1
gpu:
$(MAKE) clean
$(MAKE) WITH_WHISPER=1
run: $(TARGET)
./voice_linux
run-gui: $(GUI_TARGET)
./voice_linux_gui
clean:
rm -f $(OBJ) $(TARGET) $(GUI_TARGET)
rm -f $(TARGET)
+32 -34
View File
@@ -9,58 +9,56 @@ Local hotkey dictation for Linux/X11 using:
```bash
sudo apt update
sudo apt install -y build-essential cmake git libpulse-dev libx11-dev libxtst-dev
```
## Build whisper.cpp
```bash
mkdir -p ./vendor
cd ./vendor
git clone https://github.com/ggerganov/whisper.cpp.git
cd ./whisper.cpp
cmake -B build -DWHISPER_CUDA=ON
cmake --build build -j"$(nproc)"
```
If CUDA is not ready yet, you can still build whisper.cpp CPU-only by omitting `-DWHISPER_CUDA=ON`.
## Download a model
```bash
mkdir -p ./models
wget -O ./models/ggml-base.en.bin \
https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin
sudo apt install -y build-essential cmake git wget libpulse-dev libx11-dev libxtst-dev libgtk-3-dev pkg-config
```
## Build app
Use [`build.sh`](build.sh):
```bash
# builds CLI + GUI by default
# CPU whisper build
bash ./build.sh
# optional switches:
WHISPER_CUDA=1 bash ./build.sh # enable CUDA whisper.cpp build
BUILD_GUI=0 bash ./build.sh # build CLI only
WITH_WHISPER=0 bash ./build.sh # build without whisper integration
# CUDA whisper build
WHISPER_CUDA=1 bash ./build.sh
# build without whisper integration
WITH_WHISPER=0 bash ./build.sh
```
`./voice_linux` is the single app binary and always launches the GTK window.
## Download whisper models
Download all supported test models:
```bash
bash ./scripts/download_models.sh
```
This fetches:
- `ggml-tiny.en.bin`
- `ggml-base.en.bin`
- `ggml-small.en.bin`
- `ggml-medium.en.bin`
- `ggml-large-v3-turbo.bin`
- `ggml-large-v3.bin`
## Run
```bash
# CLI hotkey mode (uses ./config.ini by default)
./voice_linux
# GTK3 window mode (uses ./config.ini by default)
./voice_linux_gui
```
On startup, the app opens a dialog where you choose:
- CPU or GPU mode
- Whisper model from `./models/`
Default hotkey: `Ctrl+Alt+V`
- Press once to **start** recording
- Press again to **stop** recording, transcribe, and type into the focused window
If you see `samples=0`, that was an immediate start/stop event.
If you see `[stt] [BLANK_AUDIO]`, recording worked but audio energy was near silent.
Check your input source with:
@@ -68,7 +66,7 @@ Check your input source with:
pactl list short sources
```
Then set `audio_device=` in `./config.ini` to the source you want.
Then set `audio_device=` in [`config.ini`](config.ini).
## Versioning workflow
+35 -68
View File
@@ -6,7 +6,6 @@ cd "$ROOT_DIR"
WITH_WHISPER="${WITH_WHISPER:-1}"
WHISPER_CUDA="${WHISPER_CUDA:-0}"
BUILD_GUI="${BUILD_GUI:-1}"
JOBS="${JOBS:-$(nproc)}"
WHISPER_DIR="./vendor/whisper.cpp"
@@ -33,9 +32,24 @@ LDFLAGS_COMMON=(
)
echo "[build] root: $ROOT_DIR"
echo "[build] WITH_WHISPER=$WITH_WHISPER WHISPER_CUDA=$WHISPER_CUDA BUILD_GUI=$BUILD_GUI JOBS=$JOBS"
echo "[build] WITH_WHISPER=$WITH_WHISPER WHISPER_CUDA=$WHISPER_CUDA JOBS=$JOBS"
need_cmd gcc
need_cmd pkg-config
if ! pkg-config --exists gtk+-3.0; then
echo "[build] error: gtk+-3.0 dev files not found" >&2
exit 2
fi
GTK_CFLAGS_STR="$(pkg-config --cflags gtk+-3.0 2>/dev/null || true)"
if [[ -z "$GTK_CFLAGS_STR" ]]; then
echo "[build] error: gtk+-3.0 cflags are unavailable" >&2
exit 2
fi
if ! printf '#include <gtk/gtk.h>\n' | gcc -x c -fsyntax-only $GTK_CFLAGS_STR - >/dev/null 2>&1; then
echo "[build] error: gtk headers are not usable (gtk/gtk.h check failed)" >&2
exit 2
fi
if [[ "$WITH_WHISPER" == "1" ]]; then
if ! have_cmd cmake; then
@@ -58,28 +72,7 @@ if [[ "$WITH_WHISPER" == "1" ]]; then
fi
fi
if [[ "$BUILD_GUI" == "1" ]]; then
if ! have_cmd pkg-config; then
echo "[build] warning: pkg-config not found; forcing BUILD_GUI=0" >&2
BUILD_GUI=0
elif ! pkg-config --exists gtk+-3.0; then
echo "[build] warning: gtk+-3.0 dev files not found; forcing BUILD_GUI=0" >&2
BUILD_GUI=0
else
GTK_CFLAGS_STR="$(pkg-config --cflags gtk+-3.0 2>/dev/null || true)"
if [[ -z "$GTK_CFLAGS_STR" ]]; then
echo "[build] warning: gtk+-3.0 cflags are unavailable; forcing BUILD_GUI=0" >&2
BUILD_GUI=0
else
if ! printf '#include <gtk/gtk.h>\n' | gcc -x c -fsyntax-only $GTK_CFLAGS_STR - >/dev/null 2>&1; then
echo "[build] warning: gtk headers are not usable (gtk/gtk.h check failed); forcing BUILD_GUI=0" >&2
BUILD_GUI=0
fi
fi
fi
fi
echo "[build] effective: WITH_WHISPER=$WITH_WHISPER BUILD_GUI=$BUILD_GUI"
echo "[build] effective: WITH_WHISPER=$WITH_WHISPER"
if [[ "$WITH_WHISPER" == "1" ]]; then
if [[ ! -d "$WHISPER_DIR" ]]; then
@@ -90,7 +83,7 @@ if [[ "$WITH_WHISPER" == "1" ]]; then
echo "[build] configuring whisper.cpp..."
if [[ "$WHISPER_CUDA" == "1" ]]; then
cmake -S "$WHISPER_DIR" -B "$WHISPER_DIR/build" -DWHISPER_CUDA=ON
cmake -S "$WHISPER_DIR" -B "$WHISPER_DIR/build" -DGGML_CUDA=ON
else
cmake -S "$WHISPER_DIR" -B "$WHISPER_DIR/build"
fi
@@ -99,26 +92,31 @@ if [[ "$WITH_WHISPER" == "1" ]]; then
cmake --build "$WHISPER_DIR/build" -j"$JOBS"
mkdir -p ./models
if [[ ! -f "$MODEL_PATH" ]]; then
if [[ -x ./scripts/download_models.sh ]]; then
echo "[build] ensuring whisper models are downloaded..."
bash ./scripts/download_models.sh
elif [[ ! -f "$MODEL_PATH" ]]; then
echo "[build] downloading model $MODEL_PATH..."
wget -O "$MODEL_PATH" \
https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin
fi
fi
mkdir -p ./src
echo "[build] building GTK3 app binary..."
echo "[build] building CLI binary..."
CLI_CFLAGS=("${CFLAGS_COMMON[@]}")
CLI_LDFLAGS=("${LDFLAGS_COMMON[@]}")
read -r -a GTK_CFLAGS <<< "$(pkg-config --cflags gtk+-3.0)"
read -r -a GTK_LIBS <<< "$(pkg-config --libs gtk+-3.0)"
APP_CFLAGS=("${CFLAGS_COMMON[@]}" "${GTK_CFLAGS[@]}")
APP_LDFLAGS=("${LDFLAGS_COMMON[@]}" "${GTK_LIBS[@]}")
if [[ "$WITH_WHISPER" == "1" ]]; then
CLI_CFLAGS+=(
APP_CFLAGS+=(
-DWITH_WHISPER=1
-isystem "$WHISPER_DIR/include"
-isystem "$WHISPER_DIR/ggml/include"
)
CLI_LDFLAGS+=(
APP_LDFLAGS+=(
-L"$WHISPER_DIR/build/src"
-L"$WHISPER_DIR/build/ggml/src"
-Wl,-rpath,"$WHISPER_DIR/build/src"
@@ -127,41 +125,10 @@ if [[ "$WITH_WHISPER" == "1" ]]; then
)
fi
gcc "${CLI_CFLAGS[@]}" \
./src/main.c ./src/config.c ./src/hotkey.c ./src/audio.c ./src/transcribe.c ./src/typer.c \
gcc "${APP_CFLAGS[@]}" \
./src/gui_main.c ./src/config.c ./src/audio.c ./src/transcribe.c ./src/typer.c ./src/voice_cmd.c \
-o ./voice_linux \
"${CLI_LDFLAGS[@]}"
"${APP_LDFLAGS[@]}"
if [[ "$BUILD_GUI" == "1" ]]; then
echo "[build] building GTK3 GUI binary..."
read -r -a GTK_CFLAGS <<< "$(pkg-config --cflags gtk+-3.0)"
read -r -a GTK_LIBS <<< "$(pkg-config --libs gtk+-3.0)"
GUI_CFLAGS=("${CFLAGS_COMMON[@]}" "${GTK_CFLAGS[@]}")
GUI_LDFLAGS=("${LDFLAGS_COMMON[@]}" "${GTK_LIBS[@]}")
if [[ "$WITH_WHISPER" == "1" ]]; then
GUI_CFLAGS+=(
-DWITH_WHISPER=1
-isystem "$WHISPER_DIR/include"
-isystem "$WHISPER_DIR/ggml/include"
)
GUI_LDFLAGS+=(
-L"$WHISPER_DIR/build/src"
-L"$WHISPER_DIR/build/ggml/src"
-Wl,-rpath,"$WHISPER_DIR/build/src"
-Wl,-rpath,"$WHISPER_DIR/build/ggml/src"
-lwhisper -lggml
)
fi
gcc "${GUI_CFLAGS[@]}" \
./src/gui_main.c ./src/config.c ./src/audio.c ./src/transcribe.c ./src/typer.c \
-o ./voice_linux_gui \
"${GUI_LDFLAGS[@]}"
fi
echo "[build] done: ./voice_linux (and ./voice_linux_gui if BUILD_GUI=1)"
echo "[build] run CLI: ./voice_linux"
echo "[build] run GUI: ./voice_linux_gui"
echo "[build] done: ./voice_linux"
echo "[build] run: ./voice_linux"
+38 -5
View File
@@ -2,7 +2,8 @@
# Hotkey format supports Ctrl, Alt, Shift, Super + final key symbol
# Example: Ctrl+Alt+V
model_path=./models/ggml-base.en.bin
model_path=./models/ggml-medium.en.bin
use_gpu=1
audio_device=alsa_input.usb-Shure_Inc_Shure_MV6_MV6_5-ed2d37fa14d3e65d8d477cacf910d93c-01.mono-fallback
sample_rate=16000
language=en
@@ -10,21 +11,53 @@ hotkey=Ctrl+Alt+V
type_delay_us=8000
max_record_seconds=30
# always-on VAD controls
always_on=1
always_on=0
# legacy window size (unused by new VAD pipeline, kept for compatibility)
always_on_window_ms=1400
# live level threshold (0.0 - 1.0)
vad_peak_threshold=0.006
vad_peak_threshold=0.0460
# level must stay above threshold this long before speech starts
vad_trigger_ms=100
vad_trigger_ms=153
# level must stay below threshold this long before speech ends
vad_release_ms=300
# include this much pre-trigger audio in transcript segment
vad_preroll_ms=500
# discard segments shorter than this
vad_min_speech_ms=250
vad_min_speech_ms=692
# hard cap for one speech segment
vad_max_speech_ms=15000
autotype_enabled=1
filter_bracketed_tags=0
# whisper decode controls
whisper_sampling_strategy=0
whisper_n_max_text_ctx=16384
whisper_offset_ms=0
whisper_duration_ms=0
whisper_translate=0
whisper_detect_language=0
whisper_no_context=1
whisper_no_timestamps=1
whisper_single_segment=1
whisper_token_timestamps=1
whisper_thold_pt=0.0100
whisper_thold_ptsum=0.0100
whisper_max_len=0
whisper_split_on_word=0
whisper_max_tokens=0
whisper_audio_ctx=0
whisper_tdrz_enable=0
whisper_suppress_blank=1
whisper_suppress_nst=1
whisper_temperature=0.0000
whisper_max_initial_ts=1.0000
whisper_length_penalty=-1.0000
whisper_temperature_inc=0.2000
whisper_entropy_thold=2.4000
whisper_logprob_thold=-1.0000
whisper_no_speech_thold=0.6000
whisper_greedy_best_of=1
whisper_beam_size=5
whisper_beam_patience=-1.0000
+34 -5
View File
@@ -37,6 +37,10 @@ EXAMPLES:
./increment_and_push.sh -m "Add live debug indicators"
./increment_and_push.sh -M "Breaking config format update"
./increment_and_push.sh -r -m "Release minor update"
NOTES:
- If the working tree is dirty, the script auto-commits pending changes first
as: prep: <commit message>
EOF
}
@@ -47,11 +51,36 @@ check_git_repo() {
fi
}
require_clean_worktree() {
if ! git -C "$ROOT_DIR" diff --quiet || ! git -C "$ROOT_DIR" diff --cached --quiet; then
print_error "Working tree is not clean. Commit or stash changes before running this script."
exit 1
has_pending_changes() {
if ! git -C "$ROOT_DIR" diff --quiet; then
return 0
fi
if ! git -C "$ROOT_DIR" diff --cached --quiet; then
return 0
fi
if [[ -n "$(git -C "$ROOT_DIR" ls-files --others --exclude-standard)" ]]; then
return 0
fi
return 1
}
auto_commit_pending_changes() {
local message="$1"
if ! has_pending_changes; then
return 0
fi
print_warning "Working tree is dirty. Auto-committing pending changes before version bump."
git -C "$ROOT_DIR" add -A
if git -C "$ROOT_DIR" diff --cached --quiet; then
print_warning "No staged changes after auto-add; continuing."
return 0
fi
git -C "$ROOT_DIR" commit -m "prep: ${message}" >/dev/null
print_success "Committed pending changes: prep: ${message}"
}
latest_version_tag() {
@@ -221,7 +250,7 @@ fi
print_status "voice_linux increment and push"
check_git_repo
require_clean_worktree
auto_commit_pending_changes "$COMMIT_MESSAGE"
LATEST_TAG="$(latest_version_tag || true)"
if [[ -z "$LATEST_TAG" ]]; then
+240
View File
@@ -0,0 +1,240 @@
# GPU Enablement Plan — voice_linux on NVIDIA GTX 1080 Ti
## Summary
Enable CUDA-accelerated whisper.cpp inference for the voice_linux project by passing the GTX 1080 Ti through to the Qubes `ai` **StandaloneVM**, installing NVIDIA drivers and CUDA toolkit, and rebuilding the application with `WHISPER_CUDA=1`.
## Current State
The codebase is **already GPU-ready**:
- [`build.sh`](../build.sh) accepts `WHISPER_CUDA=1` to build whisper.cpp with CUDA
- [`src/transcribe.c:22`](../src/transcribe.c:22) sets `cparams.use_gpu` from the transcribe params
- [`src/main.c:77`](../src/main.c:77) supports `--gpu` / `--cpu` CLI flags
- [`src/transcribe.h:9`](../src/transcribe.h:9) has `use_gpu` in the params struct
What's missing is the **hardware and driver stack** underneath.
## Architecture
```mermaid
graph TD
A[dom0 - Xen Hypervisor] -->|PCI passthrough| B[ai StandaloneVM]
B --> C[NVIDIA Driver 550.x]
C --> D[CUDA Toolkit 12.6]
D --> E[whisper.cpp built with WHISPER_CUDA=ON]
E --> F[voice_linux --gpu]
style A fill:#f9f,stroke:#333
style F fill:#9f9,stroke:#333
```
## Phase 0: GPU Passthrough in dom0
> **All commands in this phase run in a dom0 terminal.**
### Step 0.1 — Hide the GTX 1080 Ti from dom0
Edit `/etc/default/grub` in dom0 and add `rd.qubes.hide_pci=65:00.0,65:00.1` to the end of the **first** `GRUB_CMDLINE_LINUX` line (inside the quotes):
```bash
sudo nano /etc/default/grub
```
The line should look like:
```
GRUB_CMDLINE_LINUX="...existing options... rd.qubes.hide_pci=65:00.0,65:00.1"
```
The BDF addresses `65:00.0` (GPU) and `65:00.1` (HDMI audio) come from the `lspci` output documented in [`plans/architecture.md:97`](../plans/architecture.md:97).
### Step 0.2 — Regenerate GRUB and reboot
```bash
sudo grub2-mkconfig -o /boot/efi/EFI/qubes/grub.cfg
# If that path doesn't exist: sudo grub2-mkconfig -o /boot/grub2/grub.cfg
sudo reboot
```
### Step 0.3 — Verify GPU is assignable
After reboot, in dom0:
```bash
xl pci-assignable-list
```
Expected output should include:
```
0000:65:00.0
0000:65:00.1
```
If these don't appear, check IOMMU grouping with `xl pci-assignable-list -l` and verify the GRUB change took effect with `cat /proc/cmdline`.
### Step 0.4 — Attach GPU to the ai StandaloneVM
```bash
qvm-pci attach ai dom0:65_00.0 --persistent -o permissive=true
qvm-pci attach ai dom0:65_00.1 --persistent -o permissive=true
```
The `--persistent` flag ensures the GPU is attached on every boot. The `permissive=true` option is needed for NVIDIA GPUs in Qubes.
### Step 0.5 — Persistence model (already satisfied)
Your `ai` VM is already a **StandaloneVM**, so driver and CUDA installs persist normally across reboots.
No template/bind-dirs workaround is required.
## Phase 1: NVIDIA Driver Installation
> **All commands from here run inside the ai StandaloneVM.**
### Step 1.1 — Install build prerequisites
```bash
sudo apt update
sudo apt install -y build-essential linux-headers-$(uname -r)
```
### Step 1.2 — Install NVIDIA driver
For the GTX 1080 Ti (Pascal/GP102, compute capability 6.1), use the 550.x branch:
```bash
wget https://us.download.nvidia.com/XFree86/Linux-x86_64/550.127.05/NVIDIA-Linux-x86_64-550.127.05.run
chmod +x NVIDIA-Linux-x86_64-550.127.05.run
sudo ./NVIDIA-Linux-x86_64-550.127.05.run --no-opengl-files --dkms
```
> **Critical**: `--no-opengl-files` prevents replacing the VM's display GL stack. We only need CUDA compute, not display rendering.
### Step 1.3 — Verify driver
```bash
nvidia-smi
```
Expected: Shows GTX 1080 Ti with 11GB VRAM and driver version 550.127.05.
## Phase 2: CUDA Toolkit Installation
### Step 2.1 — Install CUDA toolkit
```bash
wget https://developer.download.nvidia.com/compute/cuda/repos/debian12/x86_64/cuda-keyring_1.1-1_all.deb
sudo dpkg -i cuda-keyring_1.1-1_all.deb
sudo apt update
sudo apt install -y cuda-toolkit-12-6
```
### Step 2.2 — Configure environment
Add to `~/.bashrc`:
```bash
echo 'export PATH=/usr/local/cuda/bin:$PATH' >> ~/.bashrc
echo 'export LD_LIBRARY_PATH=/usr/local/cuda/lib64:$LD_LIBRARY_PATH' >> ~/.bashrc
source ~/.bashrc
```
### Step 2.3 — Verify CUDA
```bash
nvcc --version
```
Expected: CUDA 12.6.
## Phase 3: Rebuild with CUDA
### Step 3.1 — Clean and rebuild
From the project directory, run:
```bash
WHISPER_CUDA=1 bash ./build.sh
```
This will:
1. Clone whisper.cpp into `vendor/whisper.cpp` (if not already present)
2. Run `cmake -DWHISPER_CUDA=ON` to configure whisper.cpp with CUDA support
3. Build whisper.cpp with CUDA kernels
4. Download the `ggml-base.en.bin` model (if not present)
5. Compile voice_linux linking against the CUDA-enabled whisper library
### Step 3.2 — Verify CUDA linkage
```bash
ldd ./voice_linux | grep -i cuda
```
Should show linkage to CUDA libraries (indirectly through libwhisper).
Also check:
```bash
ldd ./vendor/whisper.cpp/build/src/libwhisper.so | grep -i cuda
```
Should show `libcudart.so`, `libcublas.so`, etc.
## Phase 4: Run with GPU
### Step 4.1 — Launch with GPU flag
```bash
./voice_linux --gpu
```
The startup log at [`src/main.c:119`](../src/main.c:119) will print `gpu: on` confirming GPU mode. Watch for whisper.cpp log lines mentioning CUDA device initialization.
### Step 4.2 — Verify GPU utilization
In a separate terminal while transcribing:
```bash
nvidia-smi
```
Should show `voice_linux` process using GPU memory.
### Step 4.3 — Optional: Try a larger model
With 11GB VRAM on the 1080 Ti, you can comfortably run `medium.en` for much better accuracy:
```bash
cd models/
wget https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.en.bin
```
Then update [`config.ini`](../config.ini) line 5:
```ini
model_path=./models/ggml-medium.en.bin
```
| Model | VRAM Usage | Speed (10s audio) | Accuracy |
|-------|-----------|-------------------|----------|
| base.en | ~1GB | <1s | Good |
| medium.en | ~5GB | ~2-3s | Excellent |
| large-v3 | ~10GB | ~4-6s | Best |
## Troubleshooting
| Problem | Solution |
|---------|----------|
| `xl pci-assignable-list` empty | Check GRUB cmdline with `cat /proc/cmdline`, verify `rd.qubes.hide_pci` is present |
| `nvidia-smi` not found | Driver install failed; check `dmesg` for NVIDIA errors |
| `nvcc` not found | CUDA PATH not set; run `source ~/.bashrc` or check install |
| whisper.cpp cmake fails with CUDA | Ensure `nvcc` is in PATH before running `build.sh` |
| `voice_linux` crashes on `--gpu` | Run without `--gpu` first to verify CPU mode works, then check CUDA libs with `ldd` |
| GPU passthrough causes VM crash | Try without `permissive=true` first, or check IOMMU groups in dom0 |
## No Code Changes Required
The existing codebase handles everything:
- [`build.sh:8`](../build.sh:8) — `WHISPER_CUDA` env var controls CUDA build
- [`build.sh:92-93`](../build.sh:92) — passes `-DWHISPER_CUDA=ON` to cmake
- [`src/transcribe.c:21-22`](../src/transcribe.c:21) — `whisper_context_default_params()` + `use_gpu` flag
- [`src/main.c:77-78`](../src/main.c:77) — `--gpu` / `--cpu` CLI argument parsing
The only change is the build command: `WHISPER_CUDA=1 bash ./build.sh` instead of `bash ./build.sh`.
+225
View File
@@ -0,0 +1,225 @@
# GPU Passthrough in Qubes OS — Summary & Lessons Learned
## What We Did
Passed an NVIDIA GTX 1080 Ti (BDF `65:00.0` / `65:00.1`) through from Qubes dom0 to a StandaloneVM called `ai`, installed NVIDIA drivers and CUDA toolkit, and rebuilt whisper.cpp with CUDA support for GPU-accelerated speech-to-text.
## Environment
| Component | Detail |
|-----------|--------|
| Host OS | Qubes OS with Xen hypervisor |
| dom0 display GPU | AMD (stays in dom0) |
| Passthrough GPU | NVIDIA GeForce GTX 1080 Ti (Pascal/GP102, 11GB VRAM, compute 6.1) |
| Second NVIDIA GPU | RTX 3050 6GB at BDF `17:00.0` (not used for this project) |
| Target VM | `ai` — originally an AppVM, converted to StandaloneVM |
| VM OS | Debian 13 (bookworm/trixie) with XFCE |
| NVIDIA driver | 550.127.05 |
| CUDA toolkit | 12.6 |
## Timeline of Steps
### Phase 0: dom0 GPU Passthrough
1. **Identified GPU BDF addresses**`lspci` in dom0 showed two NVIDIA GPUs. We targeted the GTX 1080 Ti at `65:00.0` (GPU) and `65:00.1` (HDMI audio controller).
2. **Confirmed boot loader** — Verified Qubes was using GRUB (not systemd-boot) by checking `/etc/default/grub` existed.
3. **Hid GPU from dom0** — Added `rd.qubes.hide_pci=65:00.0,65:00.1` to `GRUB_CMDLINE_LINUX` in `/etc/default/grub`.
4. **Regenerated GRUB config**`sudo grub2-mkconfig -o /boot/efi/EFI/qubes/grub.cfg` (EFI path, not legacy BIOS path).
5. **Rebooted dom0** — Full system reboot required for PCI hide to take effect.
6. **Verified GPU was assignable**`xl pci-assignable-list` showed `0000:65:00.0` and `0000:65:00.1`.
7. **Attached GPU to VM**`qvm-pci attach ai dom0:65_00.0 --persistent -o permissive=true` (and same for `65_00.1`).
### Phase 1: Driver & CUDA Installation (inside ai VM)
8. **Installed build prerequisites**`build-essential`, `linux-headers-$(uname -r)`.
9. **Installed NVIDIA driver** — Downloaded `.run` installer, ran with `--no-opengl-files --dkms`.
10. **Installed CUDA toolkit** — Via NVIDIA's Debian repo + `cuda-toolkit-12-6`.
11. **Verified**`nvidia-smi` showed GTX 1080 Ti, `nvcc --version` showed CUDA 12.6.
### Phase 2: Application Rebuild
12. **Rebuilt whisper.cpp with CUDA**`WHISPER_CUDA=1 bash ./build.sh`.
13. **Verified CUDA linkage**`ldd ./voice_linux | grep whisper|ggml` confirmed CUDA libraries linked.
14. **Tested GPU inference** — Ran with GPU mode, confirmed `nvidia-smi` showed process using GPU memory.
## Problems Encountered & Solutions
### Problem 1: AppVM vs StandaloneVM confusion
**What happened**: The `ai` VM was initially described as an AppVM (template-based). In an AppVM, anything installed outside `/home` is lost on reboot — NVIDIA drivers would vanish.
**Discovery**: User clarified it was actually a StandaloneVM, not template-based.
**Lesson**: Always verify VM type before planning driver installation. In Qubes:
- **AppVM**: Root filesystem resets to template on reboot. Drivers must go in the template or use bind-dirs.
- **StandaloneVM**: Full persistent root filesystem. Drivers persist normally.
- Check with: `qvm-prefs ai virt_mode` and `qvm-ls --fields name,klass ai`
### Problem 2: VM must be shut down before PCI attach
**What happened**: Attempted to run `qvm-pci attach` while the `ai` VM was running. The attach appeared to succeed but the device wasn't visible inside the VM.
**Discovery**: `qvm-device pci list ai` showed empty even though attach command ran.
**Lesson**: The VM must be **shut down** before attaching PCI devices with `--persistent`. The workflow is:
1. Shut down VM
2. Attach PCI device
3. Start VM
4. Verify inside VM with `lspci`
### Problem 3: "Already assigned" error on re-attach
**What happened**: After a reboot cycle, running the attach command again produced an "already assigned" message.
**Lesson**: `--persistent` means the attachment survives reboots. Don't re-run the attach command after reboot — it's already configured. Verify with `qvm-pci list ai` from dom0.
### Problem 4: lspci not found inside VM
**What happened**: Tried to verify GPU visibility inside the VM but `lspci` wasn't installed.
**Solution**: `sudo apt install pciutils` then `lspci | grep -i nvidia`.
**Lesson**: Minimal Debian VMs may not have `pciutils` installed. Include it in prerequisites.
### Problem 5: Script couldn't be copy-pasted into dom0
**What happened**: Created a comprehensive dom0 shell script for GPU passthrough, but Qubes security model prevents clipboard paste from other VMs into dom0.
**Solution**: Provided numbered step-by-step commands that could be typed manually, and also created the script as a file that could be transferred via `qvm-run` or Qubes file copy.
**Lesson**: When automating dom0 tasks in Qubes:
- Dom0 is intentionally isolated — no clipboard sharing
- Scripts must be typed manually or transferred via `qvm-copy-to-vm` (from dom0 to VM) or `qvm-run -p` pipes
- Keep dom0 scripts short and simple
- Always include a revert mechanism
### Problem 6: Reboot scope confusion
**What happened**: Unclear whether "reboot" meant just the VM or the entire Qubes system (dom0 + all VMs).
**Clarification**: GRUB changes require a **full system reboot** (dom0 reboot, which takes down all VMs). PCI attachment changes only require the target VM to be restarted.
**Lesson**: Be explicit about reboot scope:
- `sudo reboot` in dom0 = full system reboot
- `qvm-shutdown ai && qvm-start ai` = just the VM
### Problem 7: Build accidentally ran without whisper support
**What happened**: After some iteration, a build was accidentally triggered with `WITH_WHISPER=0`, producing a binary that showed "[transcription unavailable: rebuild with WITH_WHISPER=1]".
**Solution**: Rebuilt with `WITH_WHISPER=1 bash ./build.sh`.
**Lesson**: The build system defaults matter. Our `build.sh` auto-detects whisper if the vendor directory exists, but explicit `WITH_WHISPER=0` overrides that. Always verify the build output includes whisper support with `ldd ./voice_linux | grep whisper`.
### Problem 8: Dirty git worktree blocking version increment
**What happened**: The `increment_and_push.sh` script refused to run because of uncommitted changes in the working tree.
**Solution**: Committed the intended changes first, then ran the increment script.
**Lesson**: The increment script enforces a clean worktree policy. Always commit your changes before running it. If there are unrelated/untracked files, `git stash` them first.
## Key Qubes-Specific Knowledge
### PCI Passthrough Essentials
```
# Hide device from dom0 (GRUB, requires full reboot):
rd.qubes.hide_pci=BDF1,BDF2
# Attach to VM (VM must be off):
qvm-pci attach VMNAME dom0:BDF --persistent -o permissive=true
# Verify assignment:
qvm-pci list VMNAME # from dom0
lspci | grep -i nvidia # from inside VM
```
### NVIDIA Driver in Qubes VM
- **Always use `--no-opengl-files`** — Qubes VMs use a virtual GPU for display. Installing OpenGL files would break the display.
- **Use `--dkms`** — Ensures kernel module rebuilds on kernel updates.
- **StandaloneVM recommended** — Avoids template pollution and persistence issues.
- **`permissive=true`** — Required for NVIDIA GPUs in Qubes due to how they access PCI config space.
### What Persists Where
| VM Type | /home | /usr, /lib, /etc | Drivers |
|---------|-------|-------------------|---------|
| AppVM | ✅ Persists | ❌ Resets to template | ❌ Lost on reboot |
| StandaloneVM | ✅ Persists | ✅ Persists | ✅ Persists |
| Template | ✅ Persists | ✅ Persists | ✅ Persists (shared to AppVMs) |
### Dom0 Safety
- Dom0 has no network access by design
- Clipboard is one-way (dom0 → VM, not VM → dom0) and requires explicit Ctrl+Shift+C/V
- Always have a revert plan for GRUB changes (keep a backup of `/etc/default/grub`)
- Log what you change — our script wrote to `/var/log/gpu_passthrough.log`
## Architecture Diagram
```
┌─────────────────────────────────────────────────────────┐
│ dom0 (Xen Hypervisor) │
│ │
│ AMD GPU ──── Display │
│ GTX 1080 Ti ──── HIDDEN via rd.qubes.hide_pci │
│ │ │
│ │ PCI passthrough (qvm-pci attach --persistent) │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ai StandaloneVM │ │
│ │ │ │
│ │ NVIDIA Driver 550.x (--no-opengl-files --dkms) │ │
│ │ │ │ │
│ │ CUDA Toolkit 12.6 │ │
│ │ │ │ │
│ │ whisper.cpp (GGML_CUDA=ON) │ │
│ │ │ │ │
│ │ voice_linux (GPU-accelerated transcription) │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
## Performance Results
| Model | VRAM Used | Decode Time (10s audio) | Quality |
|-------|-----------|------------------------|---------|
| tiny.en | ~1GB | <0.5s | Fair |
| base.en | ~1GB | <1s | Good |
| small.en | ~2GB | ~1-2s | Very good |
| medium.en | ~5GB | ~2-3s | Excellent |
| large-v3-turbo | ~6GB | ~3-4s | Excellent+ |
| large-v3 | ~10GB | ~4-6s | Best |
The GTX 1080 Ti with 11GB VRAM can run all models including large-v3. For real-time dictation, medium.en provides the best accuracy-to-speed tradeoff.
## Files Created During This Process
| File | Purpose |
|------|---------|
| [`plans/gpu_enablement_plan.md`](gpu_enablement_plan.md) | Step-by-step GPU passthrough and driver installation plan |
| [`plans/architecture.md`](architecture.md) | Full system architecture including hardware details |
| [`scripts/download_models.sh`](../scripts/download_models.sh) | Downloads all compatible whisper models |
## If You Had to Do It Again
1. **Verify VM type first**`qvm-ls --fields name,klass ai` before planning anything
2. **Shut down VM before PCI operations** — always
3. **Back up GRUB config**`sudo cp /etc/default/grub /etc/default/grub.bak` before editing
4. **Install pciutils early**`sudo apt install pciutils` so you can verify GPU visibility
5. **Use the .run installer, not apt packages** — NVIDIA's apt packages for Debian can conflict with Qubes' virtual GPU setup; the `.run` installer with `--no-opengl-files` is cleaner
6. **Test CPU mode first** — Build and verify voice_linux works on CPU before adding GPU complexity
7. **Keep dom0 commands minimal** — Type them manually, don't try to automate complex scripts in dom0
+142
View File
@@ -0,0 +1,142 @@
# Unified Binary + Startup Dialog Plan
## Summary
Merge the CLI and GUI into a single `voice_linux` binary that always opens the GTK3 window. On launch, a **startup dialog** appears letting the user choose CPU vs GPU and select which whisper model to use. All available ggml models are downloaded to `./models/` so the user can test them.
## Current State
| Item | Status |
|------|--------|
| Two binaries: `voice_linux` (CLI) and `voice_linux_gui` (GTK3) | Active |
| GPU passthrough + NVIDIA driver + CUDA toolkit | Working |
| whisper.cpp built with `GGML_CUDA=ON` | Working |
| `--gpu` / `--cpu` flags parsed in both binaries | Working |
| Model path hardcoded in `config.ini` as `model_path=` | Working |
| `transcribe_init()` called once at startup before GTK loop | Working |
## Architecture Changes
```mermaid
flowchart TD
A[./voice_linux launched] --> B[Load config.ini]
B --> C[audio_init]
C --> D{Show Startup Dialog}
D --> |User picks CPU/GPU + model| E[transcribe_init with selections]
E --> F[typer_init]
F --> G[Main GTK window + event loop]
G --> H[On window close: cleanup]
```
Key change: **`transcribe_init()` is deferred** until after the startup dialog returns the user's selections. Audio init happens first so the mic meter can work, but model loading waits for the dialog.
## Whisper Models to Download
All English-optimized ggml models from the whisper.cpp HuggingFace repo. The GTX 1080 Ti has 11 GB VRAM, so all models up to `large-v3` fit comfortably.
| Model | File | Size | VRAM est. |
|-------|------|------|-----------|
| tiny.en | `ggml-tiny.en.bin` | ~75 MB | ~200 MB |
| base.en | `ggml-base.en.bin` | ~142 MB | ~350 MB |
| small.en | `ggml-small.en.bin` | ~466 MB | ~1 GB |
| medium.en | `ggml-medium.en.bin` | ~1.5 GB | ~3 GB |
| large-v3-turbo | `ggml-large-v3-turbo.bin` | ~1.6 GB | ~3.5 GB |
| large-v3 | `ggml-large-v3.bin` | ~3.1 GB | ~6 GB |
Base URL: `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/`
## Detailed Steps
### Step 1: Download all whisper models
Create a `scripts/download_models.sh` that downloads each model to `./models/` if not already present. Also callable from `build.sh`.
### Step 2: Add startup dialog to gui_main.c
Insert a modal `GtkDialog` that runs **before** `transcribe_init()`:
- **GPU toggle**: `GtkComboBoxText` with entries `CPU` and `GPU`. Default to `GPU` if CUDA device is detected at runtime, otherwise `CPU`.
- **Model picker**: `GtkComboBoxText` populated by scanning `./models/ggml-*.bin` files. Display friendly names derived from filenames. Pre-select the model from `config.ini`.
- **OK button**: closes dialog, returns selections to `main()`.
The dialog is a transient child of a hidden parent window so it appears centered on screen.
GPU detection at dialog time: check if `nvidia-smi` exits 0, or check if the whisper.cpp CUDA backend reports a device. Simplest approach: try `access /dev/nvidia0` — if it exists, default to GPU.
### Step 3: Defer transcribe_init
Current flow in `gui_main.c` [`main()`](src/gui_main.c:588):
```
config_load -> use_gpu from argv -> audio_init -> transcribe_init -> typer_init -> gtk loop
```
New flow:
```
config_load -> audio_init -> show_startup_dialog -> transcribe_init(dialog selections) -> typer_init -> gtk loop
```
The startup dialog returns:
- `int use_gpu` — 0 or 1
- `char model_path[512]` — full path to selected model file
These override whatever was in config.ini / argv.
### Step 4: Remove CLI binary from default build
- Keep `src/main.c` in the repo for reference or future headless use, but do not build it by default.
- In [`build.sh`](build.sh): remove the separate CLI gcc invocation. The GUI gcc line outputs `./voice_linux` instead of `./voice_linux_gui`.
- In [`Makefile`](Makefile): change `TARGET` to build from `gui_main.c` sources. Remove `voice_linux_gui` target. Optionally add a `cli` target for headless builds.
- The `BUILD_GUI` env var is no longer needed; remove it.
### Step 5: Add use_gpu to config.ini and config.c
Add a `use_gpu=1` line to [`config.ini`](config.ini). Parse it in [`config_load_file()`](src/config.c:43). This becomes the default for the startup dialog, overridable by the dialog selection. The `--gpu` / `--cpu` CLI flags still work as overrides.
Add `int use_gpu;` to [`voice_config_t`](src/config.h:6).
### Step 6: Update README.md
Simplify run instructions to just `./voice_linux`. Remove `voice_linux_gui` references. Document the startup dialog behavior.
### Step 7: Build and test
```bash
bash ./build.sh
./voice_linux
```
Verify:
- Startup dialog appears with CPU/GPU toggle and model list
- Selecting GPU + medium.en loads correctly with CUDA backend
- Main window works as before after dialog closes
## Files Modified
| File | Change |
|------|--------|
| `src/gui_main.c` | Add startup dialog function; defer `transcribe_init`; update `main()` flow |
| `src/config.h` | Add `use_gpu` field to `voice_config_t` |
| `src/config.c` | Parse `use_gpu` from config file |
| `config.ini` | Add `use_gpu=1` |
| `build.sh` | Single binary output `./voice_linux`; remove `BUILD_GUI` toggle; remove CLI build |
| `Makefile` | Single target `./voice_linux` from gui sources |
| `README.md` | Update run instructions |
| `scripts/download_models.sh` | New script to download all models |
## Files NOT Modified
| File | Reason |
|------|--------|
| `src/main.c` | Kept for optional headless builds; not built by default |
| `src/transcribe.c` | API unchanged — `transcribe_init` / `transcribe_cleanup` already support the needed flow |
| `src/transcribe.h` | No changes needed |
| `src/audio.c` / `src/audio.h` | No changes needed |
| `src/hotkey.c` / `src/hotkey.h` | Only used by CLI; not linked into GUI binary |
## Risk Notes
- The startup dialog adds a brief pause before the main window. Model loading (especially large-v3 at 3 GB) takes several seconds — a progress indicator or status label in the main window info area will show loading state.
- If no models are found in `./models/`, the dialog should show an error message and offer to run the download script.
- The `transcribe_init` call blocks the GTK main thread during model load. For large models this could be 5-10 seconds. An improvement would be async loading with a progress bar, but for v1 a simple "Loading model..." label update before the blocking call is sufficient.
+202
View File
@@ -0,0 +1,202 @@
# Voice Commands Tab — Implementation Plan
## Overview
Add a third GUI tab ("Commands") that lets users define trigger phrases from whisper output and map them to either text replacements or keyboard combinations. This enables meta-speech like saying "period" to insert `.`, or "over" to send `Return`.
## Architecture
### Data Model
```c
typedef enum {
VOICE_CMD_TEXT, // replace trigger with literal text
VOICE_CMD_KEYCOMBO // send a key combination via XTest
} voice_cmd_type_t;
typedef struct {
char trigger[128]; // spoken phrase, case-insensitive
voice_cmd_type_t type;
char text_value[256]; // for TEXT type: replacement string
unsigned int keyval; // for KEYCOMBO type: GDK keyval
unsigned int modifiers; // for KEYCOMBO type: modifier mask
char display_key[128]; // human-readable key description
} voice_cmd_t;
typedef struct {
voice_cmd_t *cmds;
int count;
int capacity;
int enabled; // global on/off toggle
} voice_cmd_list_t;
```
### Processing Pipeline
```
transcribe_buffer() → raw text
voice_cmd_apply(text, list) → action sequence
For each action:
- TEXT_CHUNK: accumulate into output string
- KEY_EVENT: call typer_send_keycombo()
Final text → append_transcript() + typer_type_text()
```
The apply function returns a sequence of interleaved actions because a single transcript may contain multiple triggers mixed with regular text. Example: "hello over goodbye over" → text:"hello " → key:Return → text:"goodbye " → key:Return.
### Matching Strategy
- Case-insensitive comparison
- Whole-word boundary matching to avoid false positives
- Longest-match-first: sort triggers by length descending before scanning
- Left-to-right scan through transcript text
- Matched trigger phrases are consumed and not included in output text
## New Files
### src/voice_cmd.h
- `voice_cmd_t` and `voice_cmd_list_t` structs
- `void voice_cmd_init(voice_cmd_list_t *list)`
- `void voice_cmd_free(voice_cmd_list_t *list)`
- `int voice_cmd_add(voice_cmd_list_t *list, const voice_cmd_t *cmd)`
- `int voice_cmd_remove(voice_cmd_list_t *list, int index)`
- `int voice_cmd_load(const char *path, voice_cmd_list_t *list)`
- `int voice_cmd_save(const char *path, const voice_cmd_list_t *list)`
### src/voice_cmd.c
- Load/save from `voice_commands.ini`
- `voice_cmd_apply()` — the core text processing function
### voice_commands.ini
Persisted command rules, separate from main config.ini.
Format:
```
# trigger|type|value
# type: text or key
# for text: value is the replacement string (supports \n \t escapes)
# for key: value is keyval:modifiers (decimal GDK values)
period|text|.
question mark|text|?
comma|text|,
exclamation mark|text|!
new line|text|\n
new paragraph|text|\n\n
tab|text|\t
over|key|65293:0
end of command|key|65293:5
```
## Modified Files
### src/typer.h / src/typer.c
- Add: `int typer_send_keycombo(unsigned int keyval, unsigned int modifiers)`
- Uses XTest to send arbitrary key combinations with modifier state
- Reuses existing X11 display connection from typer_init()
### src/gui_main.c
- Add third notebook tab in on_app_activate()
- Tab contains:
- GtkTreeView showing existing commands (trigger, type, action display)
- Delete button per row or for selected row
- Add-new-command form:
- GtkEntry for trigger phrase
- GtkRadioButton pair: Text / Key Combo
- GtkEntry for text replacement value
- GtkButton "Capture Key" + GtkLabel showing captured key
- GtkButton "Add Command"
- GtkCheckButton "Enable voice commands" toggle
- Key capture workflow:
1. User clicks Capture
2. Button label changes to "Press key combo now..."
3. Window key-press-event handler captures next keypress
4. Records keyval + modifier state
5. Formats display string like "Ctrl+Shift+Return"
6. Disconnects capture handler, restores button label
### src/gui_main.c — process_segment()
- After transcribe_buffer() returns text, call voice_cmd_apply()
- Process returned action sequence: text chunks go to transcript/autotype, key events go to typer_send_keycombo()
### Makefile
- Add voice_cmd.o to build targets
### build.sh
- Add src/voice_cmd.c to compilation
## GUI Tab Layout
```
┌─ Voice Commands ──────────────────────────────────────────┐
│ │
│ ☑ Enable voice commands │
│ │
│ ┌──────────────┬──────┬─────────────────┬───────────┐ │
│ │ Trigger │ Type │ Action │ │ │
│ ├──────────────┼──────┼─────────────────┼───────────┤ │
│ │ period │ text │ . │ [Delete] │ │
│ │ question mark│ text │ ? │ [Delete] │ │
│ │ new line │ text │ \n │ [Delete] │ │
│ │ over │ key │ Return │ [Delete] │ │
│ │ end of cmd │ key │ Ctrl+Shift+Ret │ [Delete] │ │
│ └──────────────┴──────┴─────────────────┴───────────┘ │
│ │
│ ── Add New Command ── │
│ Trigger: [____________] │
│ ○ Text replacement ● Key combination │
│ Text: [____________] │
│ Key: [Press keys...] [Capture] │
│ [Add Command] │
│ │
│ Help: [hover text area] │
└────────────────────────────────────────────────────────────┘
```
## Default Commands (shipped with first install)
| Trigger | Type | Action |
|---------|------|--------|
| period | text | . |
| question mark | text | ? |
| exclamation mark | text | ! |
| comma | text | , |
| colon | text | : |
| semicolon | text | ; |
| new line | text | \n |
| new paragraph | text | \n\n |
| tab | text | \t |
| open paren | text | ( |
| close paren | text | ) |
| open bracket | text | [ |
| close bracket | text | ] |
| open brace | text | { |
| close brace | text | } |
## Edge Cases
1. **Substring false positives**: "I need a period of time" — whole-word boundary matching prevents "period" from matching inside "periodical" but this phrase would still trigger. Could add a "require end-of-phrase position" option per command, but start simple with whole-word matching.
2. **Case sensitivity**: Whisper may output "Period" or "period" depending on sentence position. All matching is case-insensitive.
3. **Multi-word triggers**: "end of command" requires multi-word scanning. Sort triggers longest-first so "end of command" matches before "end".
4. **Escape sequences in text values**: Support \n, \t, \\ in the text replacement field. Parse on load, display escaped in UI.
5. **Key capture conflicts**: While capturing, consume the event so it doesn't trigger other handlers. Disconnect capture handler after first keypress.
6. **Empty transcript after replacements**: If the entire transcript was voice commands with no remaining text, skip autotype and transcript append.
## Implementation Order
1. Create src/voice_cmd.h and src/voice_cmd.c with data structures and load/save
2. Add typer_send_keycombo() to src/typer.h / src/typer.c
3. Implement voice_cmd_apply() core matching logic
4. Build the Commands tab UI in src/gui_main.c
5. Wire key capture handler
6. Hook voice_cmd_apply() into process_segment()
7. Create default voice_commands.ini
8. Update Makefile and build.sh
9. Build and test
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
mkdir -p ./models
BASE_URL="https://huggingface.co/ggerganov/whisper.cpp/resolve/main"
MODELS=(
"ggml-tiny.en.bin"
"ggml-base.en.bin"
"ggml-small.en.bin"
"ggml-medium.en.bin"
"ggml-large-v3-turbo.bin"
"ggml-large-v3.bin"
)
need_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "[models] error: required command '$1' not found" >&2
exit 2
fi
}
need_cmd wget
echo "[models] target dir: $ROOT_DIR/models"
for model in "${MODELS[@]}"; do
out="./models/$model"
url="$BASE_URL/$model"
if [[ -f "$out" ]]; then
size="$(stat -c%s "$out" 2>/dev/null || echo 0)"
echo "[models] found local copy for $model (${size} bytes), resuming/verifying with wget -c"
else
echo "[models] downloading: $model"
fi
wget -c -O "$out" "$url"
echo "[models] ready: $model"
done
echo "[models] complete"
+175
View File
@@ -25,6 +25,7 @@ void config_set_defaults(voice_config_t *cfg) {
snprintf(cfg->audio_device, sizeof(cfg->audio_device), "qubes-source");
snprintf(cfg->language, sizeof(cfg->language), "en");
snprintf(cfg->hotkey, sizeof(cfg->hotkey), "Ctrl+Alt+V");
cfg->use_gpu = 1;
cfg->sample_rate = 16000;
cfg->type_delay_us = 8000;
cfg->max_record_seconds = 30;
@@ -37,6 +38,44 @@ void config_set_defaults(voice_config_t *cfg) {
cfg->vad_min_speech_ms = 250;
cfg->vad_max_speech_ms = 15000;
cfg->autotype_enabled = 1;
cfg->filter_bracketed_tags = 1;
cfg->whisper_sampling_strategy = 0;
cfg->whisper_n_max_text_ctx = 16384;
cfg->whisper_offset_ms = 0;
cfg->whisper_duration_ms = 0;
cfg->whisper_translate = 0;
cfg->whisper_detect_language = 0;
cfg->whisper_no_context = 0;
cfg->whisper_no_timestamps = 1;
cfg->whisper_single_segment = 0;
cfg->whisper_token_timestamps = 0;
cfg->whisper_thold_pt = 0.01f;
cfg->whisper_thold_ptsum = 0.01f;
cfg->whisper_max_len = 0;
cfg->whisper_split_on_word = 0;
cfg->whisper_max_tokens = 0;
cfg->whisper_audio_ctx = 0;
cfg->whisper_tdrz_enable = 0;
cfg->whisper_suppress_blank = 1;
cfg->whisper_suppress_nst = 0;
cfg->whisper_temperature = 0.0f;
cfg->whisper_max_initial_ts = 1.0f;
cfg->whisper_length_penalty = -1.0f;
cfg->whisper_temperature_inc = 0.2f;
cfg->whisper_entropy_thold = 2.4f;
cfg->whisper_logprob_thold = -1.0f;
cfg->whisper_no_speech_thold = 0.6f;
cfg->whisper_greedy_best_of = 1;
cfg->whisper_beam_size = 5;
cfg->whisper_beam_patience = -1.0f;
}
int config_load_file(const char *path, voice_config_t *cfg) {
@@ -67,6 +106,8 @@ int config_load_file(const char *path, voice_config_t *cfg) {
snprintf(cfg->language, sizeof(cfg->language), "%s", value);
} else if (strcmp(key, "hotkey") == 0) {
snprintf(cfg->hotkey, sizeof(cfg->hotkey), "%s", value);
} else if (strcmp(key, "use_gpu") == 0) {
cfg->use_gpu = atoi(value);
} else if (strcmp(key, "sample_rate") == 0) {
cfg->sample_rate = atoi(value);
} else if (strcmp(key, "type_delay_us") == 0) {
@@ -91,9 +132,143 @@ int config_load_file(const char *path, voice_config_t *cfg) {
cfg->vad_max_speech_ms = atoi(value);
} else if (strcmp(key, "autotype_enabled") == 0) {
cfg->autotype_enabled = atoi(value);
} else if (strcmp(key, "filter_bracketed_tags") == 0) {
cfg->filter_bracketed_tags = atoi(value);
} else if (strcmp(key, "whisper_sampling_strategy") == 0) {
cfg->whisper_sampling_strategy = atoi(value);
} else if (strcmp(key, "whisper_n_max_text_ctx") == 0) {
cfg->whisper_n_max_text_ctx = atoi(value);
} else if (strcmp(key, "whisper_offset_ms") == 0) {
cfg->whisper_offset_ms = atoi(value);
} else if (strcmp(key, "whisper_duration_ms") == 0) {
cfg->whisper_duration_ms = atoi(value);
} else if (strcmp(key, "whisper_translate") == 0) {
cfg->whisper_translate = atoi(value);
} else if (strcmp(key, "whisper_detect_language") == 0) {
cfg->whisper_detect_language = atoi(value);
} else if (strcmp(key, "whisper_no_context") == 0) {
cfg->whisper_no_context = atoi(value);
} else if (strcmp(key, "whisper_no_timestamps") == 0) {
cfg->whisper_no_timestamps = atoi(value);
} else if (strcmp(key, "whisper_single_segment") == 0) {
cfg->whisper_single_segment = atoi(value);
} else if (strcmp(key, "whisper_token_timestamps") == 0) {
cfg->whisper_token_timestamps = atoi(value);
} else if (strcmp(key, "whisper_thold_pt") == 0) {
cfg->whisper_thold_pt = (float)atof(value);
} else if (strcmp(key, "whisper_thold_ptsum") == 0) {
cfg->whisper_thold_ptsum = (float)atof(value);
} else if (strcmp(key, "whisper_max_len") == 0) {
cfg->whisper_max_len = atoi(value);
} else if (strcmp(key, "whisper_split_on_word") == 0) {
cfg->whisper_split_on_word = atoi(value);
} else if (strcmp(key, "whisper_max_tokens") == 0) {
cfg->whisper_max_tokens = atoi(value);
} else if (strcmp(key, "whisper_audio_ctx") == 0) {
cfg->whisper_audio_ctx = atoi(value);
} else if (strcmp(key, "whisper_tdrz_enable") == 0) {
cfg->whisper_tdrz_enable = atoi(value);
} else if (strcmp(key, "whisper_suppress_blank") == 0) {
cfg->whisper_suppress_blank = atoi(value);
} else if (strcmp(key, "whisper_suppress_nst") == 0) {
cfg->whisper_suppress_nst = atoi(value);
} else if (strcmp(key, "whisper_temperature") == 0) {
cfg->whisper_temperature = (float)atof(value);
} else if (strcmp(key, "whisper_max_initial_ts") == 0) {
cfg->whisper_max_initial_ts = (float)atof(value);
} else if (strcmp(key, "whisper_length_penalty") == 0) {
cfg->whisper_length_penalty = (float)atof(value);
} else if (strcmp(key, "whisper_temperature_inc") == 0) {
cfg->whisper_temperature_inc = (float)atof(value);
} else if (strcmp(key, "whisper_entropy_thold") == 0) {
cfg->whisper_entropy_thold = (float)atof(value);
} else if (strcmp(key, "whisper_logprob_thold") == 0) {
cfg->whisper_logprob_thold = (float)atof(value);
} else if (strcmp(key, "whisper_no_speech_thold") == 0) {
cfg->whisper_no_speech_thold = (float)atof(value);
} else if (strcmp(key, "whisper_greedy_best_of") == 0) {
cfg->whisper_greedy_best_of = atoi(value);
} else if (strcmp(key, "whisper_beam_size") == 0) {
cfg->whisper_beam_size = atoi(value);
} else if (strcmp(key, "whisper_beam_patience") == 0) {
cfg->whisper_beam_patience = (float)atof(value);
}
}
fclose(f);
return 0;
}
int config_save_file(const char *path, const voice_config_t *cfg) {
if (!path || !cfg) return -1;
FILE *f = fopen(path, "w");
if (!f) return -2;
fprintf(f, "# voice_linux runtime configuration\n");
fprintf(f, "# Hotkey format supports Ctrl, Alt, Shift, Super + final key symbol\n");
fprintf(f, "# Example: Ctrl+Alt+V\n\n");
fprintf(f, "model_path=%s\n", cfg->model_path);
fprintf(f, "use_gpu=%d\n", cfg->use_gpu ? 1 : 0);
fprintf(f, "audio_device=%s\n", cfg->audio_device);
fprintf(f, "sample_rate=%d\n", cfg->sample_rate);
fprintf(f, "language=%s\n", cfg->language);
fprintf(f, "hotkey=%s\n", cfg->hotkey);
fprintf(f, "type_delay_us=%d\n", cfg->type_delay_us);
fprintf(f, "max_record_seconds=%d\n", cfg->max_record_seconds);
fprintf(f, "# always-on VAD controls\n");
fprintf(f, "always_on=%d\n", cfg->always_on ? 1 : 0);
fprintf(f, "# legacy window size (unused by new VAD pipeline, kept for compatibility)\n");
fprintf(f, "always_on_window_ms=%d\n\n", cfg->always_on_window_ms);
fprintf(f, "# live level threshold (0.0 - 1.0)\n");
fprintf(f, "vad_peak_threshold=%.4f\n", cfg->vad_peak_threshold);
fprintf(f, "# level must stay above threshold this long before speech starts\n");
fprintf(f, "vad_trigger_ms=%d\n", cfg->vad_trigger_ms);
fprintf(f, "# level must stay below threshold this long before speech ends\n");
fprintf(f, "vad_release_ms=%d\n", cfg->vad_release_ms);
fprintf(f, "# include this much pre-trigger audio in transcript segment\n");
fprintf(f, "vad_preroll_ms=%d\n", cfg->vad_preroll_ms);
fprintf(f, "# discard segments shorter than this\n");
fprintf(f, "vad_min_speech_ms=%d\n", cfg->vad_min_speech_ms);
fprintf(f, "# hard cap for one speech segment\n");
fprintf(f, "vad_max_speech_ms=%d\n\n", cfg->vad_max_speech_ms);
fprintf(f, "autotype_enabled=%d\n", cfg->autotype_enabled ? 1 : 0);
fprintf(f, "filter_bracketed_tags=%d\n\n", cfg->filter_bracketed_tags ? 1 : 0);
fprintf(f, "# whisper decode controls\n");
fprintf(f, "whisper_sampling_strategy=%d\n", cfg->whisper_sampling_strategy);
fprintf(f, "whisper_n_max_text_ctx=%d\n", cfg->whisper_n_max_text_ctx);
fprintf(f, "whisper_offset_ms=%d\n", cfg->whisper_offset_ms);
fprintf(f, "whisper_duration_ms=%d\n", cfg->whisper_duration_ms);
fprintf(f, "whisper_translate=%d\n", cfg->whisper_translate ? 1 : 0);
fprintf(f, "whisper_detect_language=%d\n", cfg->whisper_detect_language ? 1 : 0);
fprintf(f, "whisper_no_context=%d\n", cfg->whisper_no_context ? 1 : 0);
fprintf(f, "whisper_no_timestamps=%d\n", cfg->whisper_no_timestamps ? 1 : 0);
fprintf(f, "whisper_single_segment=%d\n", cfg->whisper_single_segment ? 1 : 0);
fprintf(f, "whisper_token_timestamps=%d\n", cfg->whisper_token_timestamps ? 1 : 0);
fprintf(f, "whisper_thold_pt=%.4f\n", cfg->whisper_thold_pt);
fprintf(f, "whisper_thold_ptsum=%.4f\n", cfg->whisper_thold_ptsum);
fprintf(f, "whisper_max_len=%d\n", cfg->whisper_max_len);
fprintf(f, "whisper_split_on_word=%d\n", cfg->whisper_split_on_word ? 1 : 0);
fprintf(f, "whisper_max_tokens=%d\n", cfg->whisper_max_tokens);
fprintf(f, "whisper_audio_ctx=%d\n", cfg->whisper_audio_ctx);
fprintf(f, "whisper_tdrz_enable=%d\n", cfg->whisper_tdrz_enable ? 1 : 0);
fprintf(f, "whisper_suppress_blank=%d\n", cfg->whisper_suppress_blank ? 1 : 0);
fprintf(f, "whisper_suppress_nst=%d\n", cfg->whisper_suppress_nst ? 1 : 0);
fprintf(f, "whisper_temperature=%.4f\n", cfg->whisper_temperature);
fprintf(f, "whisper_max_initial_ts=%.4f\n", cfg->whisper_max_initial_ts);
fprintf(f, "whisper_length_penalty=%.4f\n", cfg->whisper_length_penalty);
fprintf(f, "whisper_temperature_inc=%.4f\n", cfg->whisper_temperature_inc);
fprintf(f, "whisper_entropy_thold=%.4f\n", cfg->whisper_entropy_thold);
fprintf(f, "whisper_logprob_thold=%.4f\n", cfg->whisper_logprob_thold);
fprintf(f, "whisper_no_speech_thold=%.4f\n", cfg->whisper_no_speech_thold);
fprintf(f, "whisper_greedy_best_of=%d\n", cfg->whisper_greedy_best_of);
fprintf(f, "whisper_beam_size=%d\n", cfg->whisper_beam_size);
fprintf(f, "whisper_beam_patience=%.4f\n", cfg->whisper_beam_patience);
fclose(f);
return 0;
}
+40
View File
@@ -8,6 +8,7 @@ typedef struct {
char audio_device[256];
char language[32];
char hotkey[64];
int use_gpu;
int sample_rate;
int type_delay_us;
int max_record_seconds;
@@ -20,9 +21,48 @@ typedef struct {
int vad_min_speech_ms;
int vad_max_speech_ms;
int autotype_enabled;
int filter_bracketed_tags;
int whisper_sampling_strategy; // 0=greedy, 1=beam search
int whisper_n_max_text_ctx;
int whisper_offset_ms;
int whisper_duration_ms;
int whisper_translate;
int whisper_detect_language;
int whisper_no_context;
int whisper_no_timestamps;
int whisper_single_segment;
int whisper_token_timestamps;
float whisper_thold_pt;
float whisper_thold_ptsum;
int whisper_max_len;
int whisper_split_on_word;
int whisper_max_tokens;
int whisper_audio_ctx;
int whisper_tdrz_enable;
int whisper_suppress_blank;
int whisper_suppress_nst;
float whisper_temperature;
float whisper_max_initial_ts;
float whisper_length_penalty;
float whisper_temperature_inc;
float whisper_entropy_thold;
float whisper_logprob_thold;
float whisper_no_speech_thold;
int whisper_greedy_best_of;
int whisper_beam_size;
float whisper_beam_patience;
} voice_config_t;
void config_set_defaults(voice_config_t *cfg);
int config_load_file(const char *path, voice_config_t *cfg);
int config_save_file(const char *path, const voice_config_t *cfg);
#endif
+1127 -48
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -87,6 +87,7 @@ int main(int argc, char **argv) {
snprintf(tp.language, sizeof(tp.language), "%s", cfg.language);
tp.use_gpu = use_gpu;
tp.n_threads = (int)sysconf(_SC_NPROCESSORS_ONLN);
tp.filter_bracketed_tags = cfg.filter_bracketed_tags;
hotkey_ctx_t hk;
+135 -5
View File
@@ -1,5 +1,6 @@
#include "transcribe.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -8,15 +9,124 @@
#define WITH_WHISPER 0
#endif
static int g_filter_bracketed_tags = 1;
static char *filter_bracketed_tags_copy(const char *input) {
if (!input) return NULL;
const size_t len = strlen(input);
char *no_tags = (char *)malloc(len + 1);
if (!no_tags) return NULL;
size_t w = 0;
int bracket_depth = 0;
for (size_t i = 0; i < len; ++i) {
unsigned char ch = (unsigned char)input[i];
if (ch == '[') {
bracket_depth++;
continue;
}
if (ch == ']' && bracket_depth > 0) {
bracket_depth--;
continue;
}
if (bracket_depth > 0) {
continue;
}
no_tags[w++] = (char)ch;
}
no_tags[w] = '\0';
char *out = (char *)malloc(w + 1);
if (!out) {
free(no_tags);
return NULL;
}
size_t j = 0;
int in_space = 1;
for (size_t i = 0; i < w; ++i) {
unsigned char ch = (unsigned char)no_tags[i];
if (isspace(ch)) {
if (!in_space) {
out[j++] = ' ';
in_space = 1;
}
} else {
out[j++] = (char)ch;
in_space = 0;
}
}
if (j > 0 && out[j - 1] == ' ') {
j--;
}
out[j] = '\0';
free(no_tags);
return out;
}
void transcribe_set_filter_bracketed_tags(int enabled) {
g_filter_bracketed_tags = enabled ? 1 : 0;
}
#if WITH_WHISPER
#include <whisper.h>
static struct whisper_context *g_ctx = NULL;
static transcribe_params_t g_params;
static void apply_full_params_from_config(struct whisper_full_params *dst, const transcribe_params_t *src) {
if (!dst || !src) return;
dst->n_threads = src->n_threads > 0 ? src->n_threads : 4;
dst->n_max_text_ctx = src->n_max_text_ctx;
dst->offset_ms = src->offset_ms;
dst->duration_ms = src->duration_ms;
dst->translate = src->translate ? true : false;
dst->detect_language = src->detect_language ? true : false;
dst->no_context = src->no_context ? true : false;
dst->no_timestamps = src->no_timestamps ? true : false;
dst->single_segment = src->single_segment ? true : false;
dst->token_timestamps = src->token_timestamps ? true : false;
dst->thold_pt = src->thold_pt;
dst->thold_ptsum = src->thold_ptsum;
dst->max_len = src->max_len;
dst->split_on_word = src->split_on_word ? true : false;
dst->max_tokens = src->max_tokens;
dst->audio_ctx = src->audio_ctx;
dst->tdrz_enable = src->tdrz_enable ? true : false;
dst->suppress_blank = src->suppress_blank ? true : false;
dst->suppress_nst = src->suppress_nst ? true : false;
dst->temperature = src->temperature;
dst->max_initial_ts = src->max_initial_ts;
dst->length_penalty = src->length_penalty;
dst->temperature_inc = src->temperature_inc;
dst->entropy_thold = src->entropy_thold;
dst->logprob_thold = src->logprob_thold;
dst->no_speech_thold = src->no_speech_thold;
dst->greedy.best_of = src->greedy_best_of;
dst->beam_search.beam_size = src->beam_size;
dst->beam_search.patience = src->beam_patience;
dst->language = src->language[0] ? src->language : "en";
}
int transcribe_init(const transcribe_params_t *params) {
if (!params) return -1;
g_params = *params;
transcribe_set_filter_bracketed_tags(g_params.filter_bracketed_tags);
struct whisper_context_params cparams = whisper_context_default_params();
cparams.use_gpu = g_params.use_gpu ? true : false;
@@ -28,17 +138,22 @@ int transcribe_init(const transcribe_params_t *params) {
return 0;
}
void transcribe_update_params(const transcribe_params_t *params) {
if (!params) return;
g_params = *params;
transcribe_set_filter_bracketed_tags(g_params.filter_bracketed_tags);
}
char *transcribe_buffer(const float *samples, size_t count) {
if (!g_ctx || !samples || count == 0) return NULL;
struct whisper_full_params params = whisper_full_default_params(WHISPER_SAMPLING_GREEDY);
enum whisper_sampling_strategy strategy = g_params.sampling_strategy ? WHISPER_SAMPLING_BEAM_SEARCH : WHISPER_SAMPLING_GREEDY;
struct whisper_full_params params = whisper_full_default_params(strategy);
params.print_progress = false;
params.print_special = false;
params.print_realtime = false;
params.print_timestamps = false;
params.translate = false;
params.n_threads = g_params.n_threads > 0 ? g_params.n_threads : 4;
params.language = g_params.language[0] ? g_params.language : "en";
apply_full_params_from_config(&params, &g_params);
if (whisper_full(g_ctx, params, samples, (int)count) != 0) {
fprintf(stderr, "transcribe: whisper_full failed\n");
@@ -63,7 +178,17 @@ char *transcribe_buffer(const float *samples, size_t count) {
if (i + 1 < nseg) strcat(out, " ");
}
return out;
if (!g_filter_bracketed_tags) {
return out;
}
char *filtered = filter_bracketed_tags_copy(out);
if (!filtered) {
return out;
}
free(out);
return filtered;
}
void transcribe_cleanup(void) {
@@ -84,6 +209,11 @@ int transcribe_init(const transcribe_params_t *params) {
return 0;
}
void transcribe_update_params(const transcribe_params_t *params) {
if (!params) return;
g_params = *params;
}
char *transcribe_buffer(const float *samples, size_t count) {
(void)samples;
(void)count;
+40
View File
@@ -8,9 +8,49 @@ typedef struct {
char language[32];
int use_gpu;
int n_threads;
int filter_bracketed_tags;
int sampling_strategy; // 0=greedy, 1=beam search
int n_max_text_ctx;
int offset_ms;
int duration_ms;
int translate;
int detect_language;
int no_context;
int no_timestamps;
int single_segment;
int token_timestamps;
float thold_pt;
float thold_ptsum;
int max_len;
int split_on_word;
int max_tokens;
int audio_ctx;
int tdrz_enable;
int suppress_blank;
int suppress_nst;
float temperature;
float max_initial_ts;
float length_penalty;
float temperature_inc;
float entropy_thold;
float logprob_thold;
float no_speech_thold;
int greedy_best_of;
int beam_size;
float beam_patience;
} transcribe_params_t;
int transcribe_init(const transcribe_params_t *params);
void transcribe_update_params(const transcribe_params_t *params);
void transcribe_set_filter_bracketed_tags(int enabled);
char *transcribe_buffer(const float *samples, size_t count);
void transcribe_cleanup(void);
+47
View File
@@ -182,6 +182,53 @@ int typer_type_text(const char *text) {
return failures == 0 ? 0 : -1;
}
int typer_send_keycombo(unsigned int keyval, unsigned int modifiers) {
if (!g_dpy || keyval == 0) return -1;
KeyCode kc = XKeysymToKeycode(g_dpy, (KeySym)keyval);
if (!kc) return -1;
KeyCode ctrl_kc = XKeysymToKeycode(g_dpy, XK_Control_L);
KeyCode shift_kc = XKeysymToKeycode(g_dpy, XK_Shift_L);
KeyCode alt_kc = XKeysymToKeycode(g_dpy, XK_Alt_L);
KeyCode super_kc = XKeysymToKeycode(g_dpy, XK_Super_L);
if ((modifiers & (1u << 1)) && ctrl_kc) {
if (!XTestFakeKeyEvent(g_dpy, ctrl_kc, True, CurrentTime)) return -1;
}
if ((modifiers & (1u << 0)) && shift_kc) {
if (!XTestFakeKeyEvent(g_dpy, shift_kc, True, CurrentTime)) return -1;
}
if ((modifiers & (1u << 2)) && alt_kc) {
if (!XTestFakeKeyEvent(g_dpy, alt_kc, True, CurrentTime)) return -1;
}
if ((modifiers & (1u << 3)) && super_kc) {
if (!XTestFakeKeyEvent(g_dpy, super_kc, True, CurrentTime)) return -1;
}
if (!XTestFakeKeyEvent(g_dpy, kc, True, CurrentTime)) return -1;
XSync(g_dpy, False);
sleep_us(g_key_hold_us);
if (!XTestFakeKeyEvent(g_dpy, kc, False, CurrentTime)) return -1;
if ((modifiers & (1u << 3)) && super_kc) {
if (!XTestFakeKeyEvent(g_dpy, super_kc, False, CurrentTime)) return -1;
}
if ((modifiers & (1u << 2)) && alt_kc) {
if (!XTestFakeKeyEvent(g_dpy, alt_kc, False, CurrentTime)) return -1;
}
if ((modifiers & (1u << 0)) && shift_kc) {
if (!XTestFakeKeyEvent(g_dpy, shift_kc, False, CurrentTime)) return -1;
}
if ((modifiers & (1u << 1)) && ctrl_kc) {
if (!XTestFakeKeyEvent(g_dpy, ctrl_kc, False, CurrentTime)) return -1;
}
XSync(g_dpy, False);
sleep_us(g_delay_us);
return 0;
}
void typer_cleanup(void) {
g_target_window = None;
if (g_dpy) {
+1
View File
@@ -4,6 +4,7 @@
int typer_init(int type_delay_us);
int typer_capture_focus(void);
int typer_type_text(const char *text);
int typer_send_keycombo(unsigned int keyval, unsigned int modifiers);
void typer_cleanup(void);
#endif
+2 -2
View File
@@ -1,9 +1,9 @@
#ifndef VOICE_VERSION_H
#define VOICE_VERSION_H
#define VOICE_LINUX_VERSION "v0.0.3"
#define VOICE_LINUX_VERSION "v0.0.9"
#define VOICE_LINUX_VERSION_MAJOR 0
#define VOICE_LINUX_VERSION_MINOR 0
#define VOICE_LINUX_VERSION_PATCH 3
#define VOICE_LINUX_VERSION_PATCH 9
#endif
+379
View File
@@ -0,0 +1,379 @@
#include "voice_cmd.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static void trim(char *s) {
if (!s) return;
size_t len = strlen(s);
while (len > 0 && isspace((unsigned char)s[len - 1])) {
s[--len] = '\0';
}
char *p = s;
while (*p && isspace((unsigned char)*p)) p++;
if (p != s) {
memmove(s, p, strlen(p) + 1);
}
}
static int str_ieq_n(const char *a, const char *b, size_t n) {
for (size_t i = 0; i < n; ++i) {
unsigned char ca = (unsigned char)a[i];
unsigned char cb = (unsigned char)b[i];
if (tolower(ca) != tolower(cb)) return 0;
}
return 1;
}
static int is_word_char(unsigned char c) {
return isalnum(c) || c == '_';
}
static int matches_whole_word_ci(const char *text, size_t pos, const char *trig, size_t trig_len) {
if (!text || !trig || trig_len == 0) return 0;
if (!str_ieq_n(text + pos, trig, trig_len)) return 0;
unsigned char left = (pos == 0) ? 0 : (unsigned char)text[pos - 1];
unsigned char right = (unsigned char)text[pos + trig_len];
if (pos > 0 && is_word_char(left)) return 0;
if (right != '\0' && is_word_char(right)) return 0;
return 1;
}
static void unescape_copy(const char *src, char *dst, size_t dst_sz) {
if (!dst || dst_sz == 0) return;
dst[0] = '\0';
if (!src) return;
size_t w = 0;
for (size_t i = 0; src[i] != '\0' && w + 1 < dst_sz; ++i) {
if (src[i] == '\\' && src[i + 1] != '\0') {
i++;
char c = src[i];
if (c == 'n') dst[w++] = '\n';
else if (c == 't') dst[w++] = '\t';
else if (c == 'r') dst[w++] = '\r';
else dst[w++] = c;
} else {
dst[w++] = src[i];
}
}
dst[w] = '\0';
}
static void escape_copy(const char *src, char *dst, size_t dst_sz) {
if (!dst || dst_sz == 0) return;
dst[0] = '\0';
if (!src) return;
size_t w = 0;
for (size_t i = 0; src[i] != '\0' && w + 1 < dst_sz; ++i) {
char c = src[i];
const char *rep = NULL;
if (c == '\n') rep = "\\n";
else if (c == '\t') rep = "\\t";
else if (c == '\r') rep = "\\r";
else if (c == '\\') rep = "\\\\";
if (rep) {
for (size_t j = 0; rep[j] != '\0' && w + 1 < dst_sz; ++j) {
dst[w++] = rep[j];
}
} else {
dst[w++] = c;
}
}
dst[w] = '\0';
}
void voice_cmd_format_key_combo(unsigned int keyval, unsigned int modifiers, char *out, size_t out_sz) {
if (!out || out_sz == 0) return;
char keyname[64];
if (keyval >= 32 && keyval < 127) {
snprintf(keyname, sizeof(keyname), "%c", (char)keyval);
} else {
switch (keyval) {
case 65293: snprintf(keyname, sizeof(keyname), "Return"); break;
case 65289: snprintf(keyname, sizeof(keyname), "Tab"); break;
case 65307: snprintf(keyname, sizeof(keyname), "Escape"); break;
case 65535: snprintf(keyname, sizeof(keyname), "Delete"); break;
case 65288: snprintf(keyname, sizeof(keyname), "BackSpace"); break;
default: snprintf(keyname, sizeof(keyname), "Key%u", keyval); break;
}
}
char pref[96];
pref[0] = '\0';
if (modifiers & VOICE_CMD_MOD_CTRL) strncat(pref, "Ctrl+", sizeof(pref) - strlen(pref) - 1);
if (modifiers & VOICE_CMD_MOD_SHIFT) strncat(pref, "Shift+", sizeof(pref) - strlen(pref) - 1);
if (modifiers & VOICE_CMD_MOD_ALT) strncat(pref, "Alt+", sizeof(pref) - strlen(pref) - 1);
if (modifiers & VOICE_CMD_MOD_SUPER) strncat(pref, "Super+", sizeof(pref) - strlen(pref) - 1);
snprintf(out, out_sz, "%s%s", pref, keyname);
}
void voice_cmd_init(voice_cmd_list_t *list) {
if (!list) return;
memset(list, 0, sizeof(*list));
list->enabled = 1;
}
void voice_cmd_free(voice_cmd_list_t *list) {
if (!list) return;
free(list->cmds);
memset(list, 0, sizeof(*list));
list->enabled = 1;
}
static int ensure_capacity(voice_cmd_list_t *list, int min_cap) {
if (!list) return -1;
if (list->capacity >= min_cap) return 0;
int next = list->capacity > 0 ? list->capacity * 2 : 16;
if (next < min_cap) next = min_cap;
void *p = realloc(list->cmds, (size_t)next * sizeof(voice_cmd_t));
if (!p) return -1;
list->cmds = (voice_cmd_t *)p;
list->capacity = next;
return 0;
}
int voice_cmd_add(voice_cmd_list_t *list, const voice_cmd_t *cmd) {
if (!list || !cmd) return -1;
if (cmd->trigger[0] == '\0') return -2;
if (ensure_capacity(list, list->count + 1) != 0) return -3;
list->cmds[list->count++] = *cmd;
return 0;
}
int voice_cmd_remove(voice_cmd_list_t *list, int index) {
if (!list) return -1;
if (index < 0 || index >= list->count) return -2;
for (int i = index; i + 1 < list->count; ++i) {
list->cmds[i] = list->cmds[i + 1];
}
list->count--;
return 0;
}
int voice_cmd_seed_defaults(voice_cmd_list_t *list) {
if (!list) return -1;
static const struct {
const char *trigger;
const char *text;
} defs[] = {
{"period", "."},
{"question mark", "?"},
{"exclamation mark", "!"},
{"comma", ","},
{"colon", ":"},
{"semicolon", ";"},
{"new line", "\n"},
{"new paragraph", "\n\n"},
{"tab", "\t"},
{"open paren", "("},
{"close paren", ")"},
{"open bracket", "["},
{"close bracket", "]"},
{"open brace", "{"},
{"close brace", "}"},
};
for (size_t i = 0; i < sizeof(defs) / sizeof(defs[0]); ++i) {
voice_cmd_t c;
memset(&c, 0, sizeof(c));
snprintf(c.trigger, sizeof(c.trigger), "%s", defs[i].trigger);
c.type = VOICE_CMD_TEXT;
snprintf(c.text_value, sizeof(c.text_value), "%s", defs[i].text);
if (voice_cmd_add(list, &c) != 0) return -2;
}
voice_cmd_t over;
memset(&over, 0, sizeof(over));
snprintf(over.trigger, sizeof(over.trigger), "over");
over.type = VOICE_CMD_KEYCOMBO;
over.keyval = 65293;
over.modifiers = 0;
voice_cmd_format_key_combo(over.keyval, over.modifiers, over.display_key, sizeof(over.display_key));
if (voice_cmd_add(list, &over) != 0) return -3;
voice_cmd_t eoc;
memset(&eoc, 0, sizeof(eoc));
snprintf(eoc.trigger, sizeof(eoc.trigger), "end of command");
eoc.type = VOICE_CMD_KEYCOMBO;
eoc.keyval = 65293;
eoc.modifiers = VOICE_CMD_MOD_CTRL | VOICE_CMD_MOD_SHIFT;
voice_cmd_format_key_combo(eoc.keyval, eoc.modifiers, eoc.display_key, sizeof(eoc.display_key));
if (voice_cmd_add(list, &eoc) != 0) return -4;
return 0;
}
int voice_cmd_load(const char *path, voice_cmd_list_t *list) {
if (!path || !list) return -1;
FILE *f = fopen(path, "r");
if (!f) return -2;
voice_cmd_free(list);
voice_cmd_init(list);
char line[1024];
while (fgets(line, sizeof(line), f)) {
trim(line);
if (line[0] == '\0' || line[0] == '#') continue;
if (strncmp(line, "@enabled=", 9) == 0) {
list->enabled = (atoi(line + 9) != 0) ? 1 : 0;
continue;
}
char *p1 = strchr(line, '|');
if (!p1) continue;
*p1 = '\0';
char *p2 = strchr(p1 + 1, '|');
if (!p2) continue;
*p2 = '\0';
char *trigger = line;
char *type = p1 + 1;
char *value = p2 + 1;
trim(trigger);
trim(type);
trim(value);
if (trigger[0] == '\0') continue;
voice_cmd_t c;
memset(&c, 0, sizeof(c));
snprintf(c.trigger, sizeof(c.trigger), "%.127s", trigger);
if (strcmp(type, "text") == 0) {
c.type = VOICE_CMD_TEXT;
unescape_copy(value, c.text_value, sizeof(c.text_value));
} else if (strcmp(type, "key") == 0) {
c.type = VOICE_CMD_KEYCOMBO;
unsigned int k = 0, m = 0;
if (sscanf(value, "%u:%u", &k, &m) != 2) continue;
c.keyval = k;
c.modifiers = m;
voice_cmd_format_key_combo(c.keyval, c.modifiers, c.display_key, sizeof(c.display_key));
} else {
continue;
}
if (voice_cmd_add(list, &c) != 0) {
fclose(f);
return -3;
}
}
fclose(f);
return 0;
}
int voice_cmd_save(const char *path, const voice_cmd_list_t *list) {
if (!path || !list) return -1;
FILE *f = fopen(path, "w");
if (!f) return -2;
fprintf(f, "# voice command rules\n");
fprintf(f, "# @enabled=1 or @enabled=0 controls global enable state\n");
fprintf(f, "# trigger|type|value\n");
fprintf(f, "# type=text => value supports escaped \\n, \\t, \\\\ \n");
fprintf(f, "# type=key => value format keyval:modifiers\n\n");
fprintf(f, "@enabled=%d\n\n", list->enabled ? 1 : 0);
for (int i = 0; i < list->count; ++i) {
const voice_cmd_t *c = &list->cmds[i];
if (c->type == VOICE_CMD_TEXT) {
char esc[512];
escape_copy(c->text_value, esc, sizeof(esc));
fprintf(f, "%s|text|%s\n", c->trigger, esc);
} else {
fprintf(f, "%s|key|%u:%u\n", c->trigger, c->keyval, c->modifiers);
}
}
fclose(f);
return 0;
}
char *voice_cmd_apply(const char *input,
const voice_cmd_list_t *list,
voice_cmd_key_callback_t key_cb,
void *user_data) {
if (!input) return NULL;
size_t in_len = strlen(input);
size_t cap = in_len * 2 + 64;
char *out = (char *)malloc(cap);
if (!out) return NULL;
size_t w = 0;
size_t i = 0;
while (i < in_len) {
int best_idx = -1;
size_t best_len = 0;
if (list && list->enabled) {
for (int ci = 0; ci < list->count; ++ci) {
const voice_cmd_t *c = &list->cmds[ci];
size_t tl = strlen(c->trigger);
if (tl == 0 || i + tl > in_len) continue;
if (!matches_whole_word_ci(input, i, c->trigger, tl)) continue;
if (tl > best_len) {
best_len = tl;
best_idx = ci;
}
}
}
if (best_idx >= 0) {
const voice_cmd_t *c = &list->cmds[best_idx];
if (c->type == VOICE_CMD_TEXT) {
size_t repl = strlen(c->text_value);
if (w + repl + 1 > cap) {
size_t next = (cap * 2) + repl + 32;
char *np = (char *)realloc(out, next);
if (!np) {
free(out);
return NULL;
}
out = np;
cap = next;
}
memcpy(out + w, c->text_value, repl);
w += repl;
} else if (key_cb) {
key_cb(c->keyval, c->modifiers, user_data);
}
i += best_len;
continue;
}
if (w + 2 > cap) {
size_t next = cap * 2;
char *np = (char *)realloc(out, next);
if (!np) {
free(out);
return NULL;
}
out = np;
cap = next;
}
out[w++] = input[i++];
}
out[w] = '\0';
return out;
}
+61
View File
@@ -0,0 +1,61 @@
#ifndef VOICE_VOICE_CMD_H
#define VOICE_VOICE_CMD_H
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
VOICE_CMD_TEXT = 0,
VOICE_CMD_KEYCOMBO = 1,
} voice_cmd_type_t;
enum {
VOICE_CMD_MOD_SHIFT = 1 << 0,
VOICE_CMD_MOD_CTRL = 1 << 1,
VOICE_CMD_MOD_ALT = 1 << 2,
VOICE_CMD_MOD_SUPER = 1 << 3,
};
typedef struct {
char trigger[128];
voice_cmd_type_t type;
char text_value[256];
unsigned int keyval;
unsigned int modifiers;
char display_key[128];
} voice_cmd_t;
typedef struct {
voice_cmd_t *cmds;
int count;
int capacity;
int enabled;
} voice_cmd_list_t;
typedef int (*voice_cmd_key_callback_t)(unsigned int keyval, unsigned int modifiers, void *user_data);
void voice_cmd_init(voice_cmd_list_t *list);
void voice_cmd_free(voice_cmd_list_t *list);
int voice_cmd_add(voice_cmd_list_t *list, const voice_cmd_t *cmd);
int voice_cmd_remove(voice_cmd_list_t *list, int index);
int voice_cmd_load(const char *path, voice_cmd_list_t *list);
int voice_cmd_save(const char *path, const voice_cmd_list_t *list);
int voice_cmd_seed_defaults(voice_cmd_list_t *list);
char *voice_cmd_apply(const char *input,
const voice_cmd_list_t *list,
voice_cmd_key_callback_t key_cb,
void *user_data);
void voice_cmd_format_key_combo(unsigned int keyval,
unsigned int modifiers,
char *out,
size_t out_sz);
#ifdef __cplusplus
}
#endif
#endif
+25
View File
@@ -0,0 +1,25 @@
# voice command rules
# @enabled=1 or @enabled=0 controls global enable state
# trigger|type|value
# type=text => value supports escaped \n, \t, \\
# type=key => value format keyval:modifiers
@enabled=1
period|text|.
question mark|text|?
exclamation mark|text|!
comma|text|,
colon|text|:
semicolon|text|;
new line|text|\n
new paragraph|text|\n\n
tab|text|\t
open paren|text|(
close paren|text|)
open bracket|text|[
close bracket|text|]
open brace|text|{
close brace|text|}
end of command|key|65293:3
over.|key|65293:2
BIN
View File
Binary file not shown.