-
Notifications
You must be signed in to change notification settings - Fork 0
development
-
Rust 1.75+ with
cargo— rustup.rs -
Node.js 18+ with
npm -
Tauri CLI 2.x —
cargo install tauri-cli
# 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- 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.
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
│ ├── voxctrl-config/
│ ├── voxctrl-audio/
│ ├── voxctrl-hotkeys/
│ ├── voxctrl-inference/
│ ├── voxctrl-routing/
│ ├── voxctrl-inject/
│ ├── voxctrl-tts/
│ ├── voxctrl-mcp/
│ ├── voxctrl-dbus/
│ ├── voxctrl-llm/
│ └── voxctrl-text/ # Shared text-processing (snippets, fuzzy vocab correction)
│
├── Cargo.toml # Workspace definition
├── package.json # Frontend deps
├── vite.config.ts
└── svelte.config.js
npm install # Install frontend deps (first time only)
npm run tauri dev # Start Tauri + Vite in development modeThis:
- Starts Vite dev server on
http://localhost:5173with HMR - Compiles the Rust backend
- Launches the app with the WebView pointed at Vite
Svelte changes hot-reload instantly. Rust changes trigger a backend recompile (typically 5–30s).
If you only need to work on the UI:
npm run dev
# Opens http://localhost:5173 in browser
# Note: Tauri commands won't work in browser — mock them if needednpm run check # Runs svelte-check (Svelte + TypeScript) against tsconfig.jsonThis 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.
cargo build -p voxctrl-inference # Build a specific crate
cargo test -p voxctrl-config # Test a specific crate
cargo check --workspace # Type-check all cratesbash build_appimage.sh
# Output: VoxCtrl.AppImage in project rootThe build script:
- Runs
npm run tauri buildto produce a.debbundle - Extracts the contents into an AppDir
- Runs
appimagetoolto create the AppImage
npm run tauri build
# Output: src-tauri/target/release/bundle/
# Linux: .deb, .AppImage
# Windows: .msi, .exe (NSIS)CUDA inference acceleration is disabled by default so the app builds on any machine. Enable it with the cuda cargo feature:
# 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-protocolIn 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.
Breeze-TTS-2 runs on candle, whose GPU backends are CUDA and Metal — there is no Vulkan backend to select. Neither is on by default, because each needs its toolchain at build time:
npm run tauri build -- --features breeze-cuda # NVIDIA
npm run tauri build -- --features breeze-metal # macOSWithout one of these, tts.breeze_tts_2.gpu has nothing to switch to: the
setting is saved, a warning is logged, and synthesis stays on the CPU. The same
fallback covers a GPU that fails to open at runtime.
RNNoise sits behind noisereduce on voxctrl-audio, and src-tauri enables it,
so a normal build can honor the Audio 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:
cargo test -p voxctrl-audio --features noisereduce.\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" }
- Keep each crate focused on one domain
- Expose a minimal public API (
pubon types/functions needed by callers) - Use
tokiofor async where I/O is needed; keep CPU-heavy work on dedicated OS threads - Pass channels rather than
Arc<Mutex<_>>for data pipelines where possible
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.
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.
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.
Defines OutputTarget, HotkeyBinding, DeliveryType, TargetProcessingConfig, and GestureType. Add new delivery types or target fields here.
- Add a variant to
DeliveryTypeenum incrates/voxctrl-routing/src/models.rs - Add any target-specific fields to
OutputTargetincrates/voxctrl-routing/src/models.rs - Add a match arm in the router dispatch logic in
crates/voxctrl-routing/src/router.rs - Update the TypeScript
OutputTargetinterface insrc/stores/config.ts - Add the new type to the "Delivery System" selector in
src/lib/Settings/TargetEditorModal.svelte(opened fromCommandsTab.svelte, the Output Commands tab) - Document in
docs/routing.md
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.
The easiest way to run the entire test suite (Rust, Svelte, and Pytest Integration) is via the master test runner script:
npm testThis 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):
-
Rust Backend tests (
cargo test) -
Svelte Frontend tests (
npm run test:unit) -
Python Integration tests (
pytest tests/integration/)
Backend logic, including settings schemas, migrations, routing models, and utilities, is tested using standard Rust/Cargo unit tests.
# 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
# Run the native overlay engine's visualizer/animation tests
cargo test --bin voxctrl-overlayThe overlay engine tests (in src-tauri/src/overlay.rs) cover the load/unload animation spring (convergence, bounded overshoot, unload duration), the oscilloscope trace (orientation and clamping), the radar sweep/rings/blips geometry, the ocean wave paths (bottom edge locked to the stage, drain/fill mapping), and the VU LED matrix ballistics.
Backend unit tests are written inside their respective crate files within a #[cfg(test)] module block.
Example from crates/voxctrl-config/src/lib.rs:
#[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);
}
}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 likeprepare-sidecar.test.ts) -
Framework Stack: Vitest (runner), jsdom (DOM environment),
@testing-library/svelte(rendering & selectors)
# Run all frontend tests once
npm run test:unit
# Run frontend tests in interactive watch mode
npx vitestTauri 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:
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
}),
}));When testing Svelte components:
- Render the component using
render(Component, { props }). - Locate elements using Svelte Testing Library selectors (e.g.,
screen.findByTextorscreen.queryByText). - Assert behaviors using Vitest's
expect().
Example:
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();
});
});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 withtest_) - Framework: Pytest
# 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.
Integration tests use the standard pytest framework, creating client socket connections to communicate with /tmp/voxctrl-mcp.sock over JSON-RPC.
Example:
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()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.
commands::hotkey_status() reports what is actually happening rather than
auditing configuration files:
-
Backend —
voxctrl_hotkeys::ListenerHealth::backend()says which mechanism is live:Portal,Evdev,WindowsHook,Starting, orNone. This is ground truth; nothing is inferred from what is on disk. -
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(), andevery_backend_either_sees_keys_or_is_privateinhealth.rsenforces that. The Windows hook used to be in both sets — aWH_KEYBOARD_LLhook 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. -
Bound shortcuts — the portal returns what the compositor actually bound, which may differ from what VoxCtrl requested.
BoundShortcutcarries both, so the Hotkeys tab can show the real keys and flag anything refused. -
Startup grace —
Startingreports 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. -
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.
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.
-
Portal working (the normal case):
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):
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:
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.
-
UI Outcome: Spawns the standalone VoxCtrl Setup window
(
To exercise the real evdev fallback on a desktop that does have the portal,
set VOXCTRL_DISABLE_PORTAL_HOTKEYS=1.
VoxCtrl uses the log crate with env_logger. Enable verbose output:
RUST_LOG=debug npm run tauri dev
RUST_LOG=voxctrl_inference=trace npm run tauri devIn dev mode, right-click the Tauri window → Inspect Element to open WebKit DevTools.
Add console.log around invoke() calls in Svelte, or add println! in command handlers in Rust.
# Check CPAL devices
RUST_LOG=cpal=debug npm run tauri dev
# Check PulseAudio
pactl list sources short