# Development Guide ## Prerequisites ### Required Tools - **Rust** 1.75+ with `cargo` — [rustup.rs](https://rustup.rs/) - **Node.js** 18+ with `npm` - **Tauri CLI** 2.x — `cargo install tauri-cli` ### Linux System Dependencies ```bash # Ubuntu / Debian sudo apt install \ libwebkit2gtk-4.1-dev \ libayatana-appindicator3-dev \ librsvg2-dev \ libssl-dev \ libasound2-dev \ libspeechd-dev \ pkg-config \ build-essential # Fedora sudo dnf install \ webkit2gtk4.1-devel \ libayatana-appindicator-devel \ openssl-devel \ alsa-lib-devel \ speech-dispatcher-devel ``` ### Windows - Visual Studio Build Tools 2019+ (select the "Desktop development with C++" workload) - WebView2 Runtime (pre-installed on Windows 10 21H2+ and Windows 11) - Rust MSVC toolchain: `rustup default stable-x86_64-pc-windows-msvc` For full Windows build instructions and a PowerShell helper script, see **[docs/windows_build.md](windows_build.md)**. --- ## Repository Layout ``` VoxCtrl/ ├── src/ # Svelte frontend │ ├── main.ts │ ├── App.svelte │ ├── stores/ │ │ ├── config.ts │ │ └── status.ts │ ├── assets/ │ │ └── overlays/ # Bundled .webm previews of each overlay style │ └── lib/ │ ├── Settings/ │ ├── Overlay/ │ ├── Wizard/ # First-run setup wizard (shell + steps/) │ ├── Diagnostics/ │ ├── src-tauri/ # Tauri application shell │ ├── Cargo.toml │ ├── tauri.conf.json │ └── src/ │ ├── main.rs │ ├── lib.rs # Main coordinator │ ├── commands.rs # IPC command handlers │ └── state.rs # AppState definition │ ├── crates/ # Backend library crates (15 workspace crates) │ ├── voxctrl-config/ │ ├── voxctrl-audio/ │ ├── voxctrl-hotkeys/ │ ├── voxctrl-inference/ │ ├── voxctrl-routing/ │ ├── voxctrl-inject/ │ ├── voxctrl-winput/ # Windows native synthesised Unicode keyboard input │ ├── voxctrl-tts/ │ ├── voxctrl-mcp/ │ ├── voxctrl-dbus/ │ ├── voxctrl-llm/ │ ├── voxctrl-text/ # Shared text-processing (snippets, fuzzy vocab correction) │ ├── voxctrl-update/ # Self-updating engine and release asset installer │ ├── voxctrl-bugreport/ # Diagnostic collection, allowlist redaction, telemetry-free reporting │ └── voxctrl-llm-sidecar/# Vulkan-accelerated llama.cpp sidecar for S1-mini dictation cleanup │ ├── Cargo.toml # Workspace definition ├── package.json # Frontend deps ├── vite.config.ts └── svelte.config.js ``` --- ## Development Workflow ### Start Dev Server ```bash npm install # Install frontend deps (first time only) npm run tauri dev # Start Tauri + Vite in development mode ``` This: 1. Starts Vite dev server on `http://localhost:5173` with HMR 2. Compiles the Rust backend 3. Launches the app with the WebView pointed at Vite Svelte changes hot-reload instantly. Rust changes trigger a backend recompile (typically 5–30s). ### Frontend Only If you only need to work on the UI: ```bash npm run dev # Opens http://localhost:5173 in browser # Note: Tauri commands won't work in browser — mock them if needed ``` ### Type-Check the Frontend ```bash npm run check # Runs svelte-check (Svelte + TypeScript) against tsconfig.json ``` This is the same check CI runs on every push/PR. Tailwind `@apply`/`@reference` warnings from `svelte-check` are expected (it does not parse Tailwind directives) and do not fail the build; only genuine type errors do. ### Backend Only ```bash cargo build -p voxctrl-inference # Build a specific crate cargo test -p voxctrl-config # Test a specific crate cargo check --workspace # Type-check all crates ``` --- ## Building for Production ### AppImage (Linux) ```bash bash build_appimage.sh # Output: VoxCtrl.AppImage in project root ``` The build script: 1. Runs `npm run tauri build` to produce a `.deb` bundle 2. Extracts the contents into an AppDir 3. Runs `appimagetool` to create the AppImage ### Standard Tauri Build ```bash npm run tauri build # Output: src-tauri/target/release/bundle/ # Linux: .deb, .AppImage # Windows: .msi, .exe (NSIS) ``` ### Which build am I running? ```bash ./VoxCtrl-linux-x86_64-vulkan.AppImage --version # VoxCtrl 0.6.1 (build 57375d4) ``` Every build stamps the commit it was made from — `github.sha` in the release workflow, the working tree in `build_appimage.sh` (with `-dirty` for uncommitted changes) — and the same line opens every log and therefore every bug report. Ask this first when a fix appears not to work: a packaged build is otherwise indistinguishable from an earlier one, and a test against the wrong build reads exactly like a fix that failed. To build a branch without cutting a release, run the **Release** workflow manually with **publish** off; the AppImage lands in the run's Artifacts. ### Debugging a bug that only reproduces in a packaged build Some bugs live in what the AppImage bundles rather than in the source — they reproduce from a released build and not from a local one, which means no source change can explain the difference. Two scripts narrow that down by measuring rather than guessing: ```bash # What actually differs between a working and a failing bundle? ./scripts/diff-appimage-stacks.sh GOOD.AppImage BAD.AppImage # Run a bundle against the host's GTK or WebKit instead of its own. ./scripts/try-host-gtk.sh --gtk BAD.AppImage ./scripts/try-host-gtk.sh --webkit BAD.AppImage ``` Both are read-only with respect to the AppImage, need no FUSE, and install nothing. `try-host-gtk.sh` refuses to launch if it removed nothing, so a no-op can never be mistaken for a result. This is how the overlay's smeared closing animation was traced to the bundled WebKitGTK — see [AppImage Runtime Library Policy](appimage_build_process.md#-appimage-runtime-library-policy). ### CUDA GPU Acceleration (opt-in) CUDA inference acceleration is disabled by default so the app builds on any machine. Enable it with the `cuda` cargo feature: ```bash # Linux / macOS npm run tauri build -- --features cuda # Windows (PowerShell) npm run tauri build -- --features cuda The `--` is required — without it npm treats `--features` as its own flag and fails with `EUNKNOWNCONFIG`. The two ONNX-backed engines — `moonshine` (speech-to-text) and `inflect-micro` (text-to-speech) — are **default features**, so a plain build includes both and neither needs naming. They share one ONNX Runtime, which is fetched at build time and linked in, so builds need network access to that host. To build without it: ```bash npm run tauri build -- --no-default-features --features custom-protocol ``` In such a build, selecting Moonshine falls back to whisper-cpp, and the Inflect TTS engine still downloads its model but leaves Test TTS disabled — only synthesis is gated. ### Pocket-TTS / Breeze-TTS-2 / VoxCPM2 on the GPU These three engines run through [audio.cpp](https://github.com/0xShug0/audio.cpp)'s prebuilt `audiocpp_cli` binary, which already ships a Vulkan backend — no VoxCtrl build feature is needed. Toggling `gpu` in Settings for any of them just switches the `--backend` flag passed to that subprocess between `vulkan` and `cpu`. If no usable Vulkan device is found, `audiocpp_cli` reports the failure as a normal TTS error rather than silently downgrading. ### Noise suppression RNNoise sits behind `noisereduce` on `voxctrl-audio`, and `src-tauri` enables it, so a normal build can honor the Audio Input tab's noise-suppression toggle. Building `voxctrl-audio` on its own (or with `--no-default-features` on that crate) leaves it out, and the toggle then logs a warning and passes audio through unchanged. Its tests need the feature: ```bash cargo test -p voxctrl-audio --features noisereduce ``` # Or use the helper script: .\scripts\build_windows.ps1 -Cuda ``` The `cuda` feature propagates: `voxctrl-app/cuda` → `voxctrl-inference/cuda` → `whisper-rs/cuda`. --- ## Crate Development Guide Each crate under `crates/` is self-contained. They are included in the workspace `Cargo.toml` and referenced by `src-tauri` as path dependencies. ### Adding a new crate ```bash cargo new --lib crates/voxctrl-myfeature # Add to Cargo.toml workspace members: [workspace] members = [ ... "crates/voxctrl-myfeature", ] # Reference from src-tauri/Cargo.toml: voxctrl-myfeature = { path = "../crates/voxctrl-myfeature" } ``` ### Crate Conventions - Keep each crate focused on one domain - Expose a minimal public API (`pub` on types/functions needed by callers) - Use `tokio` for async where I/O is needed; keep CPU-heavy work on dedicated OS threads - Pass channels rather than `Arc>` for data pipelines where possible --- ## Key Files to Understand ### `src-tauri/src/lib.rs` The main coordinator. This is where the audio pipeline is assembled: - Creates all channels - Spawns the hotkey listener - Spawns the audio recorder - Spawns the inference worker - Starts the MCP server - Starts the DBus service - Runs the Tauri event loop with the status ticker When adding a new integration, this is typically where you wire it in. ### `src-tauri/src/commands.rs` All `#[tauri::command]` handlers. Each command is a thin wrapper that reads/writes `AppState` or calls into a crate. Keep commands small — business logic belongs in crates. ### `crates/voxctrl-config/src/lib.rs` The `AppConfig` struct is the source of truth for all settings. If you add a config option, add it here first, then expose it in the Settings UI. ### `crates/voxctrl-routing/src/models.rs` Defines `OutputTarget`, `HotkeyBinding`, `DeliveryType`, `TargetProcessingConfig`, and `GestureType`. Add new delivery types or target fields here. --- ## Adding a New Output Command Delivery Type 1. Add a variant to `DeliveryType` enum in `crates/voxctrl-routing/src/models.rs` 2. Add any target-specific fields to `OutputTarget` in `crates/voxctrl-routing/src/models.rs` 3. Add a match arm in the router dispatch logic in `crates/voxctrl-routing/src/router.rs` 4. Update the TypeScript `OutputTarget` interface in `src/stores/config.ts` 5. Add the new type to the "Delivery System" selector in `src/lib/Settings/TargetEditorModal.svelte` (opened from `CommandsTab.svelte`, the Output Commands tab) 6. Document in `docs/routing.md` --- ## Testing VoxCtrl utilizes a multi-tiered, unified testing suite spanning Svelte frontend components, Rust backend crates, and end-to-end integration tests over local socket connections. ### Master Test Orchestrator The easiest way to run the entire test suite (Rust, Svelte, and Pytest Integration) is via the master test runner script: ```bash npm test ``` This runs `python3 scripts/run_tests.py`, which sequences the following three test suites and returns a consolidated exit code (cleanly skipping the integration tests with a warning if `pytest` is not installed on the system): 1. **Rust Backend tests** (`cargo test`) 2. **Svelte Frontend tests** (`npm run test:unit`) 3. **Python Integration tests** (`pytest tests/integration/`) --- ### Rust Backend Crate Tests Backend logic, including settings schemas, migrations, routing models, and utilities, is tested using standard Rust/Cargo unit tests. #### Running Backend Tests ```bash # Run all tests across the entire workspace cargo test --workspace # Run tests for a specific backend crate cargo test -p voxctrl-config cargo test -p voxctrl-routing ``` The overlay's visualizer/animation logic lives in the Svelte components under `src/lib/Overlay/` (see `docs/overlays.md`) and is covered by the frontend test suite (`npm run test:unit`), not backend Rust tests. #### Writing Backend Tests Backend unit tests are written inside their respective crate files within a `#[cfg(test)]` module block. Example from `crates/voxctrl-config/src/lib.rs`: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn test_default_config_values() { let cfg = AppConfig::default(); assert!(!cfg.ui.auto_show_settings); assert_eq!(cfg.ui.overlay_style, OverlayStyle::BlueWave); } } ``` --- ### Svelte Frontend Unit & Component Tests Frontend Svelte 5 components, settings views, and warning overlays are tested using **Vitest**, **JSDOM**, and **Svelte Testing Library**. * **Test Location**: `tests/svelte/` (files ending in `.test.ts`, including component tests and utility script tests like `prepare-sidecar.test.ts`) * **Framework Stack**: Vitest (runner), jsdom (DOM environment), `@testing-library/svelte` (rendering & selectors) #### Running Frontend Tests ```bash # Run all frontend tests once npm run test:unit # Run frontend tests in interactive watch mode npx vitest ``` #### Mocking Tauri APIs Tauri commands (`invoke`) and events (`listen`) are mocked inside Svelte tests using Vitest's `vi.mock` to ensure they run successfully in headless/JSDOM environments without a live Webview context. Example from `tests/svelte/EngineTab.test.ts`: ```typescript import { describe, test, expect, vi } from "vitest"; import { render, screen } from "@testing-library/svelte"; import EngineTab from "../../src/lib/Settings/EngineTab.svelte"; // Mock Tauri core commands vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(async (cmd, args) => { if (cmd === "check_model_downloaded") { return args.modelSize === "base"; // mock "base" downloaded, others missing } return true; }), })); // Mock Tauri events vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => { return () => {}; // return clean unsubscribe function }), })); ``` #### Writing Svelte Tests When testing Svelte components: 1. Render the component using `render(Component, { props })`. 2. Locate elements using Svelte Testing Library selectors (e.g., `screen.findByText` or `screen.queryByText`). 3. Assert behaviors using Vitest's `expect()`. Example: ```typescript describe("EngineTab.svelte Warning Banner", () => { test("shows warning banner if Whisper voice model is not downloaded", async () => { const mockConfig = { engine: { backend: "whisper-cpp", whisper_cpp: { model_size: "large-v3" }, } } as any; render(EngineTab, { cfg: mockConfig }); // Assert warning banner is found const title = await screen.findByText("Voice Model Not Downloaded"); expect(title).not.toBeNull(); }); }); ``` --- ### Python Socket Integration Tests Integration tests verify end-to-end communication channels such as the Model Context Protocol (MCP) server over Unix domain sockets (`/tmp/voxctrl-mcp.sock`). * **Test Location**: `tests/integration/` (files prefixed with `test_`) * **Framework**: Pytest #### Running Integration Tests ```bash # Ensure pytest is installed pip install pytest # Run integration tests pytest tests/integration/ ``` *Note: These tests check for the live socket connection. If VoxCtrl is not currently running, these tests will gracefully skip to prevent false failure reports.* #### Writing Integration Tests Integration tests use the standard `pytest` framework, creating client socket connections to communicate with `/tmp/voxctrl-mcp.sock` over JSON-RPC. Example: ```python import socket import json import pytest import os SOCKET_PATH = "/tmp/voxctrl-mcp.sock" @pytest.mark.skipif(not os.path.exists(SOCKET_PATH), reason="MCP Socket not running") def test_mcp_handshake_and_tools(): sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.connect(SOCKET_PATH) try: # Send a standard JSON-RPC request to the MCP server payload = {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}} sock.sendall((json.dumps(payload) + "\n").encode('utf-8')) # Read and parse response resp = json.loads(sock.recv(1024).decode('utf-8').strip()) assert "result" in resp assert "tools" in resp["result"] finally: sock.close() ``` ### Simulating and Testing Hotkey Diagnostics Global shortcuts go through the XDG `GlobalShortcuts` portal, which is a property of the user's desktop and awkward to vary on a dev machine. The `VOXCTRL_TEST_HOTKEY_STATUS` environment variable mocks the outcome so the first-launch UI can be exercised in every state. #### How diagnostics work `commands::hotkey_status()` reports what is actually happening rather than auditing configuration files: 1. **Backend** — `voxctrl_hotkeys::ListenerHealth::backend()` says which mechanism is live: `Portal`, `Evdev`, `WindowsHook`, `Starting`, or `None`. This is ground truth; nothing is inferred from what is on disk. 2. **Privacy** — `is_private()` is true only where something else owns the key grab and hands VoxCtrl whole shortcuts: the XDG portal, and a Linux Mint custom keybinding that invokes VoxCtrl over D-Bus. The UI states this in plain language, and only when true. It is the exact opposite of `Backend::sees_raw_keys()`, and `every_backend_either_sees_keys_or_is_private` in `health.rs` enforces that. The Windows hook used to be in *both* sets — a `WH_KEYBOARD_LL` hook is called for every keystroke on the machine, so the Hotkeys tab showed a padlock and "VoxCtrl does not read your keyboard" over a backend that reads all of it. Deciding a new backend's privacy is now a choice that test forces. 3. **Bound shortcuts** — the portal returns what the compositor *actually* bound, which may differ from what VoxCtrl requested. `BoundShortcut` carries both, so the Hotkeys tab can show the real keys and flag anything refused. 4. **Startup grace** — `Starting` reports as active. The portal handshake is async, and flashing a failure for a few hundred milliseconds on every launch would pop the setup window on a perfectly working install. 5. **Device counts** — only probed on the evdev path. On the portal path VoxCtrl opens no input devices, so reporting "0 of 8 readable" would be accurate but deeply misleading. #### What must never appear VoxCtrl does not install a udev rule, does not run `usermod -aG input`, and offers no UI affordance to do either. `installer::build_privileged_setup_script` is package installation only, and both a Rust test (`the_privileged_script_never_touches_input_permissions`) and a Svelte test (`never offers to grant keyboard access`) fail if that regresses. See [Hotkeys → Why this changed](hotkeys.md#why-this-changed). #### Mock configurations * **Portal working (the normal case)**: ```bash VOXCTRL_TEST_HOTKEY_STATUS=portal npm run tauri dev ``` * **UI Outcome**: The setup window's first step is green and states that the desktop owns the shortcuts and VoxCtrl cannot read the keyboard. * **evdev fallback (no portal, but input devices are already readable)**: ```bash VOXCTRL_TEST_HOTKEY_STATUS=evdev npm run tauri dev ``` * **UI Outcome**: Shortcuts report as working, with an amber note that every keystroke passes through VoxCtrl in this mode and that the access was not created by VoxCtrl. * **Nothing available**: ```bash VOXCTRL_TEST_HOTKEY_STATUS=none npm run tauri dev ``` * **UI Outcome**: Spawns the standalone **VoxCtrl Setup** window (`udev-warning`) in the foreground, explaining that the desktop provides no shortcuts portal and why VoxCtrl will not grant itself keyboard access, with a **Continue anyway** close pathway. To exercise the real evdev fallback on a desktop that *does* have the portal, set `VOXCTRL_DISABLE_PORTAL_HOTKEYS=1`. --- ## Debugging ### Rust Logging VoxCtrl uses the `log` crate with `env_logger`. Enable verbose output: ```bash RUST_LOG=debug npm run tauri dev RUST_LOG=voxctrl_inference=trace npm run tauri dev ``` ### Frontend DevTools In dev mode, right-click the Tauri window → Inspect Element to open WebKit DevTools. ### IPC Tracing Add `console.log` around `invoke()` calls in Svelte, or add `println!` in command handlers in Rust. ### Audio Issues ```bash # Check CPAL devices RUST_LOG=cpal=debug npm run tauri dev # Check PulseAudio pactl list sources short ```