diff --git a/Makefile b/Makefile index 2f883ab..23afcfa 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/build.sh b/build.sh index ad1a479..0d182bc 100755 --- a/build.sh +++ b/build.sh @@ -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[@]}" diff --git a/config.ini b/config.ini index 287f751..77ae408 100644 --- a/config.ini +++ b/config.ini @@ -16,7 +16,7 @@ always_on=1 always_on_window_ms=1400 # live level threshold (0.0 - 1.0) -vad_peak_threshold=0.0060 +vad_peak_threshold=0.0460 # level must stay above threshold this long before speech starts vad_trigger_ms=100 # level must stay below threshold this long before speech ends @@ -29,4 +29,35 @@ vad_min_speech_ms=250 vad_max_speech_ms=15000 autotype_enabled=1 -filter_bracketed_tags=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 diff --git a/plans/gpu_passthrough_lessons_learned.md b/plans/gpu_passthrough_lessons_learned.md new file mode 100644 index 0000000..3779de7 --- /dev/null +++ b/plans/gpu_passthrough_lessons_learned.md @@ -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 diff --git a/plans/voice_commands_plan.md b/plans/voice_commands_plan.md new file mode 100644 index 0000000..22ebd5b --- /dev/null +++ b/plans/voice_commands_plan.md @@ -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 diff --git a/src/gui_main.c b/src/gui_main.c index 77cad21..8a71244 100644 --- a/src/gui_main.c +++ b/src/gui_main.c @@ -3,6 +3,7 @@ #include "transcribe.h" #include "typer.h" #include "version.h" +#include "voice_cmd.h" #include @@ -67,6 +68,24 @@ typedef struct { GtkWidget *logprob_thold_spin; GtkWidget *no_speech_thold_spin; GtkWidget *beam_patience_spin; + GtkWidget *whisper_help_label; + + voice_cmd_list_t voice_cmds; + char voice_cmd_path[512]; + GtkListStore *cmd_store; + GtkWidget *cmd_tree; + GtkWidget *cmd_enable_check; + GtkWidget *cmd_trigger_entry; + GtkWidget *cmd_text_entry; + GtkWidget *cmd_mode_text_radio; + GtkWidget *cmd_mode_key_radio; + GtkWidget *cmd_capture_btn; + GtkWidget *cmd_capture_label; + GtkWidget *cmd_add_btn; + GtkWidget *cmd_delete_btn; + unsigned int cmd_capture_keyval; + unsigned int cmd_capture_mods; + int cmd_capture_armed; voice_config_t cfg; int use_gpu; @@ -82,6 +101,13 @@ typedef struct { char cfg_path[512]; } gui_state_t; +enum { + CMD_COL_TRIGGER = 0, + CMD_COL_TYPE, + CMD_COL_ACTION, + CMD_COL_COUNT +}; + static float clampf(float v, float lo, float hi) { if (v < lo) return lo; if (v > hi) return hi; @@ -296,6 +322,16 @@ static void remember_transcript(gui_state_t *s, const char *text) { s->last_transcript_time = monotonic_seconds(); } +static int voice_cmd_key_emit_cb(unsigned int keyval, unsigned int modifiers, void *user_data) { + gui_state_t *s = (gui_state_t *)user_data; + if (!s) return -1; + if (typer_send_keycombo(keyval, modifiers) != 0) { + fprintf(stderr, "gui: voice command key combo failed (key=%u mods=%u)\n", keyval, modifiers); + return -1; + } + return 0; +} + static void process_segment(gui_state_t *s, float *samples, size_t count) { if (!samples || count == 0) { free(samples); @@ -339,27 +375,41 @@ static void process_segment(gui_state_t *s, float *samples, size_t count) { return; } - if (is_duplicate_transcript(s, text)) { + char *processed = voice_cmd_apply(text, + &s->voice_cmds, + should_autotype ? voice_cmd_key_emit_cb : NULL, + s); + if (!processed) { + processed = text; + text = NULL; + } + + if (is_duplicate_transcript(s, processed)) { fprintf(stderr, "gui: suppressed duplicate transcript within 1s window\n"); set_status(s, "Duplicate segment suppressed"); free(text); + free(processed); return; } - append_transcript(s, text); + if (processed[0] != '\0') { + append_transcript(s, processed); + } - if (should_autotype) { - if (typer_type_text(text) != 0) { + if (should_autotype && processed[0] != '\0') { + if (typer_type_text(processed) != 0) { fprintf(stderr, "gui: autotype injection failed\n"); set_status(s, "Autotype failed (see terminal logs)"); free(text); + free(processed); return; } } - remember_transcript(s, text); + remember_transcript(s, processed); set_status(s, audio_msg); free(text); + free(processed); } static int read_scale_int(GtkWidget *scale) { @@ -656,6 +706,35 @@ static void on_whisper_combo_changed(GtkComboBox *box, gpointer user_data) { apply_whisper_settings_from_widgets(s); } +static void set_whisper_help_text(gui_state_t *s, const char *text) { + if (!s || !s->whisper_help_label) return; + gtk_label_set_text(GTK_LABEL(s->whisper_help_label), text ? text : ""); +} + +static gboolean on_whisper_help_enter(GtkWidget *widget, GdkEventCrossing *event, gpointer user_data) { + (void)event; + gui_state_t *s = (gui_state_t *)user_data; + const char *text = (const char *)g_object_get_data(G_OBJECT(widget), "whisper-help-text"); + set_whisper_help_text(s, text ? text : "No help available for this item."); + return FALSE; +} + +static gboolean on_whisper_help_leave(GtkWidget *widget, GdkEventCrossing *event, gpointer user_data) { + (void)widget; + (void)event; + gui_state_t *s = (gui_state_t *)user_data; + set_whisper_help_text(s, "Hover any control to see what it does."); + return FALSE; +} + +static void attach_whisper_help(gui_state_t *s, GtkWidget *widget, const char *help_text) { + if (!s || !widget || !help_text) return; + g_object_set_data(G_OBJECT(widget), "whisper-help-text", (gpointer)help_text); + gtk_widget_add_events(widget, GDK_ENTER_NOTIFY_MASK | GDK_LEAVE_NOTIFY_MASK); + g_signal_connect(widget, "enter-notify-event", G_CALLBACK(on_whisper_help_enter), s); + g_signal_connect(widget, "leave-notify-event", G_CALLBACK(on_whisper_help_leave), s); +} + static GtkWidget *add_spin_row(GtkWidget *grid, int col, int row, @@ -665,6 +744,7 @@ static GtkWidget *add_spin_row(GtkWidget *grid, double step, double value, int digits, + const char *help_text, gui_state_t *s, GtkWidget **out_spin) { GtkWidget *label = gtk_label_new(label_text); @@ -678,6 +758,8 @@ static GtkWidget *add_spin_row(GtkWidget *grid, gtk_grid_attach(GTK_GRID(grid), spin, col + 1, row, 1, 1); g_signal_connect(spin, "value-changed", G_CALLBACK(on_whisper_spin_changed), s); + attach_whisper_help(s, label, help_text); + attach_whisper_help(s, spin, help_text); *out_spin = spin; return spin; } @@ -711,56 +793,68 @@ static GtkWidget *build_whisper_settings_tab(gui_state_t *s) { gtk_combo_box_set_active(GTK_COMBO_BOX(s->sampling_combo), s->cfg.whisper_sampling_strategy ? 1 : 0); g_signal_connect(s->sampling_combo, "changed", G_CALLBACK(on_whisper_combo_changed), s); gtk_grid_attach(GTK_GRID(checks), s->sampling_combo, 1, 0, 1, 1); + attach_whisper_help(s, sampling_label, "Choose decoder search method. Greedy is faster; Beam Search may improve quality at higher compute cost."); + attach_whisper_help(s, s->sampling_combo, "Choose decoder search method. Greedy is faster; Beam Search may improve quality at higher compute cost."); s->translate_check = gtk_check_button_new_with_label("Translate to English"); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(s->translate_check), s->cfg.whisper_translate ? TRUE : FALSE); g_signal_connect(s->translate_check, "toggled", G_CALLBACK(on_whisper_toggle_changed), s); gtk_grid_attach(GTK_GRID(checks), s->translate_check, 0, 1, 1, 1); + attach_whisper_help(s, s->translate_check, "Translate detected speech to English output instead of transcribing in-source language."); s->detect_language_check = gtk_check_button_new_with_label("Auto detect language"); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(s->detect_language_check), s->cfg.whisper_detect_language ? TRUE : FALSE); g_signal_connect(s->detect_language_check, "toggled", G_CALLBACK(on_whisper_toggle_changed), s); gtk_grid_attach(GTK_GRID(checks), s->detect_language_check, 1, 1, 1, 1); + attach_whisper_help(s, s->detect_language_check, "Enable automatic language detection each decode call."); s->no_context_check = gtk_check_button_new_with_label("No context"); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(s->no_context_check), s->cfg.whisper_no_context ? TRUE : FALSE); g_signal_connect(s->no_context_check, "toggled", G_CALLBACK(on_whisper_toggle_changed), s); gtk_grid_attach(GTK_GRID(checks), s->no_context_check, 0, 2, 1, 1); + attach_whisper_help(s, s->no_context_check, "Disable conditioning on previous text context. Can reduce drift but may reduce continuity."); s->no_timestamps_check = gtk_check_button_new_with_label("No timestamps"); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(s->no_timestamps_check), s->cfg.whisper_no_timestamps ? TRUE : FALSE); g_signal_connect(s->no_timestamps_check, "toggled", G_CALLBACK(on_whisper_toggle_changed), s); gtk_grid_attach(GTK_GRID(checks), s->no_timestamps_check, 1, 2, 1, 1); + attach_whisper_help(s, s->no_timestamps_check, "Disable timestamp generation. Usually faster and cleaner for plain transcript text."); s->single_segment_check = gtk_check_button_new_with_label("Single segment"); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(s->single_segment_check), s->cfg.whisper_single_segment ? TRUE : FALSE); g_signal_connect(s->single_segment_check, "toggled", G_CALLBACK(on_whisper_toggle_changed), s); gtk_grid_attach(GTK_GRID(checks), s->single_segment_check, 0, 3, 1, 1); + attach_whisper_help(s, s->single_segment_check, "Force one segment output per decode call; useful for streaming-like behavior."); s->token_timestamps_check = gtk_check_button_new_with_label("Token timestamps"); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(s->token_timestamps_check), s->cfg.whisper_token_timestamps ? TRUE : FALSE); g_signal_connect(s->token_timestamps_check, "toggled", G_CALLBACK(on_whisper_toggle_changed), s); gtk_grid_attach(GTK_GRID(checks), s->token_timestamps_check, 1, 3, 1, 1); + attach_whisper_help(s, s->token_timestamps_check, "Enable token-level timestamp estimates (experimental)."); s->split_on_word_check = gtk_check_button_new_with_label("Split on word"); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(s->split_on_word_check), s->cfg.whisper_split_on_word ? TRUE : FALSE); g_signal_connect(s->split_on_word_check, "toggled", G_CALLBACK(on_whisper_toggle_changed), s); gtk_grid_attach(GTK_GRID(checks), s->split_on_word_check, 0, 4, 1, 1); + attach_whisper_help(s, s->split_on_word_check, "When max_len is set, prefer splitting at word boundaries."); s->suppress_blank_check = gtk_check_button_new_with_label("Suppress blank tokens"); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(s->suppress_blank_check), s->cfg.whisper_suppress_blank ? TRUE : FALSE); g_signal_connect(s->suppress_blank_check, "toggled", G_CALLBACK(on_whisper_toggle_changed), s); gtk_grid_attach(GTK_GRID(checks), s->suppress_blank_check, 1, 4, 1, 1); + attach_whisper_help(s, s->suppress_blank_check, "Suppress blank token sampling to reduce empty/no-op outputs."); s->suppress_nst_check = gtk_check_button_new_with_label("Suppress non-speech tokens"); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(s->suppress_nst_check), s->cfg.whisper_suppress_nst ? TRUE : FALSE); g_signal_connect(s->suppress_nst_check, "toggled", G_CALLBACK(on_whisper_toggle_changed), s); gtk_grid_attach(GTK_GRID(checks), s->suppress_nst_check, 0, 5, 1, 1); + attach_whisper_help(s, s->suppress_nst_check, "Suppress non-speech tokens like music/noise annotations."); s->tdrz_enable_check = gtk_check_button_new_with_label("Enable tinydiarize (TDRZ)"); gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(s->tdrz_enable_check), s->cfg.whisper_tdrz_enable ? TRUE : FALSE); g_signal_connect(s->tdrz_enable_check, "toggled", G_CALLBACK(on_whisper_toggle_changed), s); gtk_grid_attach(GTK_GRID(checks), s->tdrz_enable_check, 1, 5, 1, 1); + attach_whisper_help(s, s->tdrz_enable_check, "Enable TinyDiarize speaker-turn hints (experimental)."); GtkWidget *grid = gtk_grid_new(); gtk_grid_set_row_spacing(GTK_GRID(grid), 6); @@ -768,46 +862,336 @@ static GtkWidget *build_whisper_settings_tab(gui_state_t *s) { gtk_box_pack_start(GTK_BOX(box), grid, FALSE, FALSE, 0); add_spin_row(grid, 0, 0, "n_max_text_ctx", 0.0, 32768.0, 1.0, - (double)s->cfg.whisper_n_max_text_ctx, 0, s, &s->n_max_text_ctx_spin); + (double)s->cfg.whisper_n_max_text_ctx, 0, + "Maximum number of previous tokens used as prompt context for decoding.", + s, &s->n_max_text_ctx_spin); add_spin_row(grid, 2, 0, "offset_ms", 0.0, 60000.0, 10.0, - (double)s->cfg.whisper_offset_ms, 0, s, &s->offset_ms_spin); + (double)s->cfg.whisper_offset_ms, 0, + "Start decoding from this time offset (milliseconds) inside the audio chunk.", + s, &s->offset_ms_spin); add_spin_row(grid, 0, 1, "duration_ms (0=all)", 0.0, 600000.0, 100.0, - (double)s->cfg.whisper_duration_ms, 0, s, &s->duration_ms_spin); + (double)s->cfg.whisper_duration_ms, 0, + "Decode only this duration in milliseconds (0 means full available audio).", + s, &s->duration_ms_spin); add_spin_row(grid, 2, 1, "max_len", 0.0, 1024.0, 1.0, - (double)s->cfg.whisper_max_len, 0, s, &s->max_len_spin); + (double)s->cfg.whisper_max_len, 0, + "Maximum output segment length in characters before split logic applies.", + s, &s->max_len_spin); add_spin_row(grid, 0, 2, "max_tokens", 0.0, 1024.0, 1.0, - (double)s->cfg.whisper_max_tokens, 0, s, &s->max_tokens_spin); + (double)s->cfg.whisper_max_tokens, 0, + "Maximum tokens per segment (0 means no explicit token cap).", + s, &s->max_tokens_spin); add_spin_row(grid, 2, 2, "audio_ctx", 0.0, 4096.0, 1.0, - (double)s->cfg.whisper_audio_ctx, 0, s, &s->audio_ctx_spin); + (double)s->cfg.whisper_audio_ctx, 0, + "Override internal audio context window size (0 keeps model default).", + s, &s->audio_ctx_spin); add_spin_row(grid, 0, 3, "thold_pt", 0.0, 1.0, 0.001, - (double)s->cfg.whisper_thold_pt, 3, s, &s->thold_pt_spin); + (double)s->cfg.whisper_thold_pt, 3, + "Token timestamp probability threshold for token-level timing.", + s, &s->thold_pt_spin); add_spin_row(grid, 2, 3, "thold_ptsum", 0.0, 1.0, 0.001, - (double)s->cfg.whisper_thold_ptsum, 3, s, &s->thold_ptsum_spin); + (double)s->cfg.whisper_thold_ptsum, 3, + "Cumulative timestamp probability threshold for token-level timing.", + s, &s->thold_ptsum_spin); add_spin_row(grid, 0, 4, "temperature", 0.0, 2.0, 0.01, - (double)s->cfg.whisper_temperature, 2, s, &s->temperature_spin); + (double)s->cfg.whisper_temperature, 2, + "Initial sampling temperature. Lower is more deterministic, higher is more diverse.", + s, &s->temperature_spin); add_spin_row(grid, 2, 4, "max_initial_ts", 0.0, 5.0, 0.01, - (double)s->cfg.whisper_max_initial_ts, 2, s, &s->max_initial_ts_spin); + (double)s->cfg.whisper_max_initial_ts, 2, + "Maximum initial timestamp allowed for first decoded tokens.", + s, &s->max_initial_ts_spin); add_spin_row(grid, 0, 5, "length_penalty", -2.0, 2.0, 0.01, - (double)s->cfg.whisper_length_penalty, 2, s, &s->length_penalty_spin); + (double)s->cfg.whisper_length_penalty, 2, + "Penalty/boost for longer outputs during decoding (-1 keeps default behavior).", + s, &s->length_penalty_spin); add_spin_row(grid, 2, 5, "temperature_inc", 0.0, 1.0, 0.01, - (double)s->cfg.whisper_temperature_inc, 2, s, &s->temperature_inc_spin); + (double)s->cfg.whisper_temperature_inc, 2, + "Fallback temperature increment used when decode quality checks fail.", + s, &s->temperature_inc_spin); add_spin_row(grid, 0, 6, "entropy_thold", -5.0, 10.0, 0.01, - (double)s->cfg.whisper_entropy_thold, 2, s, &s->entropy_thold_spin); + (double)s->cfg.whisper_entropy_thold, 2, + "Fallback entropy threshold controlling when alternate decode attempts are triggered.", + s, &s->entropy_thold_spin); add_spin_row(grid, 2, 6, "logprob_thold", -5.0, 1.0, 0.01, - (double)s->cfg.whisper_logprob_thold, 2, s, &s->logprob_thold_spin); + (double)s->cfg.whisper_logprob_thold, 2, + "Fallback average log-probability threshold for accepting a decode pass.", + s, &s->logprob_thold_spin); add_spin_row(grid, 0, 7, "no_speech_thold", 0.0, 1.0, 0.01, - (double)s->cfg.whisper_no_speech_thold, 2, s, &s->no_speech_thold_spin); + (double)s->cfg.whisper_no_speech_thold, 2, + "Probability threshold for considering a chunk as no-speech.", + s, &s->no_speech_thold_spin); add_spin_row(grid, 2, 7, "greedy_best_of", 1.0, 10.0, 1.0, - (double)s->cfg.whisper_greedy_best_of, 0, s, &s->greedy_best_of_spin); + (double)s->cfg.whisper_greedy_best_of, 0, + "Number of candidates sampled in greedy fallback mode; best candidate is selected.", + s, &s->greedy_best_of_spin); add_spin_row(grid, 0, 8, "beam_size", 1.0, 20.0, 1.0, - (double)s->cfg.whisper_beam_size, 0, s, &s->beam_size_spin); + (double)s->cfg.whisper_beam_size, 0, + "Beam width used by beam search strategy.", + s, &s->beam_size_spin); add_spin_row(grid, 2, 8, "beam_patience", -1.0, 5.0, 0.01, - (double)s->cfg.whisper_beam_patience, 2, s, &s->beam_patience_spin); + (double)s->cfg.whisper_beam_patience, 2, + "Beam search patience (experimental; -1 uses library default behavior).", + s, &s->beam_patience_spin); + + GtkWidget *help_frame = gtk_frame_new("Help"); + gtk_box_pack_end(GTK_BOX(box), help_frame, FALSE, FALSE, 0); + + GtkWidget *help_inner = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_container_set_border_width(GTK_CONTAINER(help_inner), 8); + gtk_container_add(GTK_CONTAINER(help_frame), help_inner); + + s->whisper_help_label = gtk_label_new("Hover any control to see what it does."); + gtk_label_set_xalign(GTK_LABEL(s->whisper_help_label), 0.0f); + gtk_label_set_line_wrap(GTK_LABEL(s->whisper_help_label), TRUE); + gtk_label_set_max_width_chars(GTK_LABEL(s->whisper_help_label), 90); + gtk_box_pack_start(GTK_BOX(help_inner), s->whisper_help_label, FALSE, FALSE, 0); return scroll; } +static void voice_cmd_refresh_store(gui_state_t *s) { + if (!s || !s->cmd_store) return; + + gtk_list_store_clear(s->cmd_store); + for (int i = 0; i < s->voice_cmds.count; ++i) { + const voice_cmd_t *c = &s->voice_cmds.cmds[i]; + const char *type_txt = (c->type == VOICE_CMD_TEXT) ? "Text" : "Key"; + const char *action_txt = (c->type == VOICE_CMD_TEXT) ? c->text_value : c->display_key; + + GtkTreeIter it; + gtk_list_store_append(s->cmd_store, &it); + gtk_list_store_set(s->cmd_store, &it, + CMD_COL_TRIGGER, c->trigger, + CMD_COL_TYPE, type_txt, + CMD_COL_ACTION, action_txt, + -1); + } +} + +static int voice_cmd_load_or_seed(gui_state_t *s) { + if (!s) return -1; + if (voice_cmd_load(s->voice_cmd_path, &s->voice_cmds) == 0) { + return 0; + } + + voice_cmd_free(&s->voice_cmds); + voice_cmd_init(&s->voice_cmds); + if (voice_cmd_seed_defaults(&s->voice_cmds) != 0) return -2; + if (voice_cmd_save(s->voice_cmd_path, &s->voice_cmds) != 0) return -3; + return 0; +} + +static unsigned int voice_cmd_mods_from_gdk(guint state) { + unsigned int mods = 0; + if (state & GDK_SHIFT_MASK) mods |= VOICE_CMD_MOD_SHIFT; + if (state & GDK_CONTROL_MASK) mods |= VOICE_CMD_MOD_CTRL; + if (state & GDK_MOD1_MASK) mods |= VOICE_CMD_MOD_ALT; + if (state & GDK_SUPER_MASK) mods |= VOICE_CMD_MOD_SUPER; + return mods; +} + +static gboolean on_window_key_press(GtkWidget *widget, GdkEventKey *event, gpointer user_data) { + (void)widget; + gui_state_t *s = (gui_state_t *)user_data; + if (!s || !event) return FALSE; + + if (!s->cmd_capture_armed) return FALSE; + + s->cmd_capture_keyval = (unsigned int)event->keyval; + s->cmd_capture_mods = voice_cmd_mods_from_gdk(event->state); + s->cmd_capture_armed = 0; + + char label[128]; + voice_cmd_format_key_combo(s->cmd_capture_keyval, s->cmd_capture_mods, label, sizeof(label)); + gtk_label_set_text(GTK_LABEL(s->cmd_capture_label), label); + gtk_button_set_label(GTK_BUTTON(s->cmd_capture_btn), "Capture"); + return TRUE; +} + +static void on_cmd_capture_clicked(GtkButton *btn, gpointer user_data) { + gui_state_t *s = (gui_state_t *)user_data; + if (!s) return; + + s->cmd_capture_armed = 1; + s->cmd_capture_keyval = 0; + s->cmd_capture_mods = 0; + gtk_label_set_text(GTK_LABEL(s->cmd_capture_label), "Press key combo now..."); + gtk_button_set_label(btn, "Waiting..."); +} + +static void on_cmd_enable_toggled(GtkToggleButton *btn, gpointer user_data) { + gui_state_t *s = (gui_state_t *)user_data; + if (!s) return; + s->voice_cmds.enabled = gtk_toggle_button_get_active(btn) ? 1 : 0; + voice_cmd_save(s->voice_cmd_path, &s->voice_cmds); +} + +static void on_cmd_mode_toggled(GtkToggleButton *btn, gpointer user_data) { + (void)btn; + gui_state_t *s = (gui_state_t *)user_data; + if (!s) return; + + int text_mode = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(s->cmd_mode_text_radio)) ? 1 : 0; + gtk_widget_set_sensitive(s->cmd_text_entry, text_mode ? TRUE : FALSE); + gtk_widget_set_sensitive(s->cmd_capture_btn, text_mode ? FALSE : TRUE); +} + +static void on_cmd_add_clicked(GtkButton *btn, gpointer user_data) { + (void)btn; + gui_state_t *s = (gui_state_t *)user_data; + if (!s) return; + + const char *trigger = gtk_entry_get_text(GTK_ENTRY(s->cmd_trigger_entry)); + if (!trigger || trigger[0] == '\0') { + set_status(s, "Voice command trigger cannot be empty"); + return; + } + + voice_cmd_t c; + memset(&c, 0, sizeof(c)); + snprintf(c.trigger, sizeof(c.trigger), "%s", trigger); + + int text_mode = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(s->cmd_mode_text_radio)) ? 1 : 0; + if (text_mode) { + c.type = VOICE_CMD_TEXT; + const char *v = gtk_entry_get_text(GTK_ENTRY(s->cmd_text_entry)); + if (!v) v = ""; + snprintf(c.text_value, sizeof(c.text_value), "%s", v); + } else { + c.type = VOICE_CMD_KEYCOMBO; + c.keyval = s->cmd_capture_keyval; + c.modifiers = s->cmd_capture_mods; + if (c.keyval == 0) { + set_status(s, "Capture a key combination first"); + return; + } + voice_cmd_format_key_combo(c.keyval, c.modifiers, c.display_key, sizeof(c.display_key)); + } + + if (voice_cmd_add(&s->voice_cmds, &c) != 0) { + set_status(s, "Failed to add voice command"); + return; + } + + voice_cmd_save(s->voice_cmd_path, &s->voice_cmds); + voice_cmd_refresh_store(s); + + gtk_entry_set_text(GTK_ENTRY(s->cmd_trigger_entry), ""); + gtk_entry_set_text(GTK_ENTRY(s->cmd_text_entry), ""); + s->cmd_capture_keyval = 0; + s->cmd_capture_mods = 0; + gtk_label_set_text(GTK_LABEL(s->cmd_capture_label), "No key captured"); + set_status(s, "Voice command added"); +} + +static void on_cmd_delete_clicked(GtkButton *btn, gpointer user_data) { + (void)btn; + gui_state_t *s = (gui_state_t *)user_data; + if (!s || !s->cmd_tree) return; + + GtkTreeSelection *sel = gtk_tree_view_get_selection(GTK_TREE_VIEW(s->cmd_tree)); + GtkTreeModel *model = NULL; + GtkTreeIter it; + if (!gtk_tree_selection_get_selected(sel, &model, &it)) { + set_status(s, "Select a command to delete"); + return; + } + + GtkTreePath *path = gtk_tree_model_get_path(model, &it); + if (!path) return; + + int *indices = gtk_tree_path_get_indices(path); + if (!indices) { + gtk_tree_path_free(path); + return; + } + + int idx = indices[0]; + gtk_tree_path_free(path); + + if (voice_cmd_remove(&s->voice_cmds, idx) != 0) { + set_status(s, "Failed to delete command"); + return; + } + + voice_cmd_save(s->voice_cmd_path, &s->voice_cmds); + voice_cmd_refresh_store(s); + set_status(s, "Voice command deleted"); +} + +static GtkWidget *build_voice_commands_tab(gui_state_t *s) { + GtkWidget *outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_container_set_border_width(GTK_CONTAINER(outer), 10); + + s->cmd_enable_check = gtk_check_button_new_with_label("Enable voice commands"); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(s->cmd_enable_check), s->voice_cmds.enabled ? TRUE : FALSE); + g_signal_connect(s->cmd_enable_check, "toggled", G_CALLBACK(on_cmd_enable_toggled), s); + gtk_box_pack_start(GTK_BOX(outer), s->cmd_enable_check, FALSE, FALSE, 0); + + s->cmd_store = gtk_list_store_new(CMD_COL_COUNT, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING); + s->cmd_tree = gtk_tree_view_new_with_model(GTK_TREE_MODEL(s->cmd_store)); + + GtkCellRenderer *r = gtk_cell_renderer_text_new(); + gtk_tree_view_insert_column_with_attributes(GTK_TREE_VIEW(s->cmd_tree), -1, "Trigger", r, "text", CMD_COL_TRIGGER, NULL); + gtk_tree_view_insert_column_with_attributes(GTK_TREE_VIEW(s->cmd_tree), -1, "Type", r, "text", CMD_COL_TYPE, NULL); + gtk_tree_view_insert_column_with_attributes(GTK_TREE_VIEW(s->cmd_tree), -1, "Action", r, "text", CMD_COL_ACTION, NULL); + + GtkWidget *scroll = gtk_scrolled_window_new(NULL, NULL); + gtk_widget_set_size_request(scroll, -1, 180); + gtk_container_add(GTK_CONTAINER(scroll), s->cmd_tree); + gtk_box_pack_start(GTK_BOX(outer), scroll, TRUE, TRUE, 0); + + GtkWidget *del_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + s->cmd_delete_btn = gtk_button_new_with_label("Delete Selected"); + g_signal_connect(s->cmd_delete_btn, "clicked", G_CALLBACK(on_cmd_delete_clicked), s); + gtk_box_pack_end(GTK_BOX(del_row), s->cmd_delete_btn, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(outer), del_row, FALSE, FALSE, 0); + + GtkWidget *frame = gtk_frame_new("Add New Command"); + gtk_box_pack_start(GTK_BOX(outer), frame, FALSE, FALSE, 0); + + GtkWidget *grid = gtk_grid_new(); + gtk_container_set_border_width(GTK_CONTAINER(grid), 8); + gtk_grid_set_row_spacing(GTK_GRID(grid), 6); + gtk_grid_set_column_spacing(GTK_GRID(grid), 8); + gtk_container_add(GTK_CONTAINER(frame), grid); + + gtk_grid_attach(GTK_GRID(grid), gtk_label_new("Trigger:"), 0, 0, 1, 1); + s->cmd_trigger_entry = gtk_entry_new(); + gtk_grid_attach(GTK_GRID(grid), s->cmd_trigger_entry, 1, 0, 2, 1); + + s->cmd_mode_text_radio = gtk_radio_button_new_with_label(NULL, "Text replacement"); + s->cmd_mode_key_radio = gtk_radio_button_new_with_label_from_widget(GTK_RADIO_BUTTON(s->cmd_mode_text_radio), "Key combination"); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(s->cmd_mode_text_radio), TRUE); + g_signal_connect(s->cmd_mode_text_radio, "toggled", G_CALLBACK(on_cmd_mode_toggled), s); + g_signal_connect(s->cmd_mode_key_radio, "toggled", G_CALLBACK(on_cmd_mode_toggled), s); + gtk_grid_attach(GTK_GRID(grid), s->cmd_mode_text_radio, 1, 1, 1, 1); + gtk_grid_attach(GTK_GRID(grid), s->cmd_mode_key_radio, 2, 1, 1, 1); + + gtk_grid_attach(GTK_GRID(grid), gtk_label_new("Text:"), 0, 2, 1, 1); + s->cmd_text_entry = gtk_entry_new(); + gtk_grid_attach(GTK_GRID(grid), s->cmd_text_entry, 1, 2, 2, 1); + + gtk_grid_attach(GTK_GRID(grid), gtk_label_new("Key:"), 0, 3, 1, 1); + s->cmd_capture_label = gtk_label_new("No key captured"); + gtk_label_set_xalign(GTK_LABEL(s->cmd_capture_label), 0.0f); + gtk_grid_attach(GTK_GRID(grid), s->cmd_capture_label, 1, 3, 1, 1); + s->cmd_capture_btn = gtk_button_new_with_label("Capture"); + g_signal_connect(s->cmd_capture_btn, "clicked", G_CALLBACK(on_cmd_capture_clicked), s); + gtk_grid_attach(GTK_GRID(grid), s->cmd_capture_btn, 2, 3, 1, 1); + + s->cmd_add_btn = gtk_button_new_with_label("Add Command"); + g_signal_connect(s->cmd_add_btn, "clicked", G_CALLBACK(on_cmd_add_clicked), s); + gtk_grid_attach(GTK_GRID(grid), s->cmd_add_btn, 2, 4, 1, 1); + + on_cmd_mode_toggled(NULL, s); + voice_cmd_refresh_store(s); + + return outer; +} + static int cmp_str_ptr(const void *a, const void *b) { const char *sa = *(const char * const *)a; const char *sb = *(const char * const *)b; @@ -1029,6 +1413,11 @@ static int run_startup_dialog(gui_state_t *s, GtkWindow *parent) { static void on_app_activate(GtkApplication *app, gpointer user_data) { gui_state_t *s = (gui_state_t *)user_data; + if (s && s->window) { + gtk_window_present(GTK_WINDOW(s->window)); + return; + } + if (!s->runtime_ready) { if (run_startup_dialog(s, NULL) != 0) { g_application_quit(G_APPLICATION(app)); @@ -1074,6 +1463,7 @@ static void on_app_activate(GtkApplication *app, gpointer user_data) { GtkWidget *outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); gtk_container_set_border_width(GTK_CONTAINER(outer), 12); gtk_container_add(GTK_CONTAINER(s->window), outer); + g_signal_connect(s->window, "key-press-event", G_CALLBACK(on_window_key_press), s); s->status_label = gtk_label_new("Idle"); gtk_label_set_xalign(GTK_LABEL(s->status_label), 0.0f); @@ -1200,6 +1590,8 @@ static void on_app_activate(GtkApplication *app, gpointer user_data) { gtk_notebook_append_page(GTK_NOTEBOOK(notebook), main_page, gtk_label_new("Main")); GtkWidget *whisper_page = build_whisper_settings_tab(s); gtk_notebook_append_page(GTK_NOTEBOOK(notebook), whisper_page, gtk_label_new("Whisper")); + GtkWidget *commands_page = build_voice_commands_tab(s); + gtk_notebook_append_page(GTK_NOTEBOOK(notebook), commands_page, gtk_label_new("Commands")); gtk_widget_show_all(s->window); @@ -1237,6 +1629,7 @@ int main(int argc, char **argv) { memset(&st, 0, sizeof(st)); config_set_defaults(&st.cfg); + voice_cmd_init(&st.voice_cmds); const char *cfg_path = arg_value(argc, argv, "--config"); if (!cfg_path) cfg_path = "./config.ini"; @@ -1245,6 +1638,13 @@ int main(int argc, char **argv) { fprintf(stderr, "warning: failed to load %s, using defaults\n", cfg_path); } + snprintf(st.voice_cmd_path, sizeof(st.voice_cmd_path), "./voice_commands.ini"); + if (voice_cmd_load_or_seed(&st) != 0) { + fprintf(stderr, "warning: failed to load voice commands from %s\n", st.voice_cmd_path); + voice_cmd_free(&st.voice_cmds); + voice_cmd_init(&st.voice_cmds); + } + st.use_gpu = st.cfg.use_gpu ? 1 : 0; if (arg_flag(argc, argv, "--gpu")) st.use_gpu = 1; if (arg_flag(argc, argv, "--cpu")) st.use_gpu = 0; @@ -1287,6 +1687,7 @@ int main(int argc, char **argv) { typer_cleanup(); transcribe_cleanup(); audio_cleanup(); + voice_cmd_free(&st.voice_cmds); return rc; } diff --git a/src/typer.c b/src/typer.c index 451aa8d..869891e 100644 --- a/src/typer.c +++ b/src/typer.c @@ -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) { diff --git a/src/typer.h b/src/typer.h index 8d53b51..2aa03bc 100644 --- a/src/typer.h +++ b/src/typer.h @@ -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 diff --git a/src/voice_cmd.c b/src/voice_cmd.c new file mode 100644 index 0000000..c278743 --- /dev/null +++ b/src/voice_cmd.c @@ -0,0 +1,379 @@ +#include "voice_cmd.h" + +#include +#include +#include +#include + +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; +} diff --git a/src/voice_cmd.h b/src/voice_cmd.h new file mode 100644 index 0000000..ce670d3 --- /dev/null +++ b/src/voice_cmd.h @@ -0,0 +1,61 @@ +#ifndef VOICE_VOICE_CMD_H +#define VOICE_VOICE_CMD_H + +#include + +#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 diff --git a/voice_commands.ini b/voice_commands.ini new file mode 100644 index 0000000..733661e --- /dev/null +++ b/voice_commands.ini @@ -0,0 +1,26 @@ +# 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|} +over|key|65293:0 +end of command|key|65293:3 +over.|text|Return