6 Commits
17 changed files with 2233 additions and 58 deletions
+2 -1
View File
@@ -21,7 +21,8 @@ SRC := \
./src/config.c \
./src/audio.c \
./src/transcribe.c \
./src/typer.c
./src/typer.c \
./src/voice_cmd.c
TARGET := ./voice_linux
+1 -1
View File
@@ -126,7 +126,7 @@ if [[ "$WITH_WHISPER" == "1" ]]; then
fi
gcc "${APP_CFLAGS[@]}" \
./src/gui_main.c ./src/config.c ./src/audio.c ./src/transcribe.c ./src/typer.c \
./src/gui_main.c ./src/config.c ./src/audio.c ./src/transcribe.c ./src/typer.c ./src/voice_cmd.c \
-o ./voice_linux \
"${APP_LDFLAGS[@]}"
+37 -5
View File
@@ -2,7 +2,7 @@
# 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
@@ -11,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
+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
+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
+169
View File
@@ -39,6 +39,43 @@ void config_set_defaults(voice_config_t *cfg) {
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) {
@@ -97,9 +134,141 @@ int config_load_file(const char *path, voice_config_t *cfg) {
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;
}
+38
View File
@@ -22,9 +22,47 @@ typedef struct {
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
+915 -40
View File
File diff suppressed because it is too large Load Diff
+57 -4
View File
@@ -80,6 +80,49 @@ void transcribe_set_filter_bracketed_tags(int enabled) {
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;
@@ -95,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");
@@ -161,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;
+38
View File
@@ -9,9 +9,47 @@ typedef struct {
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.6"
#define VOICE_LINUX_VERSION "v0.0.9"
#define VOICE_LINUX_VERSION_MAJOR 0
#define VOICE_LINUX_VERSION_MINOR 0
#define VOICE_LINUX_VERSION_PATCH 6
#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