feat(0.2.6): tray-aware shell, hotkey customization, WS dictation dedupe

Tray + lifecycle:
- tauri-plugin-single-instance — second launch focuses existing window
  instead of racing for port 3900.
- Window close hides instead of destroying; backend shutdown moved to
  RunEvent::ExitRequested so only the tray "Quit" item (or Cmd+Q on macOS)
  actually exits.
- Tray icon flips to red-dot variant during dictation recording.

Hotkey customization:
- Settings → Capture tab. Records any modifier+key combo, persists to
  app config, re-registers on launch.
- set_dictation_shortcut rolls back to the previous binding on register
  failure so a bad combo never leaves the user with no shortcut.

Dictation latency / correctness:
- WS-final treated as source of truth; HTTP POST /transcribe runs only as
  fallback (WS error / timeout / no-WS path). Audio transcribed once
  instead of twice. Server accepts an "EOF" text frame (or empty binary
  frame) so the socket stays open for `final` to be delivered before the
  client closes.
- MediaRecorder chunks queued during the WS handshake are drained in
  ws.onopen — the server's final transcript no longer drops the first
  ~250 ms of audio.
- Fallback timeout scales with recording length (max(15s, recordedMs+10s))
  so long-form dictations don't trip duplicate transcription.

Donate page:
- Drop Patreon, Bitcoin / Ethereum / Solana cards. Drop qrcode.react.
- Move "Commercial License" CTA from page bottom to top-right header bar.

Docker hygiene:
- docker-compose binds 127.0.0.1 by default. README documents the LAN
  exposure trade-off + recommends a reverse proxy with auth.

CI:
- New cross-platform `tauri-cross-platform` job runs `cargo check` against
  the Tauri shell on macOS / Windows / Linux per PR. Catches platform
  cfg-gate regressions without paying the full ~15min/platform bundle
  cost (full bundling stays in release.yml on tag push).

Tests:
- tests/test_capture_ws.py (3 cases) covers EOF text-frame, empty-binary
  EOF, and legacy disconnect-finalize paths.

