Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ Lean repo guide for automated agents.
- Product requirements live in GitHub issues (epics + child tickets), not a local `PRD.md`.

## Stack
- Bun runtime and package manager
- Bun runtime and package manager (CLI/tests); the app itself runs under Electron/Node
- Strict TypeScript
- pino logs with daily rotation
- Electron overlay sidecar over local IPC
- Single Electron app hosts the daemon and the overlay window (ADR-0003); state flows main → renderer via webContents.send

## Current Product State
- Hyprvox uses parallel Groq + Deepgram transcription with merge/validation/recovery.
Expand All @@ -30,10 +30,10 @@ Lean repo guide for automated agents.

## Operational Data
- Config: `~/.config/hypr/vox/config.json`
- Logs: `paths.logs` from config
- Logs: `paths.logs` from config; app stdout/stderr in `~/.config/hypr/vox/logs/app.log`
- History: `~/.config/voice-cli/history.json`
- IPC socket: `~/.config/hypr/vox/daemon.sock`
- Overlay PID file: `~/.config/hypr/vox/overlay.pid`
- Command socket (single-instance guard + `soniox-toggle`): `~/.config/hypr/vox/daemon.sock`
- App bundle: `dist/app` (built by `bun run build:app`; `package.json` name there sets WM_CLASS)

## Workflow Notes
- Default hotkey: Right Control; Hyprland users often bind `hyprvox toggle` in the compositor.
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 14 additions & 7 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,29 @@

```
src/
├── app/ # Electron main: hosts the daemon + overlay window (single process)
├── audio/ # Recording and audio device management
├── cli/ # CLI command implementations
├── config/ # Configuration loading, validation, and storage
├── daemon/ # Background service, hotkey handling, and supervisor
├── daemon/ # Background service and hotkey handling
├── output/ # Clipboard and notification integration
├── shared/ # IPC message types shared with the overlay renderer
├── transcribe/ # External API integrations (Groq & Deepgram)
├── utils/ # Shared utilities and helpers
├── types/ # External type definitions
overlay/ # Renderer + preload assets for the overlay window (built to overlay/dist)
```

## Core Architecture

### 1. Daemon Lifecycle & Supervision
`hyprvox` operates as a persistent background daemon on Linux. It follows a multi-process architecture where a **Supervisor** ensures high availability of a **Worker** process.
### 1. Single-App Topology (ADR-0003)
`hyprvox` is one resident Electron app: the main process hosts the daemon (`DaemonService`) and the overlay `BrowserWindow`. There is no supervisor process, no systemd unit, and no daemon↔overlay socket — Electron is the single supervisor.

- **Supervisor (`src/daemon/supervisor.ts`)**: The parent process that spawns and monitors the worker. It implements auto-restart logic with crash protection (max 3 crashes in 5 minutes).
- **Service (`src/daemon/service.ts`)**: The main "Event Loop" and orchestrator for the worker process. It maintains system state and coordinates between hardware (audio/keyboard) and remote APIs.
- **Systemd Integration**: The daemon can be managed as a systemd user service, which handles environment forwarding (`DISPLAY`, `WAYLAND_DISPLAY`) for clipboard and notification access.
- **App main (`src/app/main.ts`)**: Boots the command socket (single-instance guard), starts `DaemonService` in-process, creates the overlay window, and forwards daemon state to the renderer via `webContents.send`.
- **Service (`src/daemon/service.ts`)**: The orchestrator. Maintains the state machine and coordinates hardware (audio/keyboard) with remote APIs. Emits `state` and `audioLevel` events (it is an `EventEmitter`); the app main relays them to the renderer.
- **Command socket (`src/app/command-server.ts`)**: A minimal unix socket (`daemon.sock`) for CLI verbs that need a payload (`soniox-toggle`); binding it doubles as the single-instance guard.
- **Launch & crash recovery**: Started via Hyprland `exec-once = hyprvox start`. If the app dies, the next `hyprvox toggle` lazily respawns it.
- **Window identity**: The window must map as an XWayland client with `WM_CLASS` `hyprvox-overlay` (Hyprland rules target that class, and self-positioning — how the overlay parks off-screen — only works under XWayland). The launcher strips `ELECTRON_OZONE_PLATFORM_HINT` and the app pins `--ozone-platform=x11`; the class comes from `dist/app/package.json`'s `name` field written by `bun run build:app`.

