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:
co-authored by
Claude Opus 4.7
parent
5e35e6d0d8
commit
79d4f3b53d
@@ -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
|
||||
|
||||
@@ -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
@@ -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/
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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,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",
|
||||
|
||||
Generated
+73
-5
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user