Includes the user's previously-staged 0.2.5 polish: cross-platform
desktop-prod.sh, Dockerfile base-image fix, bun.lock churn.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
debpalash
2026-04-29 10:33:22 +05:30
co-authored by Claude Opus 4.7
parent 5e35e6d0d8
commit 79d4f3b53d
19 changed files with 1064 additions and 412 deletions
+77
View File
@@ -85,3 +85,80 @@ jobs:
- name: Run frontend node:test
working-directory: frontend
run: node --experimental-strip-types --no-warnings --test ../tests/frontend/*.test.mjs
# ── Cross-platform Tauri shell check ────────────────────────────────────
# Catches platform-specific Rust regressions on PR (cfg(target_os=...)
# gates, missing Windows/macOS deps, etc.) without spending the 15+ min
# per-platform that a full `tauri build` takes. `cargo check` is the
# lightest gate that exercises type-checking + linking for each target.
# Full bundling stays in release.yml on tag push.
tauri-cross-platform:
name: Tauri shell check (${{ matrix.label }})
needs: test
strategy:
fail-fast: false
matrix:
include:
- os: macos-14
label: macOS
rust_target: aarch64-apple-darwin
- os: windows-2022
label: Windows
rust_target: x86_64-pc-windows-msvc
- os: ubuntu-22.04
label: Linux
rust_target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Setup Rust (stable)
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_target }}
# Per-target cache key so we don't conflict with the release matrix.
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: frontend/src-tauri -> target
key: ${{ matrix.rust_target }}-check
- name: Setup Bun
uses: oven-sh/setup-bun@v1
# Linux is the only host with non-trivial Tauri build deps —
# webkit2gtk + libayatana-appindicator + xdo. Mirror release.yml.
- name: Linux system deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
build-essential curl wget file libxdo-dev libssl-dev \
libayatana-appindicator3-dev librsvg2-dev \
libasound2-dev
- name: Cache bun deps
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock', 'bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install frontend deps
working-directory: frontend
run: bun install
# tauri-build's setup hook reads tauri.conf.json's `frontendDist`
# ("../dist"), which only exists after a frontend build. Without this,
# `cargo check` would fail on a fresh checkout because the embedded
# asset map can't resolve.
- name: Build frontend (for tauri.conf.json frontendDist)
working-directory: frontend
run: bun run build
- name: Cargo check (Tauri shell)
working-directory: frontend/src-tauri
run: cargo check --target ${{ matrix.rust_target }} --message-format=short
+46
View File
@@ -0,0 +1,46 @@
# Changelog
All notable changes to OmniVoice Studio.
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
Versions track the desktop app (`tauri.conf.json` + `frontend/src-tauri/Cargo.toml`).
The bundled TTS model package (`pyproject.toml`) is versioned independently.
## [0.2.6] — Unreleased
### Added
- **Single-instance enforcement.** Launching a second copy now focuses the existing window instead of starting a second backend that races for port 3900. Powered by `tauri-plugin-single-instance`.
- **Close-to-tray.** Clicking the window X (or `Cmd+W` on macOS) now hides the window and keeps the backend + tray menu alive. The tray "Quit" item is the only path that fully exits and shuts down the Python backend (cleanup moved to `RunEvent::ExitRequested`).
- **Recording-state tray icon.** Tray icon flips to a red-dot variant while a dictation recording is active and reverts when it stops or errors out.
- **Customizable global dictation hotkey.** New **Settings → Capture** tab. Record any modifier-plus-key combo, save it, and it's persisted in `config.json` and re-registered on every launch. Failed registrations (combo already taken by the OS) roll back to the previously-working binding instead of leaving the user with no shortcut.
- **WebSocket-final dictation path.** Capture now treats the streaming `final` message as the source of truth and skips the duplicate HTTP `POST /transcribe` that used to run on every dictation. Audio is transcribed once instead of twice — typical dictation latency roughly halved. New EOF text-frame protocol (server also accepts an empty binary frame as EOF). HTTP POST kept as fallback for WS error / timeout / WS-never-opened.
- **Chunk queueing during WS handshake.** The first 250 ms of audio is no longer dropped from the server's `final` transcript. `MediaRecorder` chunks captured while the WebSocket is still in `CONNECTING` state are queued and drained in `ws.onopen`.
### Changed
- **Docker default bind is loopback.** `docker-compose.yml` now publishes `127.0.0.1:3900:3900` instead of `3900:3900` — the API is no longer reachable from the LAN out of the box. To expose it deliberately, change the mapping to `0.0.0.0:3900:3900`. README documents the trade-off and recommends a reverse proxy with auth (Caddy `basic_auth`, nginx + htpasswd, Tailscale) for any non-loopback exposure.
- **Donate page trimmed.** Removed Patreon and the Bitcoin / Ethereum / Solana cryptocurrency cards. Removed the bundled `qrcode.react` dependency. The "Commercial License" CTA moves from the bottom of the page to the top-right of the page header.
- **WS dictation hostname** now derived from the configured `API_BASE` instead of a hardcoded `localhost:3900`, so deployments behind reverse proxies route correctly.
- **HTTP POST fallback timeout** scales with recording length (`max(15s, recordedMs + 10s)`) so long-form dictations don't trip the fallback and run the model twice.
### Fixed
- **Backend was killed on every window close** even if the user only intended to dismiss the window. Backend shutdown now fires only on real-quit (`RunEvent::ExitRequested`), not on the close-to-hide path.
- **Hotkey rollback.** `set_dictation_shortcut` previously left the user with no global shortcut if `register(new)` failed after `unregister(old)` succeeded. The previous binding is now restored on failure.
### Infrastructure
- **CI cross-platform check.** PRs now run `cargo check` against the Tauri shell on macOS (Apple Silicon), Windows, and Linux in parallel — surfaces platform-specific Rust regressions before tag push without paying the full ~15 min/platform tauri-bundle cost (full bundling stays in `release.yml` on tag push).
- **Tests:** `tests/test_capture_ws.py` (3 cases) covers the EOF text-frame, empty-binary-frame, and legacy disconnect-finalize paths for `/ws/transcribe`.
### Internal
- New Tauri commands: `quit_app`, `set_tray_recording`, `get_dictation_shortcut`, `set_dictation_shortcut`.
- New Tauri state: `AppFlags { quitting }`, `TrayHandle { tray }`, `DictationShortcutState { current }`.
- New deps: `tauri-plugin-single-instance` 2.x, `tauri/image-png` feature flag (enables `Image::from_bytes` for in-memory tray-icon swap).
---
## [0.2.5] — 2026-04-29
Region selector, realtime download speed, retry buttons, recheck top-right, HF mirror support, splash bootstrap-log backfill. See git log `v0.2.4..v0.2.5` for the full set.
## Earlier releases
See [GitHub Releases](https://github.com/debpalash/OmniVoice-Studio/releases) for prior versions.
+4 -4
View File
@@ -18,7 +18,7 @@ RUN bun run --cwd frontend build
# ==========================================
# Runtime Stage: Python & PyTorch Backend
# ==========================================
FROM pytorch/pytorch:2.4.0-cuda12.1-cudnn9-runtime AS runtime
FROM pytorch/pytorch:2.8.0-cuda12.8-cudnn9-runtime AS runtime
WORKDIR /app
# Enable unbuffered logs and optimizations
@@ -39,9 +39,9 @@ RUN pip install --no-cache-dir uv
# Copy python packaging specs
COPY pyproject.toml uv.lock ./
# Native wheels from PyPI embed CUDA matching `torch >= 2.4` standard index
# By installing via `uv`, the process completes exponentially faster
RUN uv pip install --system --no-cache -e .
# Install the project (non-editable — no need for -e in containers).
# Uses `uv` for exponentially faster resolution than plain pip.
RUN uv pip install --system --no-cache .
# Copy application source
COPY backend/ ./backend/
+2
View File
@@ -180,6 +180,8 @@ docker compose --profile gpu up --build -d
Open [http://localhost:3900](http://localhost:3900) once the health check passes. First run downloads ~4 GB of model weights — progress is shown in `docker compose logs -f`.
> **Network access:** the container binds to `127.0.0.1` only. To reach OmniVoice from another machine on your LAN, change the port mapping in `docker-compose.yml` to `"0.0.0.0:3900:3900"`. OmniVoice ships no built-in authentication — when exposing it beyond your machine, put it behind a reverse proxy with auth (Caddy `basic_auth`, nginx + htpasswd, Tailscale, etc.).
### Local Development
**Prerequisites:** [ffmpeg](https://ffmpeg.org/), [Bun](https://bun.sh/), [uv](https://docs.astral.sh/uv/)
+65 -33
View File
@@ -50,22 +50,64 @@ async def ws_transcribe(websocket: WebSocket):
last_audio_time = time.monotonic()
running = True
partial_text = ""
# Track whether the client initiated the disconnect. When True the
# WebSocket is already in a closed/closing state and any attempt to
# call `send_json()` will raise "Unexpected ASGI message".
client_disconnected = False
async def receive_audio():
"""Receive audio frames from the client."""
nonlocal total_bytes, last_audio_time, running
"""Receive audio frames from the client.
Two end-of-stream signals: (a) text frame ``"EOF"`` (preferred —
keeps the socket open so the ``final`` message can still be sent
before the client closes), or (b) socket disconnect (legacy path).
The EOF protocol exists so the client can use the WS ``final``
message as the authoritative result and skip the duplicate HTTP
POST that used to run on every dictation.
"""
nonlocal total_bytes, last_audio_time, running, client_disconnected
try:
while running:
data = await websocket.receive_bytes()
audio_chunks.append(data)
total_bytes += len(data)
last_audio_time = time.monotonic()
msg = await websocket.receive()
msg_type = msg.get("type")
if msg_type == "websocket.disconnect":
client_disconnected = True
running = False
break
if msg_type != "websocket.receive":
continue
data = msg.get("bytes")
if data is not None:
if len(data) == 0:
# Empty binary frame also acts as EOF — connection stays open.
running = False
break
audio_chunks.append(data)
total_bytes += len(data)
last_audio_time = time.monotonic()
continue
if msg.get("text") == "EOF":
# Client signals end-of-audio but stays connected for `final`.
running = False
break
except WebSocketDisconnect:
client_disconnected = True
running = False
except Exception as e:
logger.debug("WS receive ended: %s", e)
client_disconnected = True
running = False
async def _safe_send(payload: dict) -> bool:
"""Send JSON to the client, returning False if the connection is gone."""
if client_disconnected:
return False
try:
await websocket.send_json(payload)
return True
except Exception:
return False
async def process_partials():
"""Periodically transcribe the accumulated buffer for partial results."""
nonlocal partial_text, running
@@ -89,7 +131,7 @@ async def ws_transcribe(websocket: WebSocket):
text = await _transcribe_buffer(audio_chunks[:])
if text and text != partial_text:
partial_text = text
await websocket.send_json({
await _safe_send({
"type": "partial",
"text": text,
})
@@ -113,42 +155,32 @@ async def ws_transcribe(websocket: WebSocket):
except (asyncio.CancelledError, Exception):
pass
# Final transcription on complete buffer
# Final transcription on complete buffer — skip if client already gone.
if total_bytes > MIN_BUFFER_BYTES:
try:
result = await _transcribe_buffer_full(audio_chunks)
await websocket.send_json({
"type": "final",
**result,
})
if not await _safe_send({"type": "final", **result}):
logger.debug("Skipped final send — client already disconnected")
except Exception as e:
logger.error("Final transcription failed: %s", e)
try:
await websocket.send_json({
"type": "error",
"detail": str(e),
})
except Exception:
pass
await _safe_send({"type": "error", "detail": str(e)})
else:
await _safe_send({
"type": "final",
"text": "",
"segments": [],
"language": "unknown",
"duration_s": 0,
"transcription_time_s": 0,
"engine": "none",
})
if not client_disconnected:
try:
await websocket.send_json({
"type": "final",
"text": "",
"segments": [],
"language": "unknown",
"duration_s": 0,
"transcription_time_s": 0,
"engine": "none",
})
await websocket.close()
except Exception:
pass
try:
await websocket.close()
except Exception:
pass
async def _transcribe_buffer(chunks: list[bytes]) -> str:
"""Quick partial transcription of the current audio buffer."""
+1 -4
View File
@@ -15,7 +15,7 @@
},
"frontend": {
"name": "omnivoice-studio",
"version": "0.2.4",
"version": "0.2.5",
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/source-serif-4": "^5.2.9",
@@ -41,7 +41,6 @@
"i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.8.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hot-toast": "^2.6.0",
@@ -686,8 +685,6 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="],
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
+9 -2
View File
@@ -7,6 +7,13 @@
#
# First run downloads ~4 GB of models. Progress is shown in logs.
# Open http://localhost:3900 once the health check passes.
#
# SECURITY: The port is bound to 127.0.0.1 by default — only this
# machine can reach the API. To expose OmniVoice on your LAN (or
# through a reverse proxy / tunnel), change the port mapping to
# "0.0.0.0:3900:3900" or "3900:3900". OmniVoice itself ships no
# authentication — if you expose it, put it behind a reverse proxy
# with auth (Caddy basic_auth, nginx + htpasswd, Tailscale, etc.).
# ──────────────────────────────────────────────────────────────
services:
@@ -15,7 +22,7 @@ services:
build: .
container_name: omnivoice-studio
ports:
- "3900:3900"
- "127.0.0.1:3900:3900"
volumes:
- omnivoice-data:/app/omnivoice_data
environment:
@@ -37,7 +44,7 @@ services:
container_name: omnivoice-studio-gpu
profiles: ["gpu"]
ports:
- "3900:3900"
- "127.0.0.1:3900:3900"
volumes:
- omnivoice-data:/app/omnivoice_data
environment:
+1 -2
View File
@@ -1,7 +1,7 @@
{
"name": "omnivoice-studio",
"private": true,
"version": "0.2.5",
"version": "0.2.6",
"type": "module",
"scripts": {
"dev": "vite",
@@ -38,7 +38,6 @@
"i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.8.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hot-toast": "^2.6.0",
+73 -5
View File
@@ -77,7 +77,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "app"
version = "0.2.5"
version = "0.2.6"
dependencies = [
"enigo",
"flate2",
@@ -95,6 +95,7 @@ dependencies = [
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"ureq",
@@ -475,6 +476,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -1895,7 +1902,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
dependencies = [
"byteorder",
"png",
"png 0.17.16",
]
[[package]]
@@ -2013,6 +2020,19 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.1",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2403,6 +2423,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80986bbbcf925ebd3be54c26613d861255284584501595cf418320c078945608"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "muda"
version = "0.17.2"
@@ -2418,7 +2448,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
@@ -3112,6 +3142,19 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.11.0",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
@@ -3254,6 +3297,15 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "pxfm"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3502d6155304a4173a5f2c34b52b7ed0dd085890326cb50fd625fdf39e86b3b"
dependencies = [
"num-traits",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -4370,6 +4422,7 @@ dependencies = [
"heck 0.5.0",
"http",
"http-range",
"image",
"jni",
"libc",
"log",
@@ -4437,7 +4490,7 @@ dependencies = [
"ico",
"json-patch",
"plist",
"png",
"png 0.17.16",
"proc-macro2",
"quote",
"semver",
@@ -4595,6 +4648,21 @@ dependencies = [
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a33a5b7d78f0dec4406b003ea87c40bf928d801b6fd9323a556172c91d8712c1"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.18",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
@@ -5109,7 +5177,7 @@ dependencies = [
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "app"
version = "0.2.5"
version = "0.2.6"
description = "A Tauri App"
authors = ["you"]
license = ""
@@ -21,7 +21,7 @@ tauri-build = { version = "2.5.6", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.10.3", features = ["macos-private-api", "protocol-asset", "tray-icon"] }
tauri = { version = "2.10.3", features = ["macos-private-api", "protocol-asset", "tray-icon", "image-png"] }
tauri-plugin-log = "2"
tauri-plugin-dialog = "2"
tauri-plugin-window-state = "2.0.0"
@@ -29,6 +29,7 @@ tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-opener = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-single-instance = "2"
# Cross-platform keyboard simulation for auto-paste after dictation
enigo = { version = "0.3", features = ["serde"] }
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+265 -56
View File
@@ -3,12 +3,14 @@ use std::io::{self, BufRead, BufReader, Read};
use std::net::TcpStream;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager};
use tauri::image::Image;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::TrayIconBuilder;
use tauri::tray::{TrayIcon, TrayIconBuilder};
// ── Auto-paste (dictation → ⌘V into active app) ─────────────────────────
use enigo::{Direction, Enigo, Key, Keyboard, Settings as EnigoSettings};
@@ -33,6 +35,29 @@ pub struct BackendState {
pub process: Mutex<Option<Child>>,
}
// Tray + lifecycle state. `quitting` flips true when the user picks the tray
// "Quit OmniVoice" menu item (or otherwise asks for a real exit) so the
// window CloseRequested handler knows to allow the close instead of hiding.
pub struct AppFlags {
pub quitting: AtomicBool,
}
// Holds the tray icon handle so we can swap its image (red dot during
// recording) and embedded variants of both icons (compiled in via include_bytes
// — no resource bundling needed).
pub struct TrayHandle {
pub tray: Mutex<Option<TrayIcon>>,
}
// Current global dictation shortcut. Stored so `set_dictation_shortcut` can
// unregister the old binding before registering the new one.
pub struct DictationShortcutState {
pub current: Mutex<Option<tauri_plugin_global_shortcut::Shortcut>>,
}
const TRAY_ICON_DEFAULT: &[u8] = include_bytes!("../icons/32x32.png");
const TRAY_ICON_RECORDING: &[u8] = include_bytes!("../icons/tray-recording.png");
// ── Bootstrap progress (for the React splash screen) ─────────────────────
#[derive(Clone, Serialize, Debug)]
@@ -70,10 +95,22 @@ pub struct AppConfig {
/// "global" or "china"
#[serde(default = "default_region")]
pub region: String,
/// Accelerator string for the global dictation hotkey, e.g.
/// "CmdOrCtrl+Shift+Space". Parsed by tauri-plugin-global-shortcut at
/// register time. Falls back to the platform default when missing or
/// unparseable.
#[serde(default = "default_dictation_shortcut")]
pub dictation_shortcut: String,
}
fn default_region() -> String { "global".into() }
fn default_dictation_shortcut() -> String { "CmdOrCtrl+Shift+Space".into() }
impl Default for AppConfig {
fn default() -> Self { Self { region: default_region() } }
fn default() -> Self {
Self {
region: default_region(),
dictation_shortcut: default_dictation_shortcut(),
}
}
}
fn config_path<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Option<PathBuf> {
@@ -1400,11 +1437,99 @@ fn simulate_paste() -> Result<(), String> {
Ok(())
}
// ── Tray icon recording-state swap ──────────────────────────────────────
#[tauri::command]
fn set_tray_recording(
recording: bool,
tray_handle: tauri::State<'_, TrayHandle>,
) -> Result<(), String> {
let bytes = if recording { TRAY_ICON_RECORDING } else { TRAY_ICON_DEFAULT };
let img = Image::from_bytes(bytes).map_err(|e| format!("decode tray icon: {e}"))?;
let lock = tray_handle.tray.lock().map_err(|_| "tray lock poisoned")?;
if let Some(ref tray) = *lock {
tray.set_icon(Some(img)).map_err(|e| format!("set_icon: {e}"))?;
}
Ok(())
}
// ── Real quit (used by the tray "Quit" item) ─────────────────────────────
// Sets the quitting flag so the window CloseRequested handler stops
// intercepting, then asks the app to exit. Backend shutdown happens in the
// RunEvent::ExitRequested handler at the bottom of run().
#[tauri::command]
fn quit_app(app: tauri::AppHandle, flags: tauri::State<'_, AppFlags>) {
flags.quitting.store(true, Ordering::SeqCst);
app.exit(0);
}
// ── Dictation hotkey: read / change at runtime ──────────────────────────
#[tauri::command]
fn get_dictation_shortcut(app: tauri::AppHandle) -> String {
load_config(&app).dictation_shortcut
}
#[tauri::command]
fn set_dictation_shortcut(
app: tauri::AppHandle,
accelerator: String,
state: tauri::State<'_, DictationShortcutState>,
) -> Result<String, String> {
use std::str::FromStr;
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut};
let parsed = Shortcut::from_str(&accelerator)
.map_err(|e| format!("Invalid shortcut '{accelerator}': {e}"))?;
let gs = app.global_shortcut();
// Holding the lock across both calls keeps the stored Shortcut consistent
// with what the OS actually has registered. We unregister the old binding
// first (otherwise the new register can fail with "already registered"
// when the user only changed modifiers), and we keep `prev` around so we
// can restore it on failure — otherwise a bad accelerator leaves the user
// with no global shortcut at all.
let mut slot = state.current.lock().map_err(|_| "shortcut lock poisoned")?;
let prev = slot.take();
if let Some(ref p) = prev {
let _ = gs.unregister(p.clone());
}
if let Err(e) = gs.register(parsed.clone()) {
// Roll back so the previously-working shortcut keeps working.
if let Some(p) = prev {
if gs.register(p.clone()).is_ok() {
*slot = Some(p);
}
}
return Err(format!("Failed to register '{accelerator}': {e}"));
}
*slot = Some(parsed);
drop(slot);
// Persist so the new shortcut survives a restart.
let mut cfg = load_config(&app);
cfg.dictation_shortcut = accelerator.clone();
save_config(&app, &cfg);
log::info!("Dictation shortcut updated to {accelerator}");
Ok(accelerator)
}
// ── Tauri entry ───────────────────────────────────────────────────────────
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
let app = tauri::Builder::default()
// Single-instance MUST be registered first. When a second copy of the
// binary launches, the closure runs in the already-running instance:
// we just surface the existing window and discard the second process.
// This prevents two backends fighting over port 3900.
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
log::info!("Second instance attempted — focusing existing window");
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
let _ = win.unminimize();
let _ = win.set_focus();
}
}))
.invoke_handler(tauri::generate_handler![
bootstrap_status,
get_bootstrap_logs,
@@ -1416,6 +1541,10 @@ pub fn run() {
read_log_tail,
hf_cache_scan,
simulate_paste,
set_tray_recording,
quit_app,
get_dictation_shortcut,
set_dictation_shortcut,
])
.setup(|app| {
app.handle().plugin(tauri_plugin_dialog::init())?;
@@ -1436,23 +1565,35 @@ pub fn run() {
.build(),
)?;
// ── Global shortcut: ⌘+⇧+Space (system-wide dictation) ──────────
// Lifecycle + tray-handle state — must be managed BEFORE the
// tray builder runs (the tray menu handler reads AppFlags) and
// before set_tray_recording can fire from the frontend.
app.manage(AppFlags {
quitting: AtomicBool::new(false),
});
app.manage(TrayHandle {
tray: Mutex::new(None),
});
app.manage(DictationShortcutState {
current: Mutex::new(None),
});
// ── Global dictation shortcut (user-configurable) ────────────────
{
use std::str::FromStr;
use tauri_plugin_global_shortcut::{
Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState,
GlobalShortcutExt, Shortcut, ShortcutState,
};
let dictation_shortcut = Shortcut::new(
Some(Modifiers::META | Modifiers::SHIFT),
Code::Space,
);
// Plugin handler: any registered shortcut press emits the
// dictation event. We only ever bind one shortcut at a time
// (the active one is tracked in DictationShortcutState), so
// there's nothing else to disambiguate here.
app.handle().plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(move |app_handle, shortcut, event| {
if event.state == ShortcutState::Pressed
&& *shortcut == dictation_shortcut
{
.with_handler(move |app_handle, _shortcut, event| {
if event.state == ShortcutState::Pressed {
log::info!("Global shortcut triggered: dictation");
// Show + focus the window so CaptureButton can record
if let Some(win) = app_handle.get_webview_window("main") {
let _ = win.show();
let _ = win.set_focus();
@@ -1462,10 +1603,34 @@ pub fn run() {
})
.build(),
)?;
// Register the shortcut
match app.global_shortcut().register(dictation_shortcut) {
Ok(()) => log::info!("Global shortcut ⌘⇧Space registered"),
Err(e) => log::warn!("Failed to register global shortcut: {e}"),
// Read the user's saved shortcut (or the default) and register
// it. If the saved string is malformed for any reason, log and
// fall back to the default so dictation still works.
let cfg = load_config(app.handle());
let accel = cfg.dictation_shortcut.clone();
let parsed = Shortcut::from_str(&accel)
.or_else(|_| {
log::warn!(
"Saved shortcut '{accel}' unparseable — falling back to default"
);
Shortcut::from_str(&default_dictation_shortcut())
});
match parsed {
Ok(shortcut) => match app.global_shortcut().register(shortcut.clone()) {
Ok(()) => {
log::info!("Global shortcut '{accel}' registered");
if let Ok(mut slot) = app
.state::<DictationShortcutState>()
.current
.lock()
{
*slot = Some(shortcut);
}
}
Err(e) => log::warn!("Failed to register global shortcut: {e}"),
},
Err(e) => log::warn!("No usable dictation shortcut: {e}"),
}
}
@@ -1492,7 +1657,7 @@ pub fn run() {
.item(&quit_i)
.build()?;
let _tray = TrayIconBuilder::new()
let tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&tray_menu)
.tooltip("OmniVoice Studio")
@@ -1501,6 +1666,8 @@ pub fn run() {
"show" => {
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
#[cfg(not(target_os = "macos"))]
let _ = win.set_skip_taskbar(false);
let _ = win.set_focus();
}
}
@@ -1511,17 +1678,29 @@ pub fn run() {
"settings" => {
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
#[cfg(not(target_os = "macos"))]
let _ = win.set_skip_taskbar(false);
let _ = win.set_focus();
}
let _ = app.emit("tray-navigate", "settings");
}
"quit" => {
// Mark quitting so the CloseRequested handler
// stops intercepting on the way out, then exit.
// Backend shutdown happens in the run-event loop.
app.state::<AppFlags>()
.quitting
.store(true, Ordering::SeqCst);
app.exit(0);
}
_ => {}
}
})
.build(app)?;
// Stash the tray handle so set_tray_recording can swap its icon.
if let Ok(mut slot) = app.state::<TrayHandle>().tray.lock() {
*slot = Some(tray);
}
// ── Enable microphone / camera on Linux (WebKitGTK) ──────────
// WebKitGTK has no browser-style permission dialog; it denies
@@ -1657,44 +1836,74 @@ pub fn run() {
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::Destroyed = event {
if window.label() == "main" {
if let Ok(mut lock) = window.state::<BackendState>().process.lock() {
if let Some(ref mut child) = *lock {
let pid = child.id();
log::info!("Shutting down backend (pid {})", pid);
// SIGTERM first for graceful Python shutdown, then SIGKILL.
#[cfg(unix)]
{
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if start.elapsed() < Duration::from_secs(2) => {
std::thread::sleep(Duration::from_millis(100));
}
_ => {
log::warn!("Backend didn't exit in 2 s — SIGKILL");
let _ = child.kill();
break;
}
}
}
}
#[cfg(not(unix))]
{
let _ = child.kill();
}
let _ = child.wait();
}
}
// Close-to-hide: clicking the X (or Cmd+W on macOS) hides the
// window instead of tearing the app down, so the tray icon stays
// useful and the global hotkey keeps working. The user gets a
// real exit via the tray "Quit" menu (or Cmd+Q on macOS, which
// triggers RunEvent::ExitRequested directly without firing
// CloseRequested on individual windows).
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if window.label() != "main" {
return;
}
let quitting = window
.app_handle()
.state::<AppFlags>()
.quitting
.load(Ordering::SeqCst);
if quitting {
return; // Allow the close — exit handler will reap the backend.
}
api.prevent_close();
let _ = window.hide();
#[cfg(not(target_os = "macos"))]
{
let _ = window.set_skip_taskbar(true);
}
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
.build(tauri::generate_context!())
.expect("error while building tauri application");
app.run(|app_handle, event| {
if let tauri::RunEvent::ExitRequested { .. } = event {
// Real exit: reap the Python backend so we don't orphan a uvicorn
// process holding port 3900. Previously this lived in the window
// Destroyed handler, which fired on every close — including the
// close-to-hide path, which left the user with no backend.
if let Ok(mut lock) = app_handle.state::<BackendState>().process.lock() {
if let Some(ref mut child) = *lock {
let pid = child.id();
log::info!("Shutting down backend (pid {})", pid);
// SIGTERM first for graceful Python shutdown, then SIGKILL.
#[cfg(unix)]
{
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if start.elapsed() < Duration::from_secs(2) => {
std::thread::sleep(Duration::from_millis(100));
}
_ => {
log::warn!("Backend didn't exit in 2 s — SIGKILL");
let _ = child.kill();
break;
}
}
}
}
#[cfg(not(unix))]
{
let _ = child.kill();
}
let _ = child.wait();
}
}
}
});
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "OmniVoice Studio",
"version": "0.2.5",
"version": "0.2.6",
"identifier": "com.debpalash.omnivoice-studio",
"build": {
"frontendDist": "../dist",
+148 -44
View File
@@ -7,6 +7,15 @@ import './CaptureButton.css';
import { API as API_BASE } from '../api/client';
import { addTranscription } from '../pages/Transcriptions';
// Flip the system tray icon between default and red-dot. No-op when not
// running inside the Tauri shell (e.g. browser webui, Docker).
async function setTrayRecording(recording) {
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('set_tray_recording', { recording });
} catch { /* not in Tauri */ }
}
const CAPTURE_MODES = [
{ id: 'fast', label: 'Turbo', desc: 'MLX Whisper Turbo — fastest', icon: <Zap size={12} /> },
{ id: 'accurate', label: 'Accurate', desc: 'WhisperX — best word timing', icon: <Target size={12} /> },
@@ -45,6 +54,20 @@ export default function CaptureButton() {
const streamRef = useRef(null);
const timerRef = useRef(null);
const wsRef = useRef(null);
// Chunks captured before the WebSocket finishes its handshake — drained
// in `ws.onopen` so the server's `final` transcript covers the full
// recording (no missing first 250 ms).
const wsPendingRef = useRef([]);
// Set when the WebSocket delivers a `final` message. Used to dedupe
// against the HTTP POST fallback so we don't transcribe twice.
const wsHadFinalRef = useRef(false);
// Cancellable timer that fires the HTTP POST fallback if WS `final`
// never arrives in time.
const fallbackTimerRef = useRef(null);
// Wall-clock start of the current recording. Read by stopRecording to
// size the WS-fallback timeout against actual recording length without
// closing over the (stale) `duration` state.
const startTimeRef = useRef(0);
// Keyboard shortcut: Ctrl+Shift+Space (or ⌘+Shift+Space on Mac)
useEffect(() => {
@@ -90,6 +113,34 @@ export default function CaptureButton() {
clearInterval(timerRef.current);
}, [state]);
// Render a transcription result (from either the WS `final` message or
// the HTTP POST fallback). Idempotent — guarded by wsHadFinalRef so a
// late HTTP response can't overwrite a WS final that already landed.
const applyResult = useCallback(async (data) => {
setTranscript(data.text || '');
setLastEngine(data.engine || '');
setLastTime(data.transcription_time_s || 0);
setState('done');
if (data.text) {
addTranscription(data);
}
if (data.text && autoCopy) {
try {
await navigator.clipboard.writeText(data.text);
setCopied(true);
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('simulate_paste');
toast.success('Pasted into active app', { duration: 2000 });
} catch {
toast.success('Copied to clipboard — paste with ⌘V', { duration: 2000 });
}
} catch { /* clipboard API may fail in some contexts */ }
}
}, [autoCopy]);
const startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
@@ -97,6 +148,12 @@ export default function CaptureButton() {
});
streamRef.current = stream;
chunksRef.current = [];
wsPendingRef.current = [];
wsHadFinalRef.current = false;
if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current);
fallbackTimerRef.current = null;
}
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
@@ -106,20 +163,37 @@ export default function CaptureButton() {
recorder.ondataavailable = (e) => {
if (e.data.size > 0) {
chunksRef.current.push(e.data);
// Stream chunk to WebSocket for partial results
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
e.data.arrayBuffer().then(buf => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(buf);
}
});
}
// Stream chunk to WebSocket for partial results AND to drive the
// server's `final` transcription. If the socket is still in
// CONNECTING state, queue the chunk so `ws.onopen` can drain it
// — otherwise the server's final transcript would lose the
// first ~250 ms of audio (the open-handshake window).
e.data.arrayBuffer().then(buf => {
const ws = wsRef.current;
if (!ws) return;
if (ws.readyState === WebSocket.OPEN) {
ws.send(buf);
} else if (ws.readyState === WebSocket.CONNECTING) {
wsPendingRef.current.push(buf);
}
});
}
};
recorder.onstop = () => sendForTranscription();
// recorder.onstop frees the mic and (only as fallback) kicks the HTTP
// POST. The WebSocket `final` path is preferred — see ws.onmessage.
recorder.onstop = () => {
if (wsHadFinalRef.current) return;
if (!wsRef.current) {
// WS never opened — HTTP POST is the only path.
sendForTranscription();
}
// Otherwise: the fallback timer set in stopRecording will fire if
// the WS final never arrives.
};
mediaRecorderRef.current = recorder;
recorder.start(250); // collect in 250ms chunks
startTimeRef.current = Date.now();
setState('recording');
setDuration(0);
setTranscript('');
@@ -128,19 +202,39 @@ export default function CaptureButton() {
setCopied(false);
setLastEngine('');
setLastTime(0);
setTrayRecording(true);
// Open WebSocket for streaming partial results
// Open WebSocket for streaming partial results + final transcript
try {
const wsProto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const wsUrl = `${wsProto}://${window.location.hostname}:3900/ws/transcribe`;
const wsHost = API_BASE.replace(/^https?:\/\//, '').replace(/\/$/, '')
|| `${window.location.hostname}:3900`;
const wsUrl = `${wsProto}://${wsHost}/ws/transcribe`;
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
// Drain chunks captured during the WS handshake.
for (const buf of wsPendingRef.current) {
try { ws.send(buf); } catch {}
}
wsPendingRef.current = [];
};
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
if (msg.type === 'partial') {
setPartialText(msg.text || '');
} else if (msg.type === 'final') {
wsHadFinalRef.current = true;
if (fallbackTimerRef.current) {
clearTimeout(fallbackTimerRef.current);
fallbackTimerRef.current = null;
}
applyResult(msg);
try { ws.close(); } catch {}
} else if (msg.type === 'error') {
// Let the fallback timer fire HTTP POST.
}
// final/error handled after stopRecording
} catch {}
};
ws.onerror = () => { wsRef.current = null; };
@@ -152,9 +246,10 @@ export default function CaptureButton() {
}
} catch (err) {
toast.error('Microphone access denied. Check browser permissions.');
setTrayRecording(false);
setState('error');
}
}, []);
}, [applyResult]);
const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
@@ -164,15 +259,46 @@ export default function CaptureButton() {
streamRef.current.getTracks().forEach(t => t.stop());
streamRef.current = null;
}
// Close WebSocket to trigger final transcription
if (wsRef.current) {
try { wsRef.current.close(); } catch {}
wsRef.current = null;
// Signal end-of-audio to the WS but DO NOT close — we want the server's
// `final` message to arrive over the same socket. The HTTP POST fallback
// timer below covers the case where final never lands.
const ws = wsRef.current;
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
const sendEof = () => { try { ws.send('EOF'); } catch {} };
if (ws.readyState === WebSocket.OPEN) {
sendEof();
} else {
// Wait for open before sending EOF, otherwise the message is dropped.
ws.addEventListener('open', sendEof, { once: true });
}
// Fallback: if WS final doesn't arrive in time, use HTTP POST.
// Cleared in ws.onmessage when `final` lands. Timeout scales with
// recording length so long-form dictation (where the server's final
// pass naturally takes longer) doesn't trip the fallback and run the
// model twice. Floor of 15 s covers slow first-call cold starts.
const recorded = startTimeRef.current
? Date.now() - startTimeRef.current
: 0;
const ms = Math.max(15000, recorded + 10000);
if (fallbackTimerRef.current) clearTimeout(fallbackTimerRef.current);
fallbackTimerRef.current = setTimeout(() => {
fallbackTimerRef.current = null;
if (!wsHadFinalRef.current) {
try { wsRef.current?.close(); } catch {}
wsRef.current = null;
sendForTranscription();
}
}, ms);
}
setTrayRecording(false);
setState('transcribing');
}, []);
const sendForTranscription = useCallback(async () => {
// Race-guard: WS final may have landed between when this was scheduled
// and now. Skip the duplicate HTTP transcription.
if (wsHadFinalRef.current) return;
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
const formData = new FormData();
formData.append('audio', blob, 'capture.webm');
@@ -188,38 +314,16 @@ export default function CaptureButton() {
throw new Error(detail.detail || `HTTP ${res.status}`);
}
const data = await res.json();
setTranscript(data.text || '');
setLastEngine(data.engine || '');
setLastTime(data.transcription_time_s || 0);
setState('done');
// Persist to Transcriptions page history
if (data.text) {
addTranscription(data);
}
// Auto-copy to clipboard for instant paste into other apps
if (data.text && autoCopy) {
try {
await navigator.clipboard.writeText(data.text);
setCopied(true);
// Auto-paste: simulate ⌘V into the previously active app
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('simulate_paste');
toast.success('Pasted into active app', { duration: 2000 });
} catch {
// Not in Tauri or accessibility permission missing
toast.success('Copied to clipboard — paste with ⌘V', { duration: 2000 });
}
} catch { /* clipboard API may fail in some contexts */ }
}
// Re-check guard — a WS final could land while we awaited the POST.
if (wsHadFinalRef.current) return;
await applyResult(data);
} catch (err) {
if (wsHadFinalRef.current) return;
toast.error(`Transcription failed: ${err.message}`);
setState('error');
setTranscript('');
}
}, [captureMode, autoCopy]);
}, [captureMode, applyResult]);
const copyToClipboard = useCallback(() => {
navigator.clipboard.writeText(transcript).then(() => {
+6 -99
View File
@@ -13,11 +13,15 @@
isolation: isolate;
}
/* ── Back button ───────────────────────────────────────────── */
.donate-page__back {
/* ── Top bar (Back + Commercial License) ──────────────────── */
.donate-page__topbar {
padding: 16px 44px 0;
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
/* ── Content container ─────────────────────────────────────── */
@@ -112,12 +116,6 @@
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
}
.donate-grid--crypto {
display: flex;
flex-direction: column;
gap: 10px;
}
/* ── Shared card chrome ───────────────────────────────────── */
.donate-card {
position: relative;
@@ -227,97 +225,6 @@
background: color-mix(in srgb, var(--card-hue, #d3869b) 8%, transparent);
}
/* ── Crypto-specific elements ────────────────────────────── */
.donate-card__addr-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
flex-wrap: wrap;
}
.donate-card__addr {
flex: 1;
min-width: 0;
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--chrome-font-mono);
font-size: 0.65rem;
color: var(--chrome-fg-dim);
padding: 4px 8px;
border-radius: var(--chrome-radius-pill);
background: var(--chrome-hover-bg);
border: 1px solid var(--chrome-border);
}
.donate-card__addr-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.donate-card__addr-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 24px;
height: 24px;
border-radius: var(--chrome-radius-pill);
background: transparent;
border: 1px solid var(--chrome-border);
color: var(--chrome-fg-muted);
cursor: pointer;
transition: background var(--dur-fast), color var(--dur-fast), border-color var(--dur-fast);
}
.donate-card__addr-btn:hover {
background: var(--chrome-hover-bg);
color: var(--chrome-fg);
border-color: var(--chrome-border-strong);
}
.donate-card__addr-btn--open {
width: auto;
padding: 0 8px;
font-family: var(--chrome-font-mono);
font-size: var(--chrome-label-size);
font-weight: 600;
letter-spacing: var(--chrome-label-track);
}
.donate-card__open-label {
display: none;
}
@media (min-width: 480px) {
.donate-card__open-label { display: inline; }
}
.donate-card__network {
display: block;
margin-top: 4px;
font-family: var(--chrome-font-mono);
font-size: var(--chrome-label-size);
font-weight: 600;
letter-spacing: var(--chrome-label-track);
text-transform: uppercase;
color: var(--chrome-fg-dim);
}
/* QR code */
.donate-card__qr {
display: none;
flex-shrink: 0;
padding: 4px;
border-radius: 6px;
background: #fff;
border: 1px solid var(--chrome-border);
position: relative;
z-index: 1;
transition: transform var(--dur-base);
}
@media (min-width: 560px) {
.donate-card__qr { display: block; }
}
.donate-card:hover .donate-card__qr {
transform: scale(1.04);
}
/* ── Footer ───────────────────────────────────────────────── */
.donate-footer {
text-align: center;
+16 -135
View File
@@ -1,7 +1,5 @@
import React, { useState } from 'react';
import { Heart, Copy, ExternalLink, ArrowLeft, Check, Building2 } from 'lucide-react';
import toast from 'react-hot-toast';
import { QRCodeSVG } from 'qrcode.react';
import React from 'react';
import { Heart, ExternalLink, ArrowLeft, Building2 } from 'lucide-react';
import { Button } from '../ui';
import { openExternal } from '../api/external';
import './DonatePage.css';
@@ -13,15 +11,6 @@ const METHODS = [
description: 'Recurring or one-time — directly through GitHub.',
url: 'https://github.com/debpalash',
icon: '🐙',
type: 'link',
},
{
id: 'patreon',
label: 'Patreon',
description: 'Monthly support with early access perks.',
url: 'https://patreon.com/omnivoicestudio',
icon: '🎨',
type: 'link',
},
{
id: 'kofi',
@@ -29,7 +18,6 @@ const METHODS = [
description: 'Buy the team a coffee. No account needed.',
url: 'https://ko-fi.com/debpalash',
icon: '☕',
type: 'link',
},
{
id: 'paypal',
@@ -37,93 +25,9 @@ const METHODS = [
description: 'Quick one-time or recurring via PayPal.',
url: 'https://paypal.me/palashCoder',
icon: '💳',
type: 'link',
},
{
id: 'btc',
label: 'Bitcoin',
description: 'Native BTC — any amount.',
address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh',
icon: '₿',
type: 'crypto',
network: 'Bitcoin (BTC)',
protocol: 'bitcoin',
},
{
id: 'eth',
label: 'Ethereum',
description: 'ETH or ERC-20 tokens.',
address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F',
icon: 'Ξ',
type: 'crypto',
network: 'Ethereum (ETH / ERC-20)',
protocol: 'ethereum',
},
{
id: 'sol',
label: 'Solana',
description: 'SOL or SPL tokens.',
address: '7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV',
icon: '◎',
type: 'crypto',
network: 'Solana (SOL)',
protocol: 'solana',
},
];
function CryptoCard({ method, style }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(method.address);
setCopied(true);
toast.success(`${method.label} address copied`);
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error('Copy failed');
}
};
return (
<div className="donate-card lp-glow-card" style={style}>
<span className="donate-card__glow" aria-hidden="true" />
<div className="donate-card__icon">{method.icon}</div>
<div className="donate-card__body">
<div className="donate-card__label">{method.label}</div>
<div className="donate-card__desc">{method.description}</div>
<div className="donate-card__addr-row">
<code className="donate-card__addr">{method.address}</code>
<div className="donate-card__addr-actions">
<button className="donate-card__addr-btn" onClick={handleCopy} title="Copy address">
{copied ? <Check size={13} /> : <Copy size={13} />}
</button>
{method.protocol && (
<button
className="donate-card__addr-btn donate-card__addr-btn--open"
onClick={() => openExternal(`${method.protocol}:${method.address}`)}
title="Open in desktop wallet"
>
<ExternalLink size={11} />
<span className="donate-card__open-label">Open</span>
</button>
)}
</div>
</div>
<span className="donate-card__network">{method.network}</span>
</div>
<div className="donate-card__qr">
<QRCodeSVG
value={`${method.protocol || ''}:${method.address}`}
size={48}
bgColor="#ffffff"
fgColor="#000000"
level="M"
includeMargin={false}
/>
</div>
</div>
);
}
function LinkCard({ method, style }) {
return (
<button
@@ -146,9 +50,6 @@ function LinkCard({ method, style }) {
}
export default function DonatePage({ onBack, onEnterprise }) {
const links = METHODS.filter(m => m.type === 'link');
const crypto = METHODS.filter(m => m.type === 'crypto');
return (
<div className="donate-page">
{/* Aurora backdrop — same as Launchpad */}
@@ -158,8 +59,8 @@ export default function DonatePage({ onBack, onEnterprise }) {
<span className="lp-aurora__blob lp-aurora__blob--amber" />
</div>
{/* Back button */}
<div className="donate-page__back">
{/* Top bar: Back (left) + Commercial License (right) */}
<div className="donate-page__topbar">
<Button
variant="subtle"
size="sm"
@@ -168,6 +69,17 @@ export default function DonatePage({ onBack, onEnterprise }) {
>
Back to Studio
</Button>
{onEnterprise && (
<Button
variant="subtle"
size="sm"
onClick={onEnterprise}
leading={<Building2 size={14} />}
trailing={<ExternalLink size={12} />}
>
Commercial License
</Button>
)}
</div>
<div className="donate-page__content">
@@ -192,7 +104,7 @@ export default function DonatePage({ onBack, onEnterprise }) {
<span>Platforms</span>
</div>
<div className="donate-grid donate-grid--links">
{links.map((m, i) => (
{METHODS.map((m, i) => (
<LinkCard
key={m.id}
method={m}
@@ -202,40 +114,9 @@ export default function DonatePage({ onBack, onEnterprise }) {
</div>
</section>
{/* Cryptocurrency */}
<section className="donate-section">
<div className="donate-section__title">
<span>Cryptocurrency</span>
</div>
<div className="donate-grid donate-grid--crypto">
{crypto.map((m, i) => (
<CryptoCard
key={m.id}
method={m}
style={{ '--anim-i': i + 3, '--card-hue': '#fe8019' }}
/>
))}
</div>
</section>
<div className="donate-footer">
Every contribution helps push the boundaries of local AI.
</div>
{/* Enterprise CTA */}
{onEnterprise && (
<div className="donate-enterprise-cta">
<button type="button" className="donate-card donate-card--link" onClick={onEnterprise} style={{ '--card-hue': '#fe8019' }}>
<span className="donate-card__glow" aria-hidden="true" />
<div className="donate-card__icon"><Building2 size={16} /></div>
<div className="donate-card__body">
<div className="donate-card__label">Commercial License</div>
<div className="donate-card__desc">Using OmniVoice in a product or business? See enterprise plans.</div>
</div>
<div className="donate-card__arrow"><ExternalLink size={14} /></div>
</button>
</div>
)}
</div>
</div>
);
+170
View File
@@ -10,6 +10,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import {
Cpu, FileText, Info, ShieldCheck, RefreshCw, Trash2, ExternalLink,
CheckCircle, AlertCircle, Plug, Mic, MessageSquare, Download, Copy, Building2, KeyRound,
Keyboard,
} from 'lucide-react';
import { toast } from 'react-hot-toast';
import { openExternal } from '../api/external';
@@ -25,6 +26,7 @@ import './Settings.css';
const TABS = [
{ id: 'models', label: 'Models', icon: Cpu, accent: '#f3a5b6' },
{ id: 'engines', label: 'Engines', icon: Plug, accent: '#d3869b' },
{ id: 'capture', label: 'Capture', icon: Keyboard, accent: '#83a598' },
{ id: 'credentials', label: 'Credentials', icon: KeyRound, accent: '#fe8019' },
{ id: 'logs', label: 'Logs', icon: FileText, accent: '#fabd2f' },
{ id: 'about', label: 'About', icon: Info, accent: '#8ec07c' },
@@ -1106,6 +1108,8 @@ export default function Settings() {
{activeTab === 'engines' && <EnginesTab />}
{activeTab === 'capture' && <HotkeyTab />}
{activeTab === 'credentials' && <CredentialsTab info={info} />}
{activeTab === 'logs' && (
@@ -1282,6 +1286,172 @@ const CREDENTIAL_FIELDS = [
},
];
// Convert a KeyboardEvent into a tauri-plugin-global-shortcut accelerator
// string, e.g. "CmdOrCtrl+Shift+Space". Returns null when only modifiers
// are held (the user hasn't picked a "real" key yet).
function keyEventToAccelerator(e) {
const isMacLike = typeof navigator !== 'undefined'
&& /Mac|iPad|iPhone|iPod/.test(navigator.platform || '');
const mods = [];
if (e.metaKey) mods.push(isMacLike ? 'Cmd' : 'Super');
if (e.ctrlKey) mods.push('Ctrl');
if (e.altKey) mods.push('Alt');
if (e.shiftKey) mods.push('Shift');
// e.code is the physical key — already in the shape tauri expects for
// Letter/Digit/Function keys ("KeyA", "Digit1", "F5"). Strip the prefix
// so we get "A" / "1" / "F5" which matches the accelerator grammar.
let key = e.code;
if (!key) return null;
if (key.startsWith('Key')) key = key.slice(3);
else if (key.startsWith('Digit')) key = key.slice(5);
// Skip pure modifier keys — we want the user to pick a real trigger.
if (/^(Meta|Control|Alt|Shift|OS)(Left|Right)?$/.test(key)) return null;
if (mods.length === 0) return null;
return [...mods, key].join('+');
}
function HotkeyTab() {
const [current, setCurrent] = useState('');
const [recording, setRecording] = useState(false);
const [pending, setPending] = useState('');
const [saving, setSaving] = useState(false);
const tauri = isTauri();
// Load the saved shortcut on mount.
useEffect(() => {
if (!tauri) return;
(async () => {
try {
const { invoke } = await import('@tauri-apps/api/core');
const v = await invoke('get_dictation_shortcut');
setCurrent(v || '');
} catch (e) {
toast.error(`Could not load shortcut: ${e?.message || e}`);
}
})();
}, [tauri]);
// While recording, swallow keystrokes globally and convert the next real
// press into an accelerator string. Escape cancels.
useEffect(() => {
if (!recording) return;
const onKeyDown = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.key === 'Escape') {
setRecording(false);
setPending('');
return;
}
const accel = keyEventToAccelerator(e);
if (accel) {
setPending(accel);
setRecording(false);
}
};
window.addEventListener('keydown', onKeyDown, true);
return () => window.removeEventListener('keydown', onKeyDown, true);
}, [recording]);
const save = async () => {
if (!pending || pending === current) return;
setSaving(true);
try {
const { invoke } = await import('@tauri-apps/api/core');
const saved = await invoke('set_dictation_shortcut', { accelerator: pending });
setCurrent(saved);
setPending('');
toast.success(`Dictation shortcut set to ${saved}`);
} catch (e) {
// Common cause: the OS or another app already owns the combo. Surface
// the raw error so the user can pick something else.
toast.error(`Couldn't register: ${e?.message || e}`);
} finally {
setSaving(false);
}
};
const resetDefault = async () => {
setSaving(true);
try {
const { invoke } = await import('@tauri-apps/api/core');
const saved = await invoke('set_dictation_shortcut', {
accelerator: 'CmdOrCtrl+Shift+Space',
});
setCurrent(saved);
setPending('');
toast.success('Reset to default');
} catch (e) {
toast.error(`Reset failed: ${e?.message || e}`);
} finally {
setSaving(false);
}
};
return (
<section className="settings-section">
<h2><Keyboard size={16} color="#83a598" /> Capture & Dictation</h2>
{!tauri && (
<p className="settings-prose">
Global hotkeys only work in the desktop app. The web UI uses an
in-page <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Space</kbd> shortcut
while the window has focus.
</p>
)}
<div className="settings-row">
<span className="label">Active shortcut</span>
<span className="value settings-row__mono">{current || '—'}</span>
</div>
<div className="settings-row">
<span className="label">{recording ? 'Press a key combo…' : 'New shortcut'}</span>
<span className="value settings-row__mono">
{recording ? '⌨︎ listening (Esc to cancel)' : (pending || '—')}
</span>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
<Button
size="sm"
variant="subtle"
onClick={() => { setPending(''); setRecording(true); }}
disabled={!tauri || saving}
leading={<Keyboard size={12} />}
>
{recording ? 'Recording…' : 'Record shortcut'}
</Button>
<Button
size="sm"
onClick={save}
disabled={!tauri || !pending || pending === current}
loading={saving}
>
Save
</Button>
<Button
size="sm"
variant="subtle"
onClick={resetDefault}
disabled={!tauri || saving}
>
Reset to default
</Button>
</div>
<p className="settings-prose" style={{ marginTop: 12 }}>
The hotkey works system-wide while OmniVoice is running it focuses
the window and starts dictation. Avoid combos already claimed by the
OS (on macOS, <code>+Space</code> is Spotlight and <code>++Space</code>
cycles input sources). If registration fails, pick a different combo.
</p>
</section>
);
}
function CredentialsTab({ info }) {
const [values, setValues] = useState({});
const [saving, setSaving] = useState(null);
+79 -25
View File
@@ -2,7 +2,8 @@
# ──────────────────────────────────────────────────────────────────────────
# desktop-prod.sh — Build & launch OmniVoice Studio as a "fresh install"
#
# This gives you the EXACT same experience as a user downloading the DMG:
# This gives you the EXACT same experience as a user downloading the
# installer (DMG on macOS, AppImage on Linux):
# • Full Rust bootstrap (venv creation, uv sync, model setup)
# • Splash screen with live logs
# • Region selector, version badge, etc.
@@ -10,14 +11,34 @@
# Usage:
# bun desktop-prod # build debug + wipe + launch
# bun desktop-prod:run # re-launch last build (skip compile)
# bun desktop-prod:upgrade # rebuild, but keep data (test upgrade)
# ──────────────────────────────────────────────────────────────────────────
set -euo pipefail
APP_ID="com.debpalash.omnivoice-studio"
APP_DATA="$HOME/Library/Application Support/${APP_ID}"
TAURI_DIR="frontend/src-tauri"
APP_NAME="OmniVoice Studio"
# ── Detect platform ───────────────────────────────────────────────────────
OS="$(uname -s)"
case "$OS" in
Darwin) PLATFORM="macos" ;;
Linux) PLATFORM="linux" ;;
*) echo "❌ Unsupported platform: $OS"; exit 1 ;;
esac
# ── Platform-specific paths ───────────────────────────────────────────────
if [ "$PLATFORM" = "macos" ]; then
APP_DATA="$HOME/Library/Application Support/${APP_ID}"
TAURI_LOGS="$HOME/Library/Logs/${APP_ID}"
WEBKIT_DATA="$HOME/Library/WebKit/${APP_ID}"
else
# Linux: XDG conventions
APP_DATA="${XDG_DATA_HOME:-$HOME/.local/share}/${APP_ID}"
TAURI_LOGS="${XDG_DATA_HOME:-$HOME/.local/share}/${APP_ID}/logs"
WEBKIT_DATA="${XDG_DATA_HOME:-$HOME/.local/share}/${APP_ID}/webview"
fi
# HF cache — where downloaded models live
HF_CACHE="${HF_HOME:-$HOME/.cache/huggingface}"
@@ -62,7 +83,6 @@ if [ "$KEEP_DATA" = false ]; then
fi
# 3. Tauri log dir
TAURI_LOGS="$HOME/Library/Logs/${APP_ID}"
if [ -d "${TAURI_LOGS}" ]; then
echo " ✗ Tauri logs: ${TAURI_LOGS}"
rm -rf "${TAURI_LOGS}"
@@ -71,7 +91,6 @@ if [ "$KEEP_DATA" = false ]; then
fi
# 4. WebView cache / local storage
WEBKIT_DATA="$HOME/Library/WebKit/${APP_ID}"
if [ -d "${WEBKIT_DATA}" ]; then
echo " ✗ WebKit data: ${WEBKIT_DATA}"
rm -rf "${WEBKIT_DATA}"
@@ -91,12 +110,21 @@ if [ "$SKIP_BUILD" = false ]; then
echo "🔨 Building debug bundle (this takes 1-3 min first time)..."
# Remove stale bundle so we never accidentally launch old code
APP_BUNDLE="${TAURI_DIR}/target/debug/bundle/macos/${APP_NAME}.app"
[ -d "$APP_BUNDLE" ] && rm -rf "$APP_BUNDLE"
if [ "$PLATFORM" = "macos" ]; then
APP_BUNDLE="${TAURI_DIR}/target/debug/bundle/macos/${APP_NAME}.app"
[ -d "$APP_BUNDLE" ] && rm -rf "$APP_BUNDLE"
fi
# The build creates the .app bundle successfully, but then fails trying
# to sign the updater artifact (no TAURI_SIGNING_PRIVATE_KEY). The .app
# itself is fine — tolerate ONLY that specific error.
# Linux: linuxdeploy uses FUSE to mount itself; if FUSE is unavailable
# (containers, some hardened kernels), set APPIMAGE_EXTRACT_AND_RUN=1 to
# extract-and-run instead. Safe to always set on Linux.
if [ "$PLATFORM" = "linux" ]; then
export APPIMAGE_EXTRACT_AND_RUN=1
fi
# The build creates the bundle successfully, but then may fail trying
# to sign the updater artifact (no TAURI_SIGNING_PRIVATE_KEY) or to
# run linuxdeploy. The binary itself is fine — tolerate known errors.
BUILD_LOG=$(mktemp)
cd frontend
set +e
@@ -105,8 +133,11 @@ if [ "$SKIP_BUILD" = false ]; then
set -e
cd ..
if [ $BUILD_EXIT -ne 0 ]; then
if grep -qi "TAURI_SIGNING_PRIVATE_KEY\|private key" "$BUILD_LOG"; then
echo "⚠️ Updater signing skipped (no TAURI_SIGNING_PRIVATE_KEY) — .app is fine."
# Known-harmless failures:
# - Missing TAURI_SIGNING_PRIVATE_KEY (updater signing)
# - "failed to run linuxdeploy" (AppImage bundling — binary still works)
if grep -qi "TAURI_SIGNING_PRIVATE_KEY\|private key\|failed to run linuxdeploy\|failed to bundle" "$BUILD_LOG"; then
echo "⚠️ Non-fatal bundle error — binary is fine (see above for details)."
else
echo "❌ Build failed with exit code $BUILD_EXIT"
rm -f "$BUILD_LOG"
@@ -115,27 +146,50 @@ if [ "$SKIP_BUILD" = false ]; then
fi
rm -f "$BUILD_LOG"
if [ -d "${TAURI_DIR}/target/debug/bundle/macos/${APP_NAME}.app" ]; then
echo "✅ Build complete."
else
echo "❌ Build failed — no .app bundle produced."
exit 1
fi
echo "✅ Build complete."
else
echo "⏭️ Skipping build (--skip-build)"
fi
# ── Find and launch the app ────────────────────────────────────────────────
APP_BUNDLE="${TAURI_DIR}/target/debug/bundle/macos/${APP_NAME}.app"
if [ "$PLATFORM" = "macos" ]; then
APP_BUNDLE="${TAURI_DIR}/target/debug/bundle/macos/${APP_NAME}.app"
BINARY="${TAURI_DIR}/target/debug/app"
if [ -d "$APP_BUNDLE" ]; then
echo ""
echo "🚀 Launching ${APP_NAME} (.app bundle)..."
echo " Bundle: ${APP_BUNDLE}"
open "$APP_BUNDLE"
if [ -d "$APP_BUNDLE" ]; then
echo ""
echo "🚀 Launching ${APP_NAME} (.app bundle)..."
echo " Bundle: ${APP_BUNDLE}"
open "$APP_BUNDLE"
elif [ -f "$BINARY" ]; then
echo ""
echo "🚀 Launching ${APP_NAME} (raw binary — no .app bundle)..."
echo " Binary: ${BINARY}"
"$BINARY" &
else
echo "❌ No bundle or binary found. Run without --skip-build first."
exit 1
fi
else
echo "❌ No bundle found. Run without --skip-build first."
exit 1
# Linux: prefer AppImage, fall back to raw binary
APPIMAGE=$(find "${TAURI_DIR}/target/debug/bundle/appimage" -name "*.AppImage" -type f 2>/dev/null | head -1)
BINARY="${TAURI_DIR}/target/debug/app"
if [ -n "$APPIMAGE" ] && [ -f "$APPIMAGE" ]; then
echo ""
echo "🚀 Launching ${APP_NAME} (AppImage)..."
echo " AppImage: ${APPIMAGE}"
chmod +x "$APPIMAGE"
"$APPIMAGE" &
elif [ -f "$BINARY" ]; then
echo ""
echo "🚀 Launching ${APP_NAME} (raw binary)..."
echo " Binary: ${BINARY}"
"$BINARY" &
else
echo "❌ No AppImage or binary found. Run without --skip-build first."
exit 1
fi
fi
echo " App data: ${APP_DATA}"
+98
View File
@@ -0,0 +1,98 @@
"""
Tests for the streaming-ASR WebSocket endpoint.
Focus: the EOF text-frame protocol (added so the React `CaptureButton` can
treat the WS `final` message as the source of truth and skip the duplicate
HTTP POST that used to run on every dictation). Ground truth: an EOF text
frame must let the server deliver `final` over the still-open socket
*without* the client having to disconnect first.
The ASR backends are mocked — we're testing protocol, not transcription
quality.
"""
import os
import pytest
os.environ.setdefault("OMNIVOICE_MODEL", "test")
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
# Tighten the partial-tick so the test doesn't sit waiting 2 s for the
# silence path.
os.environ["OMNIVOICE_STREAM_INTERVAL"] = "0.1"
os.environ["OMNIVOICE_STREAM_SILENCE"] = "0.2"
@pytest.fixture
def client(monkeypatch):
from fastapi.testclient import TestClient
# Stub the heavy transcription helpers so the test stays in-process.
from api.routers import capture_ws as cw
async def fake_partial(_chunks):
return "hello"
async def fake_full(_chunks):
return {
"text": "hello world",
"segments": [{"start": 0.0, "end": 1.0, "text": "hello world"}],
"language": "en",
"duration_s": 1.0,
"transcription_time_s": 0.01,
"engine": "stub",
}
monkeypatch.setattr(cw, "_transcribe_buffer", fake_partial)
monkeypatch.setattr(cw, "_transcribe_buffer_full", fake_full)
from main import app
return TestClient(app)
def _audio_chunk(n_bytes: int = 20_000) -> bytes:
# MIN_BUFFER_BYTES is 16_000 — give the server enough to trigger a partial
# AND a final.
return b"\x00" * n_bytes
def test_eof_text_frame_triggers_final_without_disconnect(client):
"""Client sends audio + 'EOF' text frame, expects `final` over open socket."""
with client.websocket_connect("/ws/transcribe") as ws:
ws.send_bytes(_audio_chunk())
ws.send_text("EOF")
# Drain whatever the server sends (partials may or may not arrive
# depending on timing). The first message we care about is `final`.
final = None
for _ in range(10):
msg = ws.receive_json()
if msg.get("type") == "final":
final = msg
break
assert final is not None, "server never delivered final after EOF"
assert final["text"] == "hello world"
assert final["engine"] == "stub"
def test_legacy_disconnect_still_finalizes(client):
"""Closing the socket without EOF should still deliver final (legacy path)."""
# Even if the client closes, the server runs final and *attempts* to send
# before the close handshake completes. Whether the test client receives
# it is timing-dependent — we mostly care that no exception bubbles up
# and the server doesn't deadlock.
with client.websocket_connect("/ws/transcribe") as ws:
ws.send_bytes(_audio_chunk())
# Just close — don't wait. Endpoint should clean up gracefully.
def test_empty_binary_frame_acts_as_eof(client):
"""An empty binary frame is the same end-of-audio signal as 'EOF' text."""
with client.websocket_connect("/ws/transcribe") as ws:
ws.send_bytes(_audio_chunk())
ws.send_bytes(b"")
final = None
for _ in range(10):
msg = ws.receive_json()
if msg.get("type") == "final":
final = msg
break
assert final is not None
assert final["engine"] == "stub"