### 2. State Machine
The daemon tracks its status via a formal state machine to ensure predictable behavior:
Expand Down Expand Up @@ -112,7 +117,9 @@ For details on using these modules programmatically, see the [Programmatic API R
### Getting Started
1. Clone the repository: `git clone https://github.com/Snehit70/hyprvox.git`
2. Install dependencies: `bun install`
3. Run in development mode: `bun run index.ts start`
3. Build the overlay assets (also installs the Electron runtime): `bun run build:overlay`
4. Build the app bundle: `bun run build:app`
5. Start the app: `bun run index.ts start` (add `--foreground` to stay attached)

### Testing
We use [Vitest](https://vitest.dev/) for testing.
Expand Down
25 changes: 14 additions & 11 deletions docs/CLI_COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,49 +35,52 @@ If `hyprvox status` shows a manually started daemon before service installation,
## Main Commands

### `start`
Start the transcription daemon.
Start the hyprvox app — the daemon and the overlay window run in one Electron process (ADR-0003). Launches detached; logs go to `~/.config/hypr/vox/logs/app.log`. Requires the app bundle (`bun run build:app`) and overlay assets (`bun run build:overlay`) to have been built.
- **Options:**
- `--no-supervisor`: Run the daemon directly without the auto-restarting supervisor.
- `--daemon-worker`: (Internal) Used by the supervisor to spawn worker processes.
- `--foreground`: Stay attached to the terminal (for debugging).
- `--help`: Display help for the start command.

### `stop`
Stop the running transcription daemon.
Stop the running app.
- **Options:**
- `--help`: Display help for the stop command.

### `restart`
Restart the transcription daemon.
Restart the app (SIGTERM, wait for exit, relaunch).
- **Options:**
- `--help`: Display help for the restart command.

### `toggle`
Toggle recording. If the app is not running, it is started first and the trigger is delivered once it is up (this is the crash-recovery path).

### `soniox-toggle`
Toggle Soniox live dictation via the app's command socket (`~/.config/hypr/vox/daemon.sock`).

### `status`
Display the current status of the daemon (PID, uptime, state, statistics).
- **Options:**
- `--help`: Display help for the status command.

### `install`
Install `hyprvox` as a systemd user service for the current user.

Recommended after `config init`, `list-mics`, and `health` have passed. The command writes `~/.config/systemd/user/hyprvox.service`, runs `systemctl --user daemon-reload`, enables the service, and starts it.
Print instructions for autostarting hyprvox with Hyprland (`exec-once = hyprvox start`) and a suggested toggle binding. No systemd unit is written — the app supervises itself, and a dead app is revived by the next `hyprvox toggle`. Warns if a legacy systemd unit is still present.

- **Options:**
- `--help`: Display help for the install command.

### `setup`
Interactively set up `hyprvox` and diagnose the host environment.

The setup command detects Linux distro, Wayland/X11/headless session, container state, required commands, config validity, daemon status, and service installation. In interactive mode it can create/update config, select a microphone, recommend Wayland compositor binding behavior, and install the service.
The setup command detects Linux distro, Wayland/X11/headless session, container state, required commands, config validity, daemon status, and service installation. In interactive mode it can create/update config, select a microphone, recommend Wayland compositor binding behavior, and show autostart instructions.

- **Options:**
- `--check`: Run setup checks without changing anything.
- `--json`: Print setup check output as JSON. Implies check mode.
- `--dry-run`: Show what setup would change without writing.
- `--skip-service`: Do not install or start the systemd user service.
- `--skip-service`: Do not show autostart (exec-once) instructions.
- `--help`: Display help for the setup command.

### `uninstall`
Remove the `hyprvox` systemd user service.
Remove the legacy `hyprvox` systemd user service (from installs that predate the single-app topology).
- **Options:**
- `--help`: Display help for the uninstall command.

Expand Down
93 changes: 93 additions & 0 deletions docs/adr/0003-collapse-daemon-and-overlay-into-one-electron-app.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Collapse the daemon and overlay into a single resident Electron app

Status: accepted — implementation gated on the trigger-latency measurement window (see Consequences)

Hyprvox currently runs a Bun daemon and an Electron overlay as separate processes
across a unix socket, with **four independent supervision layers, each with its own
terminal state**: systemd's `StartLimitBurst=3`, `supervisor.ts`'s `MAX_RESTARTS`,
`OverlayProcessManager`'s 5-restarts-per-60s, and `IPCClient`'s 10 reconnect
attempts. Any one of them can permanently give up while the others believe the
system is healthy, and none of them can see the others' terminal state. That is the
reliability risk this decides against, and it is a property of the topology rather
than of any bug we can point at.

We are collapsing to one resident Electron app: main owns the trigger, the state
machine, and the STT WebSockets; a `utilityProcess` owns the merge and quality
pipeline; the renderer keeps its own microphone capture and FFT waveform. Launched
via Hyprland `exec-once`. systemd, `supervisor.ts`, and `OverlayProcessManager` all
go away, and Electron becomes the single supervisor.

## Considered Options

**Relocate the transcribe pipeline into Electron main directly.** Rejected on
measurement. The pipeline is portable Node (zero `Bun.*` APIs, three `import.meta.dir`
uses), so relocating it is nearly free — but `decideMerge` is **quadratic** in
transcript length and blocks the event loop for **~38ms at 1,500 words**, which is
~10 minutes of speech and therefore exactly the configured `clipboard.maxDuration`
ceiling. At 3,000 words it blocks ~144ms. Since that stall lands precisely while the
overlay is animating its `processing` state, it would be visible rather than hidden.
Everything else measured is safe (`validateTranscript` 4.8ms at 25kb,
`buildPcm16kMonoWav` 7.6ms on a 19MB buffer), so `utilityProcess` isolation is needed
for the merge specifically, not for the pipeline as a whole.

**Keep the daemon.** Its process isolation is a real benefit, and we had been
dismissing it. `utilityProcess` preserves that isolation *inside* the single-app
model while still deleting the hand-rolled supervision — so we get the benefit
without the four-layer failure mode.

**Socket-activate the daemon instead.** Unimplementable: `listen({ fd: 3 })` throws
`EINVAL` on Bun 1.3.3, so `sd_listen_fds`-style activation is not available to us.

## Explicitly not changing

- **Audio capture stays double.** `arecord` feeds transcription; the renderer's
`getUserMedia` feeds the waveform. This looks redundant and is not: the renderer
path drives 128 FFT bins at 60fps, where the daemon's IPC path carries 2 smoothed
scalars at 30fps. Collapsing them would visibly degrade the waveform.
- **The CLI stays a one-shot `SIGUSR1`.** It does not become a socket client.
- The overlay window stays permanently mapped and click-through, per ADR-0001.

## Consequences

- **This ADR does not supersede ADR-0001 and is not in tension with it.** ADR-0001
decided *rendering technology* (Electron over GTK); this decides *process
topology*. Electron stays either way. ADR-0001's precedent — that a felt slowness
was a fixable bug rather than an inherent cost, so don't rewrite — still stands,
and is why the next point matters.
- **This decision is justified by the supervision topology alone, not by latency.**
The reported symptom (overlay slow to respond in long sessions, fixed by a restart)
remains **undiagnosed**: measured IPC latency is flat at ~1ms across 63,021 events,
and the daemon's own `starting` timestamp is taken *before* the Deepgram WebSocket
opens, so existing telemetry structurally cannot see the suspected stall. A
trigger-latency measurement window is running to find it. Implementation waits for
that window to close, because shipping this first would destroy the only evidence
that could tell us whether it fixes anything. If the window indicts something this
rewrite would not have fixed, that is worth knowing before the rewrite, not after.

## Implementation notes (Phase 1, 2026-07-15)

Phase 1 landed on `feat/single-app-spike`: the supervision stack
(`supervisor.ts`, `overlay-process.ts`, the daemon's IPC server, the overlay's
socket client) is deleted; state flows main → renderer via `webContents.send`;
`DaemonService` is an `EventEmitter` (`state`, `audioLevel`). Two findings made
during implementation are durable constraints:

- **The overlay depends on XWayland.** Parking the always-mapped window
off-screen (ADR-0001) requires client self-positioning, which native Wayland
forbids. The app pins `--ozone-platform=x11`, and the CLI launcher strips
`ELECTRON_OZONE_PLATFORM_HINT` from the spawn environment — that hint is
consumed by Electron before application JS runs, and (verified on Electron
34) `hint=auto` combined with the x11 pin half-initializes the browser and
no window is ever created. Going native Wayland would mean redesigning
show/hide and was explicitly deferred (user decision, 2026-07-15).
- **`WM_CLASS` comes from the app directory's `package.json` name.**
`bun run build:app` writes `dist/app/package.json` with
`name: "hyprvox-overlay"` so the user's Hyprland window rules keep matching.
Launching the bare bundle file yields class `Electron` and no rule matches.

The CLI's `soniox-toggle` verb kept a socket home: a minimal command socket in
the app main (`src/app/command-server.ts`) on the same `daemon.sock` path,
which also serves as the single-instance guard. SIGUSR1/SIGUSR2 semantics are
unchanged. `hyprvox install` no longer writes a systemd unit; it prints the
`exec-once` line, and crash recovery is Electron plus lazy respawn on the next
`hyprvox toggle`.
38 changes: 2 additions & 36 deletions overlay/package.json
Original file line number Diff line number Diff line change
@@ -1,51 +1,17 @@
{
"name": "hyprvox-overlay",
"version": "0.1.0",
"description": "Waveform overlay for hyprvox",
"description": "Renderer and preload assets for the hyprvox overlay window (hosted by the single hyprvox Electron app)",
"packageManager": "npm@10.9.4",
"main": "dist/main.js",
"scripts": {
"build": "tsc && vite build",
"start": "electron .",
"dev": "npm run build && electron .",
"pack": "electron-builder --dir",
"dist": "electron-builder"
},
"build": {
"appId": "com.hyprvox.overlay",
"productName": "Hyprvox Overlay",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"package.json"
],
"linux": {
"target": [
{
"target": "AppImage",
"arch": [
"x64"
]
},
{
"target": "tar.gz",
"arch": [
"x64"
]
}
],
"category": "Utility"
}
"build": "tsc && vite build"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"electron": "^34.0.0",
"electron-builder": "^25.1.8",
"typescript": "^5.7.0",
"vite": "^7.3.1"
},
Expand Down
2 changes: 1 addition & 1 deletion overlay/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type {
AudioLevelMessage,
ConnectionStatus,
DaemonState,
} from "./ipc-client";
} from "./shared/ipc-types";

export interface ElectronAPI {
onToggleListening: (callback: () => void) => () => void;
Expand Down
Loading