Compare commits

..
Author SHA1 Message Date
Gitea Actions b4b9435317 chore: bump sidecar version to 1.0.3 [skip ci] 2026-04-07 00:27:28 +00:00
Gitea Actions ee1d4f8643 chore: bump version to 1.4.5 [skip ci] 2026-04-07 00:23:37 +00:00
DeveloperandClaude Opus 4.6 4a186d1de6 Fix CPU sidecar builds bundling CUDA torch instead of CPU
Release / Bump version and tag (push) Successful in 7s
Release / Build App (macOS) (push) Successful in 1m8s
Release / Build App (Windows) (push) Successful in 2m8s
Release / Build App (Linux) (push) Successful in 3m23s
The CPU build steps used `uv run pyinstaller` which re-resolves
dependencies from pyproject.toml's [tool.uv.sources] before running,
pulling CUDA torch back in after the CPU-only reinstall. This made
CPU and CUDA zips the same size.

Fix: run pyinstaller directly from the venv (.venv/bin/pyinstaller
on Linux/macOS, .venv\Scripts\pyinstaller.exe on Windows) to skip
uv's dependency resolution entirely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:23:28 -07:00
Gitea Actions fff37992b1 chore: bump version to 1.4.4 [skip ci] 2026-04-07 00:05:15 +00:00
DeveloperandClaude Opus 4.6 8afe3230d3 Add sidecar download, setup screen, and auto-launch
Release / Bump version and tag (push) Successful in 3s
Release / Build App (macOS) (push) Successful in 1m9s
Release / Build App (Linux) (push) Successful in 5m36s
Release / Build App (Windows) (push) Has been cancelled
On first launch, the app now prompts users to download the Python
sidecar (CPU or CUDA variant) from Gitea releases, matching the
voice-to-notes pattern. On subsequent launches, it auto-launches
the sidecar and connects.

New Rust module (src-tauri/src/sidecar/):
- download_sidecar: streams download with progress events, extracts zip
- check_sidecar: verifies installed sidecar binary exists
- check_sidecar_update: compares local vs latest release version
- SidecarManager: launches binary, waits for ready JSON, manages lifecycle
- Dev mode: runs `python -m backend.main_headless` directly
- start_sidecar/stop_sidecar/get_sidecar_port: Tauri commands

New Svelte component (SidecarSetup.svelte):
- First-time setup overlay with CPU/CUDA variant selection
- Download progress bar with byte counter
- Error state with retry, success state with auto-continue

Updated App.svelte state machine:
- checking -> needs_setup -> starting -> connected
- Falls back to direct connection in browser dev mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:02:56 -07:00
DeveloperandClaude Opus 4.6 04e7fb1a99 Fix macOS sidecar build and blank window on startup
Release / Bump version and tag (push) Has been cancelled
Release / Build App (Linux) (push) Has been cancelled
Release / Build App (Windows) (push) Has been cancelled
Release / Build App (macOS) (push) Has been cancelled
macOS sidecar: `uv run` re-resolves dependencies using CUDA sources
even after `uv sync --no-sources`. Use UV_NO_SOURCES=1 env var instead
so it applies to all uv commands in the step.

Blank window: When the Tauri app starts without the Python backend
running, it showed a completely blank window. Now shows a "Connecting
to backend..." spinner, or an error state with instructions to start
the backend manually.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:55:03 -07:00
Gitea Actions 9a282215c9 chore: bump sidecar version to 1.0.2 [skip ci] 2026-04-06 23:49:18 +00:00
Gitea Actions cc2d17a627 chore: bump version to 1.4.3 [skip ci] 2026-04-06 21:02:18 +00:00
DeveloperandClaude Opus 4.6 61c5ffa4fa Remove Zone.Identifier files that break Windows checkout
Release / Bump version and tag (push) Successful in 4s
Release / Build App (macOS) (push) Successful in 58s
Release / Build App (Windows) (push) Successful in 3m22s
Release / Build App (Linux) (push) Successful in 6m27s
Windows NTFS Zone.Identifier alternate data stream files were
accidentally committed. The colon in the filename is invalid on
Windows, causing git checkout to fail on Windows runners.

Also added *:Zone.Identifier to .gitignore to prevent this recurring.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:02:11 -07:00
Gitea Actions 289b9dabe1 chore: bump version to 1.4.2 [skip ci] 2026-04-06 21:00:01 +00:00
DeveloperandClaude Opus 4.6 9522f28c57 Fix app icons: regenerate as RGBA and add macOS .icns
Release / Bump version and tag (push) Successful in 4s
Release / Build App (Windows) (push) Failing after 10s
Release / Build App (macOS) (push) Successful in 59s
Release / Build App (Linux) (push) Has been cancelled
The bundled .ico had non-RGBA PNGs which caused Tauri's macOS bundler
to fail with "The PNG is not in RGBA format!". Regenerated all icons
from the source PNG as proper RGBA, and added icon.icns for macOS.

Also fixed bundle identifier from "com.localtranscription.app" (the
.app suffix conflicts with macOS bundle extension) to
"net.anhonesthost.local-transcription".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:59:50 -07:00
Gitea Actions a8e2e7dca8 chore: bump version to 1.4.1 [skip ci] 2026-04-06 20:53:15 +00:00
DeveloperandClaude Opus 4.6 3bcf4f09a3 Fix sidecar builds: macOS CUDA resolution and Windows uv install
Release / Bump version and tag (push) Successful in 3s
Release / Build App (Windows) (push) Failing after 10s
Release / Build App (macOS) (push) Failing after 51s
Release / Build App (Linux) (push) Successful in 4m31s
macOS: pyproject.toml's [tool.uv.sources] forces torch from the CUDA
index which has no macOS ARM wheels. Use `uv sync --no-sources` to
bypass this and get torch from PyPI (which includes MPS support).

Windows: Add additional uv PATH locations ($LOCALAPPDATA\uv\bin) for
robustness with different runner environments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:51:41 -07:00
Gitea Actions ef5734ef15 chore: bump sidecar version to 1.0.1 [skip ci] 2026-04-06 20:45:14 +00:00
jknapp c9db43d56c Merge pull request 'Rewrite frontend to Tauri v2 + Svelte 5 for cross-platform support' (#4) from feature/tauri-rewrite into main
Build Sidecars / Bump sidecar version and tag (push) Successful in 4s
Release / Bump version and tag (push) Successful in 2s
Build Sidecars / Build Sidecar (Windows) (push) Failing after 15s
Build Sidecars / Build Sidecar (macOS) (push) Failing after 18s
Release / Build App (Windows) (push) Failing after 15s
Release / Build App (macOS) (push) Failing after 52s
Release / Build App (Linux) (push) Has been cancelled
Build Sidecars / Build Sidecar (Linux) (push) Has been cancelled
Reviewed-on: #4
2026-04-06 20:45:10 +00:00
DeveloperandClaude Opus 4.6 4c519a109a Add missing Svelte components and stores, fix .gitignore lib/ pattern
The src/lib/ directory was being excluded by a Python .gitignore rule
for lib/ (meant for Python's build output). Changed to /lib/ so it
only matches root-level lib/ and doesn't block src/lib/.

Adds 8 files that were created but missed in the initial commit:
- 5 Svelte components (Header, StatusBar, Controls, TranscriptionDisplay, Settings)
- 3 TypeScript stores (backend, config, transcriptions)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:42:31 -07:00
DeveloperandClaude Opus 4.6 47ca74e75d Update README and CLAUDE.md for Tauri rewrite
Update both docs to reflect the new architecture:
- Tauri v2 + Svelte 5 frontend replacing PySide6/Qt
- Headless Python backend with FastAPI control API
- Cross-platform support (Windows, macOS, Linux)
- Deepgram remote transcription (managed/BYOK)
- Gitea CI/CD workflows for automated builds
- New project structure with backend/, src/, src-tauri/
- Updated development commands and build instructions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:34:10 -07:00
28 changed files with 3856 additions and 343 deletions
+26 -9
View File
@@ -141,7 +141,7 @@ jobs:
- name: Build sidecar (CUDA)
run: |
uv sync
uv sync --frozen || uv sync
uv run pyinstaller local-transcription-headless.spec
- name: Package sidecar (CUDA)
@@ -151,9 +151,10 @@ jobs:
- name: Build sidecar (CPU)
run: |
rm -rf dist/local-transcription-backend build/
# Install CPU-only PyTorch
uv pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu --force-reinstall
uv run pyinstaller local-transcription-headless.spec
# Run pyinstaller directly from venv to prevent uv run from
# re-resolving torch back to the CUDA version via pyproject.toml sources
.venv/bin/pyinstaller local-transcription-headless.spec
- name: Package sidecar (CPU)
run: |
@@ -228,7 +229,17 @@ jobs:
Write-Host "uv already installed: $(uv --version)"
} else {
irm https://astral.sh/uv/install.ps1 | iex
echo "$env:USERPROFILE\.local\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
# Add both possible uv install locations to PATH
$uvPaths = @(
"$env:USERPROFILE\.local\bin",
"$env:USERPROFILE\.cargo\bin",
"$env:LOCALAPPDATA\uv\bin"
)
foreach ($p in $uvPaths) {
if (Test-Path $p) {
echo $p | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
}
}
}
- name: Set up Python
@@ -245,7 +256,8 @@ jobs:
- name: Build sidecar (CUDA)
shell: powershell
run: |
uv sync
uv sync --frozen
if ($LASTEXITCODE -ne 0) { uv sync }
uv run pyinstaller local-transcription-headless.spec
- name: Package sidecar (CUDA)
@@ -258,7 +270,9 @@ jobs:
run: |
Remove-Item -Recurse -Force dist\local-transcription-backend, build -ErrorAction SilentlyContinue
uv pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu --force-reinstall
uv run pyinstaller local-transcription-headless.spec
# Run pyinstaller directly from venv to prevent uv run from
# re-resolving torch back to the CUDA version via pyproject.toml sources
.venv\Scripts\pyinstaller.exe local-transcription-headless.spec
- name: Package sidecar (CPU)
shell: powershell
@@ -355,11 +369,14 @@ jobs:
run: brew install portaudio
- name: Build sidecar (CPU)
env:
UV_NO_SOURCES: "1"
run: |
# Install CPU-only PyTorch for macOS (MPS support included in default torch)
# UV_NO_SOURCES bypasses pyproject.toml's [tool.uv.sources] which forces
# torch from the CUDA index (no macOS ARM wheels there).
# Default PyPI torch includes MPS (Apple Silicon GPU) support.
uv sync
uv pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu --force-reinstall
uv run pyinstaller local-transcription-headless.spec
.venv/bin/pyinstaller local-transcription-headless.spec
- name: Package sidecar (CPU)
run: |
+5 -2
View File
@@ -10,8 +10,8 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
/lib/
/lib64/
parts/
sdist/
var/
@@ -63,3 +63,6 @@ dist/
# Tauri
src-tauri/target/
# Windows NTFS alternate data streams
*:Zone.Identifier
Binary file not shown.
+180 -227
View File
@@ -4,52 +4,108 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
Local Transcription is a desktop application for real-time speech-to-text transcription designed for streamers. It uses Whisper models (via faster-whisper) to transcribe audio locally with optional multi-user server synchronization.
Local Transcription is a cross-platform desktop application for real-time speech-to-text transcription designed for streamers. It supports local Whisper models and cloud-based Deepgram transcription, with OBS browser source integration and optional multi-user sync.
**Architecture:** Two-process model — a Tauri v2 shell (Svelte 5 frontend) communicates with a headless Python backend (sidecar) via REST API and WebSocket.
**Key Features:**
- Standalone desktop GUI (PySide6/Qt)
- Local transcription with CPU/GPU support
- Built-in web server for OBS browser source integration
- Optional Node.js-based multi-user server for syncing transcriptions across users
- Noise suppression and Voice Activity Detection (VAD)
- Cross-platform builds (Linux/Windows) with PyInstaller
- Cross-platform desktop app (Windows, macOS, Linux) via Tauri v2 + Svelte 5
- Headless Python backend with FastAPI control API
- Dual transcription modes: local Whisper or cloud Deepgram (managed/BYOK)
- Built-in web server for OBS browser source at `http://localhost:8080`
- Optional multi-user sync via Node.js server
- CUDA, MPS (Apple Silicon), and CPU support
- Auto-updates, custom fonts, configurable colors
> **Legacy GUI:** The original PySide6/Qt GUI (`main.py`, `gui/`) still works during the transition. New features should target the Tauri frontend and headless backend.
## Project Structure
```
local-transcription/
├── client/ # Core transcription logic
│ ├── audio_capture.py # Audio input and buffering
│ ├── transcription_engine.py # Whisper model integration
├── src/ # Svelte 5 frontend (Tauri UI)
│ ├── App.svelte # Main app shell
│ ├── app.css # Global dark theme styles
│ ├── main.ts # Svelte mount point
│ ├── lib/components/ # UI components
│ │ ├── Header.svelte # Title bar + settings button
│ │ ├── StatusBar.svelte # State indicator, device, user info
│ │ ├── Controls.svelte # Start/Stop, Clear, Save buttons
│ │ ├── TranscriptionDisplay.svelte # Scrolling transcript view
│ │ └── Settings.svelte # Full settings modal (all sections)
│ └── lib/stores/ # Svelte 5 reactive stores ($state/$derived)
│ ├── backend.ts # WebSocket + REST API client
│ ├── config.ts # App configuration fetch/update
│ └── transcriptions.ts # Transcript data management
├── src-tauri/ # Tauri v2 Rust shell
│ ├── src/lib.rs # Plugin registration (shell, dialog, process)
│ ├── src/main.rs # Entry point
│ ├── tauri.conf.json # Window, bundle, plugin config
│ └── Cargo.toml # Rust dependencies
├── backend/ # Headless Python backend (the sidecar)
│ ├── app_controller.py # Core orchestration (engine, sync, config)
│ ├── api_server.py # FastAPI REST endpoints + /ws/control
│ └── main_headless.py # Headless entry point (prints JSON to stdout)
├── client/ # Core transcription modules (used by backend)
│ ├── audio_capture.py # Audio input handling
│ ├── transcription_engine_realtime.py # RealtimeSTT / Whisper engine
│ ├── deepgram_transcription.py # Deepgram WebSocket cloud transcription
│ ├── noise_suppression.py # VAD and noise reduction
│ ├── device_utils.py # CPU/GPU device management
│ ├── config.py # Configuration management
│ └── server_sync.py # Multi-user server sync client
├── gui/ # Desktop application UI
│ ├── main_window_qt.py # Main application window (PySide6)
│ ├── settings_dialog_qt.py # Settings dialog (PySide6)
│ ├── device_utils.py # CPU/GPU/MPS detection
│ ├── config.py # YAML config management (~/.local-transcription/)
│ ├── server_sync.py # Multi-user server sync client
│ ├── instance_lock.py # Single-instance PID lock
│ └── update_checker.py # Gitea release update checker
├── gui/ # Legacy PySide6/Qt GUI (still functional)
│ ├── main_window_qt.py # Main window (orchestration lives here in legacy)
│ ├── settings_dialog_qt.py # Settings dialog
│ └── transcription_display_qt.py # Display widget
├── server/ # Web display servers
│ ├── web_display.py # FastAPI server for OBS browser source (local)
│ └── nodejs/ # Optional multi-user Node.js server
│ ├── server.js # Multi-user sync server with WebSocket
│ ├── package.json # Node.js dependencies
│ └── README.md # Server deployment documentation
├── config/ # Example configuration files
│ └── default_config.yaml # Default settings template
├── main.py # GUI application entry point
├── server/
│ ├── web_display.py # FastAPI OBS display server (WebSocket + HTML)
│ └── nodejs/ # Optional multi-user sync server
├── .gitea/workflows/ # CI/CD
│ ├── release.yml # Tauri app builds (Linux/Windows/macOS)
│ └── build-sidecar.yml # Python sidecar builds (CUDA + CPU)
├── config/default_config.yaml # Default settings template
├── main.py # Legacy PySide6 GUI entry point
├── main_cli.py # CLI version for testing
└── pyproject.toml # Dependencies and build config
├── version.py # Version string (__version__)
├── local-transcription.spec # PyInstaller config (legacy, includes PySide6)
├── local-transcription-headless.spec # PyInstaller config (headless sidecar, no Qt)
├── pyproject.toml # Python deps (uv, CUDA PyTorch index)
├── package.json # Node/Tauri deps
└── vite.config.ts # Vite build config ($lib alias)
```
## Development Commands
### Installation and Setup
### Frontend (Tauri + Svelte)
```bash
# Install dependencies (creates .venv automatically)
# Install npm dependencies
npm install
# Run Tauri in development mode (hot-reload)
npm run tauri dev
# Build frontend only (for testing)
npx vite build
# Type-check Svelte
npx svelte-check
# Check Rust compiles
cd src-tauri && cargo check
```
### Backend (Python)
```bash
# Install Python dependencies
uv sync
# Run the GUI application
# Run the headless backend standalone (for development)
uv run python -m backend.main_headless --port 8080
# Run the legacy PySide6 GUI
uv run python main.py
# Run CLI version (headless, for testing)
@@ -57,257 +113,154 @@ uv run python main_cli.py
# List available audio devices
uv run python main_cli.py --list-devices
# Install with CUDA support (if needed)
uv pip install torch --index-url https://download.pytorch.org/whl/cu121
```
### Building Executables
### Building
```bash
# Linux (includes CUDA support - works on both GPU and CPU systems)
./build.sh
# Build Tauri app (produces platform installer)
npm run tauri build
# Windows (includes CUDA support - works on both GPU and CPU systems)
build.bat
# Build headless Python sidecar (no PySide6)
uv run pyinstaller local-transcription-headless.spec
# Output: dist/local-transcription-backend/
# Manual build with PyInstaller
uv sync # Install dependencies (includes CUDA PyTorch)
uv pip uninstall -q enum34 # Remove incompatible enum34 package
# Build legacy PySide6 app
uv run pyinstaller local-transcription.spec
# Or use: ./build.sh (Linux) / build.bat (Windows)
```
**Important:** All builds include CUDA support via `pyproject.toml` configuration. CUDA builds can be created on systems without NVIDIA GPUs. The PyTorch CUDA runtime is bundled, and the app automatically falls back to CPU if no GPU is available.
### Testing
```bash
# Run component tests
uv run python test_components.py
# Check CUDA availability
uv run python check_cuda.py
# Test web server manually
uv run python -m uvicorn server.web_display:app --reload
```
## Architecture
## Architecture Details
### Audio Processing Pipeline
### Communication: Tauri <-> Python Backend
1. **Audio Capture** ([client/audio_capture.py](client/audio_capture.py))
- Captures audio from microphone/system using sounddevice
- Handles automatic sample rate detection and resampling
- Uses chunking with overlap for better transcription quality
- Default: 3-second chunks with 0.5s overlap
The Svelte frontend connects to the Python backend via two channels:
2. **Noise Suppression** ([client/noise_suppression.py](client/noise_suppression.py))
- Applies noisereduce for background noise reduction
- Voice Activity Detection (VAD) using webrtcvad
- Skips silent segments to improve performance
**REST API** (on port 8081 by default):
- `GET /api/status` — app state, device info, version
- `POST /api/start` / `POST /api/stop` — transcription control
- `GET /api/config` / `PUT /api/config` — read/write settings (dot-notation keys)
- `GET /api/audio-devices` / `GET /api/compute-devices` — device enumeration
- `POST /api/reload-engine` — reload with new model/device
- `GET /api/transcriptions` / `POST /api/clear` — transcript management
- `POST /api/save-file` — write text to a file path
- `GET /api/check-update` / `POST /api/skip-version` — update management
- `POST /api/login` / `POST /api/register` / `GET /api/balance` — managed mode proxy
3. **Transcription** ([client/transcription_engine.py](client/transcription_engine.py))
- Uses faster-whisper for efficient inference
- Supports CPU, CUDA, and Apple MPS (Mac)
- Models: tiny, base, small, medium, large
- Thread-safe model loading with locks
**WebSocket** `/ws/control`:
- Pushes real-time events: `state_changed`, `transcription`, `preview`, `error`, `credits_low`
- Client sends keepalive pings
4. **Display** ([gui/main_window_qt.py](gui/main_window_qt.py))
- PySide6/Qt-based desktop GUI
- Real-time transcription display with scrolling
- Settings panel with live updates (no restart needed)
The OBS display server runs separately on port 8080 (`GET /` for HTML, `WebSocket /ws` for transcriptions).
### Web Server Architecture
### Backend Process Lifecycle
**Local Web Server** ([server/web_display.py](server/web_display.py))
- Always runs when GUI starts (port 8080 by default)
- FastAPI with WebSocket for real-time updates
- Used for OBS browser source integration
- Single-user (displays only local transcriptions)
1. `main_headless.py` starts, acquires instance lock, creates `AppController`
2. `AppController.initialize()` starts the OBS web server (port 8080) and engine init thread
3. `APIServer` wraps the controller with FastAPI routes, runs on port 8081
4. Backend prints `{"event": "ready", "port": 8080}` to stdout for Tauri to discover
5. On shutdown: engine stopped, web server stopped, lock released
**Multi-User Server** (Optional - for syncing across multiple users)
### Headless Backend vs Legacy GUI
**Node.js WebSocket Server** ([server/nodejs/](server/nodejs/)) - **RECOMMENDED**
- Real-time WebSocket support (< 100ms latency)
- Handles 100+ concurrent users
- Easy deployment to VPS/cloud hosting (Railway, Heroku, DigitalOcean, or any VPS)
- Configurable display options via URL parameters:
- `timestamps=true/false` - Show/hide timestamps
- `maxlines=50` - Maximum visible lines (prevents scroll bars in OBS)
- `fontsize=16` - Font size in pixels
- `fontfamily=Arial` - Font family
- `fade=10` - Seconds before text fades (0 = never)
The `AppController` class (`backend/app_controller.py`) extracts all orchestration logic from `gui/main_window_qt.py` into a Qt-free class. The mapping:
See [server/nodejs/README.md](server/nodejs/README.md) for deployment instructions
| Legacy (MainWindow) | Headless (AppController) |
|---------------------|--------------------------|
| `_initialize_components()` | `_initialize_engine()` |
| `_start_transcription()` | `start_transcription()` |
| `_stop_transcription()` | `stop_transcription()` |
| `_on_settings_saved()` | `apply_settings()` |
| `_reload_engine()` | `reload_engine()` |
| `_start_web_server_if_enabled()` | `_start_web_server()` |
| `_start_server_sync()` | `_start_server_sync()` |
| Qt signals | Callbacks (`on_state_changed`, `on_transcription`, etc.) |
### Configuration System
### Threading Model (Headless)
- Config stored at `~/.local-transcription/config.yaml`
- Managed by [client/config.py](client/config.py)
- Settings apply immediately without restart (except model changes)
- YAML format with nested keys (e.g., `transcription.model`)
- Main thread: Uvicorn (FastAPI) event loop
- Engine init thread: Downloads models, initializes VAD
- Web server thread: Separate asyncio loop for OBS display
- Audio capture: Runs in engine callback threads
- All results flow through `AppController` callbacks -> `APIServer` WebSocket broadcast
### Device Management
### Svelte Frontend
- [client/device_utils.py](client/device_utils.py) handles CPU/GPU detection
- Auto-detects CUDA, MPS (Mac), or falls back to CPU
- Compute types: float32 (best quality), float16 (GPU), int8 (fastest)
- Thread-safe device selection
Uses Svelte 5 runes throughout (`$state`, `$derived`, `$effect`, `$props`). No Svelte 4 patterns.
## Key Implementation Details
**Stores** (`src/lib/stores/`):
- `backend.ts` — WebSocket connection + REST helpers (`apiGet`, `apiPost`, `apiPut`), auto-reconnect
- `config.ts` — fetches/updates config from backend API
- `transcriptions.ts` — manages transcript list, listens for `CustomEvent`s from backend store
### PyInstaller Build Configuration
**Key patterns:**
- Backend store dispatches `CustomEvent`s on `window` for cross-store communication
- Settings component collects all changed values into a `Record<string, any>` with dot-notation keys, sends via `PUT /api/config`
- Controls use Tauri dialog plugin for native file save, falls back to blob download
- [local-transcription.spec](local-transcription.spec) controls build
- UPX compression enabled for smaller executables
- Hidden imports required for PySide6, faster-whisper, torch
- Console mode enabled by default (set `console=False` to hide)
## CI/CD
### Threading Model
Two Gitea Actions workflows in `.gitea/workflows/`:
- Main thread: Qt GUI event loop
- Audio thread: Captures and processes audio chunks
- Web server thread: Runs FastAPI server
- Transcription: Runs in callback thread from audio capture
- All transcription results communicated via Qt signals
- **`release.yml`**: Triggers on push to `main`. Auto-bumps version, builds Tauri app on Linux/Windows/macOS, uploads `.deb`, `.rpm`, `.msi`, `.dmg` to Gitea release.
- **`build-sidecar.yml`**: Triggers on changes to `client/`, `server/`, `backend/`, `pyproject.toml`. Builds headless Python sidecar via PyInstaller. CUDA + CPU for Linux/Windows, CPU-only for macOS.
### Server Sync (Optional Multi-User Feature)
- [client/server_sync.py](client/server_sync.py) handles server communication
- Toggle in Settings: "Enable Server Sync"
- Sends transcriptions to Node.js server via HTTP POST
- Real-time updates via WebSocket to display page
- Per-speaker font support (Web-Safe, Google Fonts, Custom uploads)
- Falls back gracefully if server unavailable
Both require a `BUILD_TOKEN` secret (Gitea API token with release write access).
## Common Patterns
### Adding a New Setting
1. Add to [config/default_config.yaml](config/default_config.yaml)
2. Update [client/config.py](client/config.py) if validation needed
3. Add UI control in [gui/settings_dialog_qt.py](gui/settings_dialog_qt.py)
4. Apply setting in relevant component (no restart if possible)
5. Emit signal to update display if needed
1. Add default to [config/default_config.yaml](config/default_config.yaml)
2. Add UI control in [src/lib/components/Settings.svelte](src/lib/components/Settings.svelte)
3. Ensure the setting is included in the save handler's config update
4. Apply in `AppController.apply_settings()` or the relevant component
5. For legacy GUI: also update [gui/settings_dialog_qt.py](gui/settings_dialog_qt.py)
### Adding a New API Endpoint
1. Add route in [backend/api_server.py](backend/api_server.py) `_setup_routes()`
2. Add supporting logic in [backend/app_controller.py](backend/app_controller.py) if needed
3. Call from Svelte via `backendStore.apiGet/apiPost/apiPut`
### Modifying Transcription Display
- Local GUI: [gui/transcription_display_qt.py](gui/transcription_display_qt.py)
- Local web display (OBS): [server/web_display.py](server/web_display.py) (HTML in `_get_html()`)
- Tauri UI: [src/lib/components/TranscriptionDisplay.svelte](src/lib/components/TranscriptionDisplay.svelte)
- OBS display: [server/web_display.py](server/web_display.py) (HTML in `_get_html()`)
- Multi-user display: [server/nodejs/server.js](server/nodejs/server.js) (display page in `/display` route)
### Adding a New Model Size
- Update [client/transcription_engine.py](client/transcription_engine.py)
- Add to model selector in [gui/settings_dialog_qt.py](gui/settings_dialog_qt.py)
- Update CLI argument choices in [main_cli.py](main_cli.py)
## Dependencies
**Core:**
- `faster-whisper`: Optimized Whisper inference
- `torch`: ML framework (CUDA-enabled via special index)
- `PySide6`: Qt6 bindings for GUI
- `sounddevice`: Cross-platform audio I/O
- `noisereduce`, `webrtcvad`: Audio preprocessing
**Web Server:**
- `fastapi`, `uvicorn`: Web server and ASGI
- `websockets`: Real-time communication
**Build:**
- `pyinstaller`: Create standalone executables
- `uv`: Fast package manager
**PyTorch CUDA Index:**
- Configured in [pyproject.toml](pyproject.toml) under `[[tool.uv.index]]`
- Uses PyTorch's custom wheel repository for CUDA builds
- Automatically installed with `uv sync` when using CUDA build scripts
**Frontend:** Tauri v2, Svelte 5, Vite, TypeScript
**Backend:** Python 3.9+, FastAPI, Uvicorn, RealtimeSTT, faster-whisper, PyTorch (CUDA), sounddevice
**Build:** PyInstaller (sidecar), Tauri CLI (app), uv (Python packages)
**CI:** Gitea Actions with platform-specific runners
## Platform-Specific Notes
### Linux
- Uses PulseAudio/ALSA for audio
- Build scripts use bash (`.sh` files)
- Executable: `dist/LocalTranscription/LocalTranscription`
- Tauri needs: `libgtk-3-dev`, `libwebkit2gtk-4.1-dev`, `libappindicator3-dev`, `librsvg2-dev`, `patchelf`
- Audio: PulseAudio/ALSA via sounddevice
### Windows
- Uses Windows Audio/WASAPI
- Build scripts use batch (`.bat` files)
- Executable: `dist\LocalTranscription\LocalTranscription.exe`
- Requires Visual C++ Redistributable on target systems
- Tauri needs: WebView2 (usually pre-installed on Windows 10+)
- Audio: WASAPI via sounddevice
### Cross-Building
- **Cannot cross-compile** - must build on target platform
- CI/CD should use platform-specific runners
## Troubleshooting
### Model Loading Issues
- Models download to `~/.cache/huggingface/`
- First run requires internet connection
- Check disk space (models: 75MB-3GB depending on size)
### Audio Device Issues
- Run `uv run python main_cli.py --list-devices`
- Check permissions (microphone access)
- Try different device indices in settings
### GPU Not Detected
- Run `uv run python check_cuda.py`
- Install CUDA drivers (not CUDA toolkit - bundled in build)
- Verify PyTorch sees GPU: `python -c "import torch; print(torch.cuda.is_available())"`
### Web Server Port Conflicts
- Default port: 8080
- Change in [gui/main_window_qt.py](gui/main_window_qt.py) or config
- Use `lsof -i :8080` (Linux) or `netstat -ano | findstr :8080` (Windows)
## OBS Integration
### Local Display (Single User)
1. Start Local Transcription app
2. In OBS: Add "Browser" source
3. URL: `http://localhost:8080`
4. Set dimensions (e.g., 1920x300)
### Multi-User Display (Node.js Server)
1. Deploy Node.js server (see [server/nodejs/README.md](server/nodejs/README.md))
2. Each user configures Server URL: `http://your-server:3000/api/send`
3. Enter same room name and passphrase
4. In OBS: Add "Browser" source
5. URL: `http://your-server:3000/display?room=ROOM&fade=10&timestamps=true&maxlines=50&fontsize=16`
6. Customize URL parameters as needed:
- `timestamps=false` - Hide timestamps
- `maxlines=30` - Show max 30 lines (prevents scroll bars)
- `fontsize=18` - Larger font
- `fontfamily=Courier` - Different font
## Performance Optimization
**For Real-Time Transcription:**
- Use `tiny` or `base` model (faster)
- Enable GPU if available (5-10x faster)
- Increase chunk_duration for better accuracy (higher latency)
- Decrease chunk_duration for lower latency (less context)
- Enable VAD to skip silent audio
**For Build Size Reduction:**
- Don't bundle models (download on demand)
- Use CPU-only build if no GPU users
- Enable UPX compression (already in spec)
## Phase Status
- ✅ **Phase 1**: Standalone desktop application (complete)
- ✅ **Web Server**: Local OBS integration (complete)
- ✅ **Builds**: PyInstaller executables (complete)
- ✅ **Phase 2**: Multi-user Node.js server (complete, optional)
- ⏸️ **Phase 3+**: Advanced features (see [NEXT_STEPS.md](NEXT_STEPS.md))
### macOS
- Tauri needs: Xcode Command Line Tools
- Audio: CoreAudio via sounddevice
- GPU: MPS (Apple Silicon) detected by `device_utils.py`
- `Info.plist` must include `NSMicrophoneUsageDescription` for mic access
- No CUDA builds — CPU/MPS only
## Related Documentation
- [README.md](README.md) - User-facing documentation
- [BUILD.md](BUILD.md) - Detailed build instructions
- [INSTALL.md](INSTALL.md) - Installation guide
- [NEXT_STEPS.md](NEXT_STEPS.md) - Future enhancements
- [server/nodejs/README.md](server/nodejs/README.md) - Node.js server setup and deployment
- [README.md](README.md) — User-facing documentation
- [BUILD.md](BUILD.md) — Detailed build instructions
- [INSTALL.md](INSTALL.md) — Installation guide
- [server/nodejs/README.md](server/nodejs/README.md) — Node.js server setup
Binary file not shown.
+148 -54
View File
@@ -1,13 +1,14 @@
# Local Transcription
A real-time speech-to-text desktop application for streamers. Run locally on your machine with GPU or CPU, display transcriptions via OBS browser source, and optionally sync with other users through a multi-user server.
A real-time speech-to-text desktop application for streamers. Runs locally on your machine with GPU or CPU, displays transcriptions via OBS browser source, and optionally syncs with other users through a multi-user server.
**Version 1.4.0**
## Features
- **Real-Time Transcription**: Live speech-to-text using Whisper models with minimal latency
- **Standalone Desktop App**: PySide6/Qt GUI that works without any server
- **Cross-Platform**: Native desktop app for Windows, macOS, and Linux via [Tauri](https://tauri.app/)
- **Dual Transcription Modes**: Local (Whisper) or cloud (Deepgram) with managed billing or BYOK
- **CPU & GPU Support**: Automatic detection of CUDA (NVIDIA), MPS (Apple Silicon), or CPU fallback
- **Advanced Voice Detection**: Dual-layer VAD (WebRTC + Silero) for accurate speech detection
- **OBS Integration**: Built-in web server for browser source capture at `http://localhost:8080`
@@ -16,36 +17,70 @@ A real-time speech-to-text desktop application for streamers. Run locally on you
- **Customizable Colors**: User-configurable colors for name, text, and background
- **Noise Suppression**: Built-in audio preprocessing to reduce background noise
- **Auto-Updates**: Automatic update checking with release notes display
- **Cross-Platform**: Builds available for Windows and Linux
## Architecture
The application uses a two-process architecture:
1. **Tauri Shell** (Svelte 5 frontend) — lightweight native window (~50MB) rendering the UI
2. **Python Backend** (sidecar) — headless process running transcription, audio capture, and the OBS web server
The Tauri frontend communicates with the Python backend via REST API and WebSocket, following the same pattern as [voice-to-notes](https://repo.anhonesthost.net/MacroPad/voice-to-notes).
```
Tauri App (user launches this)
└─ Spawns Python backend as sidecar
├─ FastAPI REST API (control endpoints)
├─ WebSocket /ws/control (real-time state + transcriptions)
├─ OBS web display at http://localhost:8080
└─ Transcription engine (Whisper or Deepgram)
```
> **Legacy GUI**: The original PySide6/Qt desktop GUI (`main.py`) still works alongside the new Tauri frontend during the transition period.
## Quick Start
### Running from Source
```bash
# Install dependencies
# Install Python dependencies
uv sync
# Run the application
# Run the Tauri app (frontend + backend)
npm install
npm run tauri dev
# Or run just the headless backend (for development)
uv run python -m backend.main_headless
# Or run the legacy PySide6 GUI
uv run python main.py
```
### Using Pre-Built Executables
Download the latest release from the [releases page](https://repo.anhonesthost.net/streamer-tools/local-transcription/releases) and run the executable for your platform.
Download the latest release from the [releases page](https://repo.anhonesthost.net/streamer-tools/local-transcription/releases):
- **App installer** (Tauri shell): `.msi` (Windows), `.dmg` (macOS), `.deb`/`.rpm`/`.AppImage` (Linux)
- **Sidecar** (Python backend): Download the matching `sidecar-*` zip for your platform (CUDA or CPU)
### Building from Source
**Linux:**
```bash
./build.sh
# Output: dist/LocalTranscription/LocalTranscription
```
# Build the Tauri app
npm install
npm run tauri build
# Output: src-tauri/target/release/bundle/
**Windows:**
```cmd
# Build the Python sidecar (headless, no Qt)
uv sync
uv run pyinstaller local-transcription-headless.spec
# Output: dist/local-transcription-backend/
# Build the legacy PySide6 app (Linux)
./build.sh
# Build the legacy PySide6 app (Windows)
build.bat
# Output: dist\LocalTranscription\LocalTranscription.exe
```
For detailed build instructions, see [BUILD.md](BUILD.md).
@@ -57,14 +92,23 @@ For detailed build instructions, see [BUILD.md](BUILD.md).
1. Launch the application
2. Select your microphone from the audio device dropdown
3. Choose a Whisper model (smaller = faster, larger = more accurate):
- `tiny.en` / `tiny` - Fastest, good for quick captions
- `base.en` / `base` - Balanced speed and accuracy
- `small.en` / `small` - Better accuracy
- `medium.en` / `medium` - High accuracy
- `large-v3` - Best accuracy (requires more resources)
- `tiny.en` / `tiny` — Fastest, good for quick captions
- `base.en` / `base` — Balanced speed and accuracy
- `small.en` / `small` — Better accuracy
- `medium.en` / `medium` — High accuracy
- `large-v3` — Best accuracy (requires more resources)
4. Click **Start** to begin transcription
5. Transcriptions appear in the main window and at `http://localhost:8080`
### Remote Transcription (Deepgram)
Instead of local Whisper models, you can use cloud-based transcription:
- **Managed mode**: Sign up via the transcription proxy for metered billing
- **BYOK mode**: Bring your own Deepgram API key for direct access
Configure in Settings > Remote Transcription.
### OBS Browser Source Setup
1. Start the Local Transcription app
@@ -88,7 +132,7 @@ For syncing transcriptions across multiple users (e.g., multi-host streams or tr
## Configuration
Settings are stored at `~/.local-transcription/config.yaml` and can be modified through the GUI settings panel.
Settings are stored at `~/.local-transcription/config.yaml` and can be modified through the GUI settings panel or the REST API.
### Key Settings
@@ -100,6 +144,7 @@ Settings are stored at `~/.local-transcription/config.yaml` and can be modified
| `transcription.silero_sensitivity` | VAD sensitivity (0-1, lower = more sensitive) | `0.4` |
| `transcription.post_speech_silence_duration` | Silence before finalizing (seconds) | `0.3` |
| `transcription.continuous_mode` | Fast speaker mode for quick talkers | `false` |
| `remote.mode` | Transcription mode (local/managed/byok) | `local` |
| `display.show_timestamps` | Show timestamps with transcriptions | `true` |
| `display.fade_after_seconds` | Fade out time (0 = never) | `10` |
| `display.font_source` | Font type (System Font/Web-Safe/Google Font/Custom File) | `System Font` |
@@ -111,67 +156,114 @@ See [config/default_config.yaml](config/default_config.yaml) for all available o
```
local-transcription/
├── src/ # Svelte 5 frontend (Tauri UI)
│ ├── App.svelte # Main app shell
│ ├── lib/components/ # UI components
│ │ ├── Header.svelte
│ │ ├── StatusBar.svelte
│ │ ├── Controls.svelte
│ │ ├── TranscriptionDisplay.svelte
│ │ └── Settings.svelte
│ └── lib/stores/ # Reactive state management
│ ├── backend.ts # WebSocket + REST API client
│ ├── config.ts # App configuration
│ └── transcriptions.ts # Transcription data
├── src-tauri/ # Tauri v2 Rust shell
│ ├── src/main.rs
│ └── tauri.conf.json
├── backend/ # Headless Python backend (sidecar)
│ ├── app_controller.py # Orchestration logic (engine, sync, config)
│ ├── api_server.py # FastAPI REST + WebSocket control API
│ └── main_headless.py # Headless entry point
├── client/ # Core transcription modules
│ ├── audio_capture.py # Audio input handling
│ ├── transcription_engine_realtime.py # RealtimeSTT integration
│ ├── transcription_engine_realtime.py # RealtimeSTT / Whisper
│ ├── deepgram_transcription.py # Deepgram cloud transcription
│ ├── noise_suppression.py # VAD and noise reduction
│ ├── device_utils.py # CPU/GPU detection
│ ├── device_utils.py # CPU/GPU/MPS detection
│ ├── config.py # Configuration management
│ ├── server_sync.py # Multi-user server client
│ └── update_checker.py # Auto-update functionality
├── gui/ # Desktop application UI
│ ├── main_window_qt.py # Main application window
│ ├── settings_dialog_qt.py # Settings dialog
│ └── transcription_display_qt.py # Display widget
├── gui/ # Legacy PySide6/Qt GUI
│ ├── main_window_qt.py
│ ├── settings_dialog_qt.py
│ └── transcription_display_qt.py
├── server/ # Web servers
│ ├── web_display.py # Local FastAPI server for OBS
│ └── nodejs/ # Multi-user sync server
│ ├── server.js # Express + WebSocket server
│ └── README.md # Deployment instructions
├── .gitea/workflows/ # CI/CD
│ ├── release.yml # Tauri app builds (all platforms)
│ └── build-sidecar.yml # Python sidecar builds (CUDA + CPU)
├── config/
│ └── default_config.yaml # Default settings template
├── main.py # GUI entry point
├── main.py # Legacy GUI entry point
├── main_cli.py # CLI version (for testing)
├── build.sh # Linux build script
├── build.bat # Windows build script
└── local-transcription.spec # PyInstaller configuration
├── local-transcription.spec # PyInstaller config (legacy, with PySide6)
├── local-transcription-headless.spec # PyInstaller config (headless sidecar)
├── pyproject.toml # Python dependencies
└── package.json # Node.js / Tauri dependencies
```
## Technology Stack
### Desktop Application
### Frontend (Tauri)
- **Tauri v2** — Native cross-platform shell (Rust)
- **Svelte 5** — Reactive UI framework (TypeScript)
- **Vite** — Frontend build tool
### Backend (Python Sidecar)
- **Python 3.9+**
- **PySide6** - Qt6 GUI framework
- **RealtimeSTT** - Real-time speech-to-text with advanced VAD
- **faster-whisper** - Optimized Whisper model inference
- **PyTorch** - ML framework (CUDA-enabled)
- **sounddevice** - Cross-platform audio capture
- **webrtcvad + silero_vad** - Voice activity detection
- **noisereduce** - Noise suppression
- **FastAPI + Uvicorn** — REST API and WebSocket server
- **RealtimeSTT** — Real-time speech-to-text with advanced VAD
- **faster-whisper** — Optimized Whisper model inference (CTranslate2)
- **PyTorch** — ML framework (CUDA-enabled builds available)
- **sounddevice** — Cross-platform audio capture
- **webrtcvad + silero_vad** — Voice activity detection
### Web Servers
- **FastAPI + Uvicorn** - Local web display server
- **Node.js + Express + WebSocket** - Multi-user sync server
### Multi-User Server (Optional)
- **Node.js + Express + WebSocket** — Real-time sync server
### Build Tools
- **PyInstaller** - Executable packaging
- **uv** - Fast Python package manager
### Build & CI/CD
- **PyInstaller** — Python sidecar packaging
- **Tauri CLI** — App bundling (.msi, .dmg, .deb, .rpm, .AppImage)
- **Gitea Actions** — Automated cross-platform builds
- **uv** — Fast Python package manager
## CI/CD
Two Gitea Actions workflows in `.gitea/workflows/`:
| Workflow | Trigger | Produces |
|----------|---------|----------|
| `release.yml` | Push to `main` | Tauri app installers for all platforms |
| `build-sidecar.yml` | Changes to `client/`, `server/`, `backend/`, or `pyproject.toml` | Python sidecar zips (CUDA + CPU) |
Both workflows require a `BUILD_TOKEN` secret in the repo settings (Gitea API token with release write access).
### Release Artifacts
| Platform | App Installer | Sidecar (CUDA) | Sidecar (CPU) |
|----------|--------------|----------------|---------------|
| Linux x86_64 | `.deb`, `.rpm`, `.AppImage` | `sidecar-linux-x86_64-cuda.zip` | `sidecar-linux-x86_64-cpu.zip` |
| Windows x86_64 | `.msi`, `-setup.exe` | `sidecar-windows-x86_64-cuda.zip` | `sidecar-windows-x86_64-cpu.zip` |
| macOS ARM64 | `.dmg` | — | `sidecar-macos-aarch64-cpu.zip` |
## System Requirements
### Minimum
- Python 3.9+
- 4GB RAM
- Any modern CPU
### Recommended (for real-time performance)
### Recommended (for local real-time transcription)
- 8GB+ RAM
- NVIDIA GPU with CUDA support (for GPU acceleration)
- FFmpeg (installed automatically with dependencies)
### For Building
- **Linux**: gcc, Python dev headers
- **Windows**: Visual Studio Build Tools, Python dev headers
- **Tauri app**: Node.js 20+, Rust stable, platform SDK (see [Tauri prerequisites](https://tauri.app/start/prerequisites/))
- **Python sidecar**: Python 3.9+, uv, PyInstaller
- **Linux**: `libgtk-3-dev`, `libwebkit2gtk-4.1-dev`, `libappindicator3-dev`, `librsvg2-dev`, `patchelf`
- **Windows**: Visual Studio Build Tools, WebView2
- **macOS**: Xcode Command Line Tools
## Troubleshooting
@@ -185,7 +277,7 @@ local-transcription/
# List available audio devices
uv run python main_cli.py --list-devices
```
- Ensure microphone permissions are granted
- Ensure microphone permissions are granted (especially on macOS)
- Try different device indices in settings
### GPU Not Detected
@@ -193,13 +285,13 @@ uv run python main_cli.py --list-devices
# Check CUDA availability
uv run python -c "import torch; print(torch.cuda.is_available())"
```
- Install NVIDIA drivers (CUDA toolkit is bundled)
- Install NVIDIA drivers (CUDA toolkit is bundled in CUDA sidecar builds)
- The app automatically falls back to CPU if no GPU is available
### Web Server Port Conflicts
- Default port is 8080
- Default port is 8080; the app tries ports 8080-8084 automatically
- Change in settings or edit config file
- Check for conflicts: `lsof -i :8080` (Linux) or `netstat -ano | findstr :8080` (Windows)
- Check for conflicts: `lsof -i :8080` (Linux/macOS) or `netstat -ano | findstr :8080` (Windows)
## Use Cases
@@ -222,3 +314,5 @@ MIT License
- [OpenAI Whisper](https://github.com/openai/whisper) for the speech recognition model
- [RealtimeSTT](https://github.com/KoljaB/RealtimeSTT) for real-time transcription capabilities
- [faster-whisper](https://github.com/guillaumekln/faster-whisper) for optimized inference
- [Tauri](https://tauri.app/) for the cross-platform desktop framework
- [Deepgram](https://deepgram.com/) for cloud transcription API
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "local-transcription",
"private": true,
"version": "1.4.0",
"version": "1.4.5",
"type": "module",
"scripts": {
"dev": "vite dev",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "local-transcription"
version = "1.0.0"
version = "1.0.3"
description = "A standalone desktop application for real-time speech-to-text transcription using Whisper models"
readme = "README.md"
requires-python = ">=3.9"
+486 -8
View File
@@ -47,6 +47,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "atk"
version = "0.18.2"
@@ -338,6 +347,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -361,9 +380,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.11.0",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -374,7 +393,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.11.0",
"core-foundation",
"core-foundation 0.10.1",
"libc",
]
@@ -515,6 +534,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -792,6 +822,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -799,7 +838,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -813,6 +852,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -1222,6 +1267,25 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.13.1",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1332,6 +1396,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1342,6 +1407,38 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -1360,9 +1457,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -1766,6 +1865,12 @@ dependencies = [
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
@@ -1774,8 +1879,11 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "local-transcription"
version = "1.4.0"
version = "1.4.3"
dependencies = [
"bytes",
"futures-util",
"reqwest 0.12.28",
"serde",
"serde_json",
"tauri",
@@ -1783,6 +1891,8 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-process",
"tauri-plugin-shell",
"tokio",
"zip",
]
[[package]]
@@ -1911,6 +2021,23 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2132,6 +2259,50 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.112"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2727,6 +2898,49 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
]
[[package]]
name = "reqwest"
version = "0.13.2"
@@ -2757,7 +2971,7 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"wasm-streams 0.5.0",
"web-sys",
]
@@ -2785,6 +2999,20 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -2800,12 +3028,64 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.11.0",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"once_cell",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@@ -2815,6 +3095,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -2872,6 +3161,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.24.0"
@@ -3014,6 +3326,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.18.0"
@@ -3294,6 +3618,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -3347,6 +3677,27 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -3368,7 +3719,7 @@ checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20"
dependencies = [
"bitflags 2.11.0",
"block2",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dispatch2",
@@ -3445,7 +3796,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"serde_repr",
@@ -3719,6 +4070,19 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.4.3"
@@ -3830,11 +4194,45 @@ dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -4116,6 +4514,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -4165,6 +4569,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -4323,6 +4733,19 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
@@ -4599,6 +5022,17 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -4644,6 +5078,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -5132,6 +5575,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.4"
@@ -5165,8 +5614,37 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap 2.13.1",
"memchr",
"thiserror 2.0.18",
"zopfli",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
+6 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "local-transcription"
version = "1.4.0"
version = "1.4.5"
description = "Real-time speech-to-text transcription for streamers"
authors = ["Local Transcription Contributors"]
edition = "2021"
@@ -19,3 +19,8 @@ tauri-plugin-dialog = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json", "stream"] }
futures-util = "0.3"
zip = { version = "2", default-features = false, features = ["deflate"] }
bytes = "1"
tokio = { version = "1", features = ["full"] }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 739 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 41 KiB

+32
View File
@@ -1,9 +1,41 @@
mod sidecar;
use std::sync::Mutex;
use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init())
.manage(sidecar::ManagedSidecar(Mutex::new(
sidecar::SidecarManager::new(),
)))
.setup(|app| {
let resource_dir = app
.path()
.resource_dir()
.expect("failed to resolve resource dir");
let data_dir = app
.path()
.app_data_dir()
.expect("failed to resolve app data dir");
// Ensure the data directory exists
std::fs::create_dir_all(&data_dir).expect("failed to create app data dir");
sidecar::init_dirs(resource_dir, data_dir);
Ok(())
})
.invoke_handler(tauri::generate_handler![
sidecar::check_sidecar,
sidecar::download_sidecar,
sidecar::check_sidecar_update,
sidecar::get_sidecar_port,
sidecar::start_sidecar,
sidecar::stop_sidecar,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+580
View File
@@ -0,0 +1,580 @@
use std::io::BufRead;
use std::path::PathBuf;
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter};
const REPO_API: &str =
"https://repo.anhonesthost.net/api/v1/repos/streamer-tools/local-transcription";
const BINARY_NAME: &str = if cfg!(windows) {
"local-transcription-backend.exe"
} else {
"local-transcription-backend"
};
// ---------------------------------------------------------------------------
// Directory state (initialised once during Tauri setup)
// ---------------------------------------------------------------------------
static DIRS: std::sync::OnceLock<SidecarDirs> = std::sync::OnceLock::new();
struct SidecarDirs {
#[allow(dead_code)]
resource_dir: PathBuf,
data_dir: PathBuf,
}
/// Called from Tauri `setup` to persist the resource / data directories.
pub fn init_dirs(resource_dir: PathBuf, data_dir: PathBuf) {
let _ = DIRS.set(SidecarDirs {
resource_dir,
data_dir,
});
}
fn data_dir() -> &'static PathBuf {
&DIRS.get().expect("sidecar::init_dirs not called").data_dir
}
// ---------------------------------------------------------------------------
// Version helpers
// ---------------------------------------------------------------------------
fn version_file() -> PathBuf {
data_dir().join("sidecar-version.txt")
}
fn read_installed_version() -> Option<String> {
std::fs::read_to_string(version_file())
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn sidecar_dir_for_version(version: &str) -> PathBuf {
data_dir().join(format!("sidecar-{version}"))
}
fn binary_path_for_version(version: &str) -> PathBuf {
sidecar_dir_for_version(version).join(BINARY_NAME)
}
// ---------------------------------------------------------------------------
// Gitea API types
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct GiteaRelease {
tag_name: String,
assets: Vec<GiteaAsset>,
}
#[derive(Debug, Deserialize)]
struct GiteaAsset {
name: String,
browser_download_url: String,
size: u64,
}
// ---------------------------------------------------------------------------
// Platform / arch detection
// ---------------------------------------------------------------------------
fn platform_token() -> &'static str {
if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"linux"
}
}
fn arch_token() -> &'static str {
if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
"x86_64"
}
}
/// Build the expected asset prefix, e.g. `sidecar-linux-x86_64-cuda`.
fn asset_prefix(variant: &str) -> String {
format!("sidecar-{}-{}-{}", platform_token(), arch_token(), variant)
}
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
/// Returns `true` when a sidecar binary is installed and the file exists.
#[tauri::command]
pub fn check_sidecar() -> bool {
if let Some(version) = read_installed_version() {
binary_path_for_version(&version).exists()
} else {
false
}
}
/// Download progress payload emitted via `sidecar-download-progress`.
#[derive(Clone, Serialize)]
struct DownloadProgress {
downloaded: u64,
total: u64,
phase: String, // "downloading" | "extracting" | "done" | "error"
message: String,
}
/// Download & install the latest sidecar release.
///
/// `variant` is typically `"cuda"` or `"cpu"`.
#[tauri::command]
pub async fn download_sidecar(app: AppHandle, variant: String) -> Result<String, String> {
use futures_util::StreamExt;
let emit = |progress: DownloadProgress| {
let _ = app.emit("sidecar-download-progress", progress);
};
// 1. Fetch releases from Gitea (filter to sidecar-v* tags) ---------------
emit(DownloadProgress {
downloaded: 0,
total: 0,
phase: "downloading".into(),
message: "Fetching release info...".into(),
});
let releases_url = format!("{REPO_API}/releases?limit=20");
let client = reqwest::Client::new();
let releases: Vec<GiteaRelease> = client
.get(&releases_url)
.send()
.await
.map_err(|e| format!("Failed to fetch releases: {e}"))?
.json()
.await
.map_err(|e| format!("Failed to parse releases: {e}"))?;
// Find the latest release whose tag starts with `sidecar-v`
let release = releases
.into_iter()
.find(|r| r.tag_name.starts_with("sidecar-v"))
.ok_or_else(|| "No sidecar release found".to_string())?;
let version = release.tag_name.clone(); // e.g. "sidecar-v1.0.2"
// 2. Find matching asset ----------------------------------------------------
let prefix = asset_prefix(&variant);
let asset = release
.assets
.iter()
.find(|a| a.name.starts_with(&prefix) && a.name.ends_with(".zip"))
.ok_or_else(|| {
format!(
"No asset matching '{}' in release {}. Available: {}",
prefix,
version,
release
.assets
.iter()
.map(|a| a.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)
})?;
let total_size = asset.size;
let download_url = asset.browser_download_url.clone();
// 3. Stream download ---------------------------------------------------------
emit(DownloadProgress {
downloaded: 0,
total: total_size,
phase: "downloading".into(),
message: format!("Downloading {}...", asset.name),
});
let response = client
.get(&download_url)
.send()
.await
.map_err(|e| format!("Download request failed: {e}"))?;
if !response.status().is_success() {
return Err(format!("Download failed with status {}", response.status()));
}
let tmp_zip = data_dir().join("_sidecar_download.zip");
let mut file = tokio::fs::File::create(&tmp_zip)
.await
.map_err(|e| format!("Cannot create temp file: {e}"))?;
let mut stream = response.bytes_stream();
let mut downloaded: u64 = 0;
use tokio::io::AsyncWriteExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("Download stream error: {e}"))?;
file.write_all(&chunk)
.await
.map_err(|e| format!("Write error: {e}"))?;
downloaded += chunk.len() as u64;
emit(DownloadProgress {
downloaded,
total: total_size,
phase: "downloading".into(),
message: format!(
"Downloading... {:.1} / {:.1} MB",
downloaded as f64 / 1_048_576.0,
total_size as f64 / 1_048_576.0
),
});
}
file.flush()
.await
.map_err(|e| format!("Flush error: {e}"))?;
drop(file);
// 4. Extract zip -------------------------------------------------------------
emit(DownloadProgress {
downloaded,
total: total_size,
phase: "extracting".into(),
message: "Extracting sidecar...".into(),
});
let dest_dir = sidecar_dir_for_version(&version);
if dest_dir.exists() {
std::fs::remove_dir_all(&dest_dir)
.map_err(|e| format!("Cannot clean old dir: {e}"))?;
}
std::fs::create_dir_all(&dest_dir)
.map_err(|e| format!("Cannot create sidecar dir: {e}"))?;
// Extraction is blocking I/O -- offload to a spawn_blocking thread.
let zip_path = tmp_zip.clone();
let dest = dest_dir.clone();
tokio::task::spawn_blocking(move || extract_zip(&zip_path, &dest))
.await
.map_err(|e| format!("Join error: {e}"))?
.map_err(|e| format!("Extraction error: {e}"))?;
// Remove the temp zip
let _ = std::fs::remove_file(&tmp_zip);
// 5. Set executable permissions on Unix -------------------------------------
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let bin = dest_dir.join(BINARY_NAME);
if bin.exists() {
let mut perms = std::fs::metadata(&bin)
.map_err(|e| format!("metadata error: {e}"))?
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&bin, perms)
.map_err(|e| format!("chmod error: {e}"))?;
}
}
// 6. Write version file & clean up old versions ----------------------------
std::fs::write(version_file(), &version)
.map_err(|e| format!("Failed to write version file: {e}"))?;
cleanup_old_versions(&version);
emit(DownloadProgress {
downloaded,
total: total_size,
phase: "done".into(),
message: "Sidecar installed successfully".into(),
});
Ok(version)
}
/// Check if there is a newer sidecar release than the installed one.
/// Returns `Some(tag_name)` when an update is available, or `None`.
#[tauri::command]
pub async fn check_sidecar_update() -> Result<Option<String>, String> {
let installed = match read_installed_version() {
Some(v) => v,
None => return Ok(None),
};
let releases_url = format!("{REPO_API}/releases?limit=20");
let releases: Vec<GiteaRelease> = reqwest::Client::new()
.get(&releases_url)
.send()
.await
.map_err(|e| format!("Failed to fetch releases: {e}"))?
.json()
.await
.map_err(|e| format!("Failed to parse releases: {e}"))?;
let latest = releases
.iter()
.find(|r| r.tag_name.starts_with("sidecar-v"));
match latest {
Some(rel) if rel.tag_name != installed => Ok(Some(rel.tag_name.clone())),
_ => Ok(None),
}
}
// ---------------------------------------------------------------------------
// Zip extraction helper
// ---------------------------------------------------------------------------
fn extract_zip(zip_path: &std::path::Path, dest: &std::path::Path) -> Result<(), String> {
let file =
std::fs::File::open(zip_path).map_err(|e| format!("Cannot open zip: {e}"))?;
let mut archive =
zip::ZipArchive::new(file).map_err(|e| format!("Invalid zip: {e}"))?;
for i in 0..archive.len() {
let mut entry = archive
.by_index(i)
.map_err(|e| format!("Zip entry error: {e}"))?;
let entry_path = match entry.enclosed_name() {
Some(p) => p.to_owned(),
None => continue,
};
let out_path = dest.join(&entry_path);
if entry.is_dir() {
std::fs::create_dir_all(&out_path)
.map_err(|e| format!("mkdir error: {e}"))?;
} else {
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("mkdir error: {e}"))?;
}
let mut outfile = std::fs::File::create(&out_path)
.map_err(|e| format!("create file error: {e}"))?;
std::io::copy(&mut entry, &mut outfile)
.map_err(|e| format!("copy error: {e}"))?;
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Cleanup old versions
// ---------------------------------------------------------------------------
fn cleanup_old_versions(current_version: &str) {
let data = data_dir();
let current_dir_name = format!("sidecar-{current_version}");
if let Ok(entries) = std::fs::read_dir(data) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("sidecar-v") // e.g. sidecar-v1.0.1
&& name != current_dir_name
&& entry.path().is_dir()
{
let _ = std::fs::remove_dir_all(entry.path());
}
}
}
}
// ---------------------------------------------------------------------------
// SidecarManager — launch / stop / query the backend process
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize, Deserialize)]
struct ReadyEvent {
event: String,
port: u16,
}
pub struct SidecarManager {
child: Option<std::process::Child>,
port: Option<u16>,
}
impl SidecarManager {
pub fn new() -> Self {
Self {
child: None,
port: None,
}
}
/// Returns `true` when the child process is still alive.
pub fn is_running(&mut self) -> bool {
match &mut self.child {
Some(child) => match child.try_wait() {
Ok(Some(_)) => {
// Process has exited
self.child = None;
self.port = None;
false
}
Ok(None) => true,
Err(_) => false,
},
None => false,
}
}
/// Start the sidecar if it is not already running. Returns the port.
pub fn ensure_running(&mut self) -> Result<u16, String> {
if self.is_running() {
return self
.port
.ok_or_else(|| "Sidecar running but port unknown".into());
}
let is_dev = cfg!(debug_assertions)
|| std::env::var("LOCAL_TRANSCRIPTION_DEV")
.map(|v| v == "1")
.unwrap_or(false);
let mut cmd = if is_dev {
self.build_dev_command()?
} else {
self.build_prod_command()?
};
// Hide the console window on Windows in release mode.
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().map_err(|e| format!("Failed to spawn sidecar: {e}"))?;
// Wait for the `{"event":"ready","port":...}` line on stdout.
let stdout = child
.stdout
.take()
.ok_or("Failed to capture sidecar stdout")?;
let port = Self::wait_for_ready(stdout)?;
self.child = Some(child);
self.port = Some(port);
Ok(port)
}
/// Stop the sidecar process if running.
pub fn stop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
self.port = None;
}
/// Return the port the sidecar is listening on, if known.
pub fn port(&self) -> Option<u16> {
self.port
}
// -- private helpers -------------------------------------------------------
fn build_dev_command(&self) -> Result<std::process::Command, String> {
let mut cmd = std::process::Command::new("python");
cmd.args(["-m", "backend.main_headless"]);
// Try to find the project root (parent of src-tauri)
if let Some(dirs) = DIRS.get() {
let project_root = dirs
.resource_dir
.parent() // src-tauri
.and_then(|p| p.parent()); // project root
if let Some(root) = project_root {
cmd.current_dir(root);
}
}
Ok(cmd)
}
fn build_prod_command(&self) -> Result<std::process::Command, String> {
let version = read_installed_version()
.ok_or("No sidecar version installed")?;
let bin = binary_path_for_version(&version);
if !bin.exists() {
return Err(format!("Sidecar binary not found at {}", bin.display()));
}
let mut cmd = std::process::Command::new(&bin);
cmd.current_dir(
bin.parent()
.ok_or("Cannot determine sidecar parent dir")?,
);
Ok(cmd)
}
fn wait_for_ready(stdout: std::process::ChildStdout) -> Result<u16, String> {
let reader = std::io::BufReader::new(stdout);
let timeout = std::time::Duration::from_secs(120);
let start = std::time::Instant::now();
for line in reader.lines() {
if start.elapsed() > timeout {
return Err("Timed out waiting for sidecar ready event".into());
}
let line = line.map_err(|e| format!("IO error reading stdout: {e}"))?;
if let Ok(evt) = serde_json::from_str::<ReadyEvent>(&line) {
if evt.event == "ready" {
return Ok(evt.port);
}
}
// Ignore other lines (e.g. log output)
}
Err("Sidecar process exited before sending ready event".into())
}
}
// ---------------------------------------------------------------------------
// Tauri-managed SidecarManager state & commands
// ---------------------------------------------------------------------------
/// Wrapper so we can store `SidecarManager` in Tauri's managed state.
pub struct ManagedSidecar(pub Mutex<SidecarManager>);
#[tauri::command]
pub fn get_sidecar_port(state: tauri::State<'_, ManagedSidecar>) -> Result<Option<u16>, String> {
let mut mgr = state
.0
.lock()
.map_err(|e| format!("Lock error: {e}"))?;
// Refresh running status before returning port
if !mgr.is_running() {
return Ok(None);
}
Ok(mgr.port())
}
#[tauri::command]
pub fn start_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<u16, String> {
let mut mgr = state
.0
.lock()
.map_err(|e| format!("Lock error: {e}"))?;
mgr.ensure_running()
}
#[tauri::command]
pub fn stop_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<(), String> {
let mut mgr = state
.0
.lock()
.map_err(|e| format!("Lock error: {e}"))?;
mgr.stop();
Ok(())
}
+3 -2
View File
@@ -1,7 +1,7 @@
{
"productName": "Local Transcription",
"version": "1.4.0",
"identifier": "com.localtranscription.app",
"version": "1.4.5",
"identifier": "net.anhonesthost.local-transcription",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
@@ -30,6 +30,7 @@
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico",
"icons/icon.png"
]
+155 -1
View File
@@ -5,13 +5,19 @@
import Controls from "$lib/components/Controls.svelte";
import TranscriptionDisplay from "$lib/components/TranscriptionDisplay.svelte";
import Settings from "$lib/components/Settings.svelte";
import SidecarSetup from "$lib/components/SidecarSetup.svelte";
import { backendStore } from "$lib/stores/backend";
import { configStore } from "$lib/stores/config";
type SidecarState = "checking" | "needs_setup" | "starting" | "connected";
let showSettings = $state(false);
let sidecarState = $state<SidecarState>("checking");
let obsDisplayUrl = $derived(backendStore.obsUrl);
let syncDisplayUrl = $derived(backendStore.syncUrl);
let isConnected = $derived(backendStore.connectionState === "connected");
let connectionState = $derived(backendStore.connectionState);
function openSettings() {
showSettings = true;
@@ -21,9 +27,55 @@
showSettings = false;
}
onMount(() => {
async function checkAndLaunchSidecar() {
try {
const { invoke } = await import("@tauri-apps/api/core");
// Check if sidecar is installed
sidecarState = "checking";
const installed = await invoke<boolean>("check_sidecar");
if (!installed) {
sidecarState = "needs_setup";
return;
}
await launchSidecar();
} catch {
// Not running in Tauri (browser dev mode) - skip sidecar check
// and connect directly to localhost:8081
sidecarState = "starting";
backendStore.setPort(8081);
backendStore.connect();
configStore.loadConfig();
}
}
async function launchSidecar() {
try {
const { invoke } = await import("@tauri-apps/api/core");
sidecarState = "starting";
await invoke("start_sidecar");
const port = await invoke<number>("get_sidecar_port");
backendStore.setPort(port);
backendStore.connect();
configStore.loadConfig();
} catch {
// If sidecar launch fails, still try connecting to default port
sidecarState = "starting";
backendStore.connect();
configStore.loadConfig();
}
}
async function onSidecarReady() {
await launchSidecar();
}
onMount(() => {
checkAndLaunchSidecar();
return () => {
backendStore.disconnect();
@@ -31,6 +83,46 @@
});
</script>
{#if sidecarState === "checking"}
<div class="connecting-overlay">
<div class="connecting-content">
<div class="connecting-icon">
<div class="spinner"></div>
</div>
<h2>Local Transcription</h2>
<p>Checking setup...</p>
</div>
</div>
{:else if sidecarState === "needs_setup"}
<SidecarSetup onComplete={onSidecarReady} />
{:else if !isConnected}
<div class="connecting-overlay">
<div class="connecting-content">
<div class="connecting-icon">
{#if connectionState === "error"}
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#e74c3c" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
{:else}
<div class="spinner"></div>
{/if}
</div>
<h2>Local Transcription</h2>
{#if connectionState === "error"}
<p>Cannot connect to backend</p>
<p class="hint">Make sure the Python backend is running:<br>
<code>uv run python -m backend.main_headless</code></p>
{:else}
<p>Connecting to backend...</p>
{/if}
</div>
</div>
{:else}
<div class="app-shell">
<Header onSettingsClick={openSettings} />
<StatusBar />
@@ -56,8 +148,70 @@
{#if showSettings}
<Settings onClose={closeSettings} />
{/if}
{/if}
<style>
.connecting-overlay {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
background-color: var(--bg-primary);
}
.connecting-content {
text-align: center;
color: var(--text-primary);
}
.connecting-content h2 {
margin: 16px 0 8px;
font-size: 20px;
font-weight: 600;
}
.connecting-content p {
margin: 4px 0;
color: var(--text-secondary);
font-size: 14px;
}
.connecting-content .hint {
margin-top: 16px;
font-size: 12px;
color: var(--text-muted);
}
.connecting-content code {
display: inline-block;
margin-top: 4px;
padding: 4px 8px;
background: var(--bg-tertiary);
border-radius: 4px;
font-size: 12px;
color: var(--text-primary);
}
.connecting-icon {
display: flex;
justify-content: center;
margin-bottom: 8px;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-color);
border-top-color: var(--accent-color, #4CAF50);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.app-shell {
display: flex;
flex-direction: column;
+116
View File
@@ -0,0 +1,116 @@
<script lang="ts">
import { backendStore } from "$lib/stores/backend";
import { transcriptionStore } from "$lib/stores/transcriptions";
let isTranscribing = $derived(backendStore.appState === "transcribing");
let isReady = $derived(
backendStore.appState === "ready" || backendStore.appState === "transcribing"
);
let isLoading = $state(false);
async function toggleTranscription() {
if (isLoading) return;
isLoading = true;
try {
if (isTranscribing) {
await backendStore.apiPost("/api/stop");
} else {
await backendStore.apiPost("/api/start");
}
} catch (err) {
console.error("Failed to toggle transcription:", err);
} finally {
isLoading = false;
}
}
async function clearTranscriptions() {
try {
await backendStore.apiPost("/api/clear");
transcriptionStore.clearAll();
} catch (err) {
console.error("Failed to clear:", err);
}
}
async function saveTranscriptions() {
try {
// Get transcription text from backend or local store
let text: string;
try {
const data = await backendStore.apiGet<{ text: string }>("/api/transcriptions");
text = data.text || transcriptionStore.getPlainText();
} catch {
text = transcriptionStore.getPlainText();
}
if (!text.trim()) {
console.warn("No transcriptions to save");
return;
}
// Try Tauri dialog for native save, fall back to browser download
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const filePath = await save({
defaultPath: "transcription.txt",
filters: [
{ name: "Text Files", extensions: ["txt"] },
{ name: "All Files", extensions: ["*"] },
],
});
if (filePath) {
// Write via backend API
await backendStore.apiPost("/api/save-file", { path: filePath, text });
}
} catch {
// Fallback: browser-style download
const blob = new Blob([text], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "transcription.txt";
a.click();
URL.revokeObjectURL(url);
}
} catch (err) {
console.error("Failed to save:", err);
}
}
</script>
<div class="controls">
<button
class={isTranscribing ? "danger" : "primary"}
onclick={toggleTranscription}
disabled={!isReady || isLoading}
>
{#if isLoading}
...
{:else if isTranscribing}
Stop Transcription
{:else}
Start Transcription
{/if}
</button>
<button onclick={clearTranscriptions} disabled={!backendStore.connected}>
Clear
</button>
<button onclick={saveTranscriptions} disabled={!backendStore.connected}>
Save
</button>
</div>
<style>
.controls {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background-color: var(--bg-secondary);
border-top: 1px solid var(--border-color);
flex-shrink: 0;
}
</style>
+82
View File
@@ -0,0 +1,82 @@
<script lang="ts">
interface Props {
onSettingsClick: () => void;
}
let { onSettingsClick }: Props = $props();
</script>
<header class="app-header">
<h1 class="app-title">Local Transcription</h1>
<button class="settings-btn" onclick={onSettingsClick} title="Settings">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3"></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1
0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0
0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2
2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65
1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2
0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68
15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0
0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0
0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1
2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0
0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2
2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0
1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0
2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65
0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0
1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
></path>
</svg>
</button>
</header>
<style>
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.app-title {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.5px;
}
.settings-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: 1px solid var(--border-color);
border-radius: 8px;
background-color: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.15s ease, background-color 0.15s ease;
}
.settings-btn:hover {
color: var(--text-primary);
background-color: var(--bg-tertiary);
}
</style>
+780
View File
@@ -0,0 +1,780 @@
<script lang="ts">
import { configStore } from "$lib/stores/config";
import { backendStore } from "$lib/stores/backend";
interface Props {
onClose: () => void;
}
let { onClose }: Props = $props();
// Local copies of config values for editing
let userName = $state("");
let audioDevice = $state("default");
let model = $state("base.en");
let language = $state("en");
let computeDevice = $state("auto");
let computeType = $state("default");
let enableRealtime = $state(false);
let realtimeModel = $state("tiny.en");
let realtimeProcessingPause = $state(0.1);
let sileroSensitivity = $state(0.4);
let webrtcSensitivity = $state(3);
let postSpeechSilence = $state(0.3);
let minRecordingLength = $state(0.5);
let minGapBetween = $state(0);
let continuousMode = $state(false);
let showTimestamps = $state(true);
let fadeSeconds = $state(10);
let maxLines = $state(100);
let fontSize = $state(12);
let userColor = $state("#4CAF50");
let textColor = $state("#FFFFFF");
let backgroundColor = $state("#000000");
let syncEnabled = $state(false);
let syncUrl = $state("");
let syncRoom = $state("default");
let syncPassphrase = $state("");
let remoteMode = $state("local");
let remoteServerUrl = $state("");
let managedEmail = $state("");
let managedPassword = $state("");
let autoCheckUpdates = $state(true);
// Fetched device lists
let audioDevices = $state<{ id: string; name: string }[]>([]);
let computeDevices = $state<{ id: string; name: string }[]>([]);
// Model options
const modelOptions = [
"tiny",
"tiny.en",
"base",
"base.en",
"small",
"small.en",
"medium",
"medium.en",
"large-v1",
"large-v2",
"large-v3",
];
const computeTypeOptions = [
{ value: "default", label: "Default" },
{ value: "int8", label: "int8 (Fastest)" },
{ value: "float16", label: "float16 (GPU)" },
{ value: "float32", label: "float32 (Best Quality)" },
];
const webrtcOptions = [
{ value: 0, label: "0 (Most Sensitive)" },
{ value: 1, label: "1" },
{ value: 2, label: "2" },
{ value: 3, label: "3 (Least Sensitive)" },
];
// Load config values on mount
$effect(() => {
const cfg = configStore.config;
userName = cfg.user.name;
audioDevice = cfg.audio.input_device;
model = cfg.transcription.model;
language = cfg.transcription.language;
computeDevice = cfg.transcription.device;
computeType = cfg.transcription.compute_type;
enableRealtime = cfg.transcription.enable_realtime_transcription;
realtimeModel = cfg.transcription.realtime_model;
realtimeProcessingPause = cfg.transcription.realtime_processing_pause;
sileroSensitivity = cfg.transcription.silero_sensitivity;
webrtcSensitivity = cfg.transcription.webrtc_sensitivity;
postSpeechSilence = cfg.transcription.post_speech_silence_duration;
minRecordingLength = cfg.transcription.min_length_of_recording;
minGapBetween = cfg.transcription.min_gap_between_recordings;
continuousMode = cfg.transcription.continuous_mode;
showTimestamps = cfg.display.show_timestamps;
fadeSeconds = cfg.display.fade_after_seconds;
maxLines = cfg.display.max_lines;
fontSize = cfg.display.font_size;
userColor = cfg.display.user_color;
textColor = cfg.display.text_color;
// Strip alpha from background color for the color picker (only supports 6-char hex)
const bgHex = cfg.display.background_color.replace("#", "");
backgroundColor = "#" + bgHex.substring(0, 6);
syncEnabled = cfg.server_sync.enabled;
syncUrl = cfg.server_sync.url;
syncRoom = cfg.server_sync.room;
syncPassphrase = cfg.server_sync.passphrase;
remoteMode = cfg.remote.mode;
remoteServerUrl = cfg.remote.server_url;
autoCheckUpdates = cfg.updates.auto_check;
});
// Fetch audio devices and compute devices on mount
$effect(() => {
fetchAudioDevices();
fetchComputeDevices();
});
async function fetchAudioDevices() {
try {
const data = await backendStore.apiGet<{
devices: { id: string; name: string }[];
}>("/api/audio-devices");
audioDevices = data.devices ?? [];
} catch {
audioDevices = [];
}
}
async function fetchComputeDevices() {
try {
const data = await backendStore.apiGet<{
devices: { id: string; name: string }[];
}>("/api/compute-devices");
computeDevices = data.devices ?? [];
} catch {
computeDevices = [
{ id: "auto", name: "Auto" },
{ id: "cpu", name: "CPU" },
{ id: "cuda", name: "CUDA (GPU)" },
];
}
}
async function handleSave() {
const updates = {
user: {
name: userName,
},
audio: {
input_device: audioDevice,
},
transcription: {
model,
device: computeDevice,
language,
compute_type: computeType,
enable_realtime_transcription: enableRealtime,
realtime_model: realtimeModel,
realtime_processing_pause: realtimeProcessingPause,
silero_sensitivity: sileroSensitivity,
webrtc_sensitivity: webrtcSensitivity,
post_speech_silence_duration: postSpeechSilence,
min_length_of_recording: minRecordingLength,
min_gap_between_recordings: minGapBetween,
continuous_mode: continuousMode,
},
display: {
show_timestamps: showTimestamps,
fade_after_seconds: fadeSeconds,
max_lines: maxLines,
font_size: fontSize,
user_color: userColor,
text_color: textColor,
background_color: backgroundColor,
},
server_sync: {
enabled: syncEnabled,
url: syncUrl,
room: syncRoom,
passphrase: syncPassphrase,
},
remote: {
mode: remoteMode,
server_url: remoteServerUrl,
},
updates: {
auto_check: autoCheckUpdates,
},
};
try {
await configStore.saveConfig(updates);
onClose();
} catch (err) {
console.error("Failed to save settings:", err);
}
}
function handleCancel() {
onClose();
}
async function handleCheckUpdates() {
try {
await backendStore.apiPost("/api/check-updates");
} catch (err) {
console.error("Failed to check for updates:", err);
}
}
async function handleManagedLogin() {
try {
await backendStore.apiPost("/api/remote/login", {
email: managedEmail,
password: managedPassword,
});
} catch (err) {
console.error("Login failed:", err);
}
}
async function handleManagedRegister() {
try {
await backendStore.apiPost("/api/remote/register", {
email: managedEmail,
password: managedPassword,
});
} catch (err) {
console.error("Register failed:", err);
}
}
function handleOverlayClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains("settings-overlay")) {
handleCancel();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
handleCancel();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="settings-overlay" role="presentation" onclick={handleOverlayClick}>
<div class="settings-panel">
<div class="settings-header">
<h2>Settings</h2>
<button class="close-btn" aria-label="Close settings" onclick={handleCancel}>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="settings-content">
<!-- User Settings -->
<section class="settings-section">
<h3>User Settings</h3>
<div class="field">
<label for="user-name">Display Name</label>
<input id="user-name" type="text" bind:value={userName} />
</div>
</section>
<!-- Audio Settings -->
<section class="settings-section">
<h3>Audio Settings</h3>
<div class="field">
<label for="audio-device">Audio Device</label>
<select id="audio-device" bind:value={audioDevice}>
<option value="default">Default</option>
{#each audioDevices as device}
<option value={device.id}>{device.name}</option>
{/each}
</select>
</div>
</section>
<!-- Transcription Settings -->
<section class="settings-section">
<h3>Transcription Settings</h3>
<div class="field">
<label for="model">Model</label>
<select id="model" bind:value={model}>
{#each modelOptions as opt}
<option value={opt}>{opt}</option>
{/each}
</select>
</div>
<div class="field">
<label for="language">Language</label>
<input id="language" type="text" bind:value={language} placeholder="en" />
</div>
<div class="field">
<label for="compute-device">Compute Device</label>
<select id="compute-device" bind:value={computeDevice}>
{#each computeDevices as dev}
<option value={dev.id}>{dev.name}</option>
{/each}
</select>
</div>
<div class="field">
<label for="compute-type">Compute Type</label>
<select id="compute-type" bind:value={computeType}>
{#each computeTypeOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
</section>
<!-- Realtime Preview -->
<section class="settings-section">
<h3>Realtime Preview</h3>
<div class="field-row">
<label for="enable-realtime">Enable Realtime Preview</label>
<input
id="enable-realtime"
type="checkbox"
bind:checked={enableRealtime}
/>
</div>
{#if enableRealtime}
<div class="field">
<label for="realtime-model">Realtime Model</label>
<select id="realtime-model" bind:value={realtimeModel}>
{#each modelOptions as opt}
<option value={opt}>{opt}</option>
{/each}
</select>
</div>
<div class="field">
<label for="realtime-pause"
>Processing Pause: {realtimeProcessingPause.toFixed(2)}s</label
>
<input
id="realtime-pause"
type="range"
min="0.01"
max="1.0"
step="0.01"
bind:value={realtimeProcessingPause}
/>
</div>
{/if}
</section>
<!-- VAD Settings -->
<section class="settings-section">
<h3>VAD Settings</h3>
<div class="field">
<label for="silero-sensitivity"
>Silero Sensitivity: {sileroSensitivity.toFixed(2)}</label
>
<input
id="silero-sensitivity"
type="range"
min="0.0"
max="1.0"
step="0.05"
bind:value={sileroSensitivity}
/>
</div>
<div class="field">
<label for="webrtc-sensitivity">WebRTC Sensitivity</label>
<select id="webrtc-sensitivity" bind:value={webrtcSensitivity}>
{#each webrtcOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
</section>
<!-- Timing -->
<section class="settings-section">
<h3>Timing</h3>
<div class="field">
<label for="post-speech-silence"
>Post-Speech Silence: {postSpeechSilence.toFixed(2)}s</label
>
<input
id="post-speech-silence"
type="range"
min="0.1"
max="3.0"
step="0.1"
bind:value={postSpeechSilence}
/>
</div>
<div class="field">
<label for="min-recording"
>Min Recording Length: {minRecordingLength.toFixed(2)}s</label
>
<input
id="min-recording"
type="range"
min="0.1"
max="5.0"
step="0.1"
bind:value={minRecordingLength}
/>
</div>
<div class="field">
<label for="min-gap"
>Min Gap Between Recordings: {minGapBetween.toFixed(2)}s</label
>
<input
id="min-gap"
type="range"
min="0"
max="3.0"
step="0.1"
bind:value={minGapBetween}
/>
</div>
<div class="field-row">
<label for="continuous-mode">Continuous Mode</label>
<input
id="continuous-mode"
type="checkbox"
bind:checked={continuousMode}
/>
</div>
</section>
<!-- Display Settings -->
<section class="settings-section">
<h3>Display Settings</h3>
<div class="field-row">
<label for="show-timestamps">Show Timestamps</label>
<input
id="show-timestamps"
type="checkbox"
bind:checked={showTimestamps}
/>
</div>
<div class="field">
<label for="fade-seconds"
>Fade After Seconds: {fadeSeconds} (0 = never)</label
>
<input
id="fade-seconds"
type="range"
min="0"
max="60"
step="1"
bind:value={fadeSeconds}
/>
</div>
<div class="field">
<label for="max-lines">Max Lines: {maxLines}</label>
<input
id="max-lines"
type="range"
min="10"
max="500"
step="10"
bind:value={maxLines}
/>
</div>
<div class="field">
<label for="font-size">Font Size: {fontSize}px</label>
<input
id="font-size"
type="range"
min="8"
max="32"
step="1"
bind:value={fontSize}
/>
</div>
</section>
<!-- Color Settings -->
<section class="settings-section">
<h3>Color Settings</h3>
<div class="field-row">
<label for="user-color">User Color</label>
<input id="user-color" type="color" bind:value={userColor} />
</div>
<div class="field-row">
<label for="text-color">Text Color</label>
<input id="text-color" type="color" bind:value={textColor} />
</div>
<div class="field-row">
<label for="bg-color">Background Color</label>
<input id="bg-color" type="color" bind:value={backgroundColor} />
</div>
</section>
<!-- Server Sync -->
<section class="settings-section">
<h3>Server Sync</h3>
<div class="field-row">
<label for="sync-enabled">Enable Server Sync</label>
<input
id="sync-enabled"
type="checkbox"
bind:checked={syncEnabled}
/>
</div>
{#if syncEnabled}
<div class="field">
<label for="sync-url">Server URL</label>
<input
id="sync-url"
type="url"
bind:value={syncUrl}
placeholder="http://localhost:3000/api/send"
/>
</div>
<div class="field">
<label for="sync-room">Room</label>
<input id="sync-room" type="text" bind:value={syncRoom} />
</div>
<div class="field">
<label for="sync-passphrase">Passphrase</label>
<input
id="sync-passphrase"
type="password"
bind:value={syncPassphrase}
/>
</div>
{/if}
</section>
<!-- Remote Transcription -->
<section class="settings-section">
<h3>Remote Transcription</h3>
<div class="radio-group">
<label>
<input
type="radio"
name="remote-mode"
value="local"
bind:group={remoteMode}
/>
Local
</label>
<label>
<input
type="radio"
name="remote-mode"
value="managed"
bind:group={remoteMode}
/>
Managed
</label>
<label>
<input
type="radio"
name="remote-mode"
value="byok"
bind:group={remoteMode}
/>
BYOK (Bring Your Own Key)
</label>
</div>
{#if remoteMode !== "local"}
<div class="field">
<label for="remote-url">Server URL</label>
<input
id="remote-url"
type="url"
bind:value={remoteServerUrl}
placeholder="wss://your-proxy.com"
/>
</div>
{/if}
{#if remoteMode === "managed"}
<div class="managed-auth">
<div class="field">
<label for="managed-email">Email</label>
<input
id="managed-email"
type="email"
bind:value={managedEmail}
placeholder="email@example.com"
/>
</div>
<div class="field">
<label for="managed-password">Password</label>
<input
id="managed-password"
type="password"
bind:value={managedPassword}
/>
</div>
<div class="auth-buttons">
<button onclick={handleManagedLogin}>Login</button>
<button onclick={handleManagedRegister}>Register</button>
</div>
</div>
{/if}
</section>
<!-- Updates -->
<section class="settings-section">
<h3>Updates</h3>
<div class="field-row">
<label for="auto-check-updates">Auto-Check for Updates</label>
<input
id="auto-check-updates"
type="checkbox"
bind:checked={autoCheckUpdates}
/>
</div>
<button onclick={handleCheckUpdates}>Check Now</button>
</section>
</div>
<div class="settings-footer">
<button onclick={handleCancel}>Cancel</button>
<button class="primary" onclick={handleSave}>Save</button>
</div>
</div>
</div>
<style>
.settings-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.settings-panel {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
width: 560px;
max-width: 95vw;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.settings-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.settings-header h2 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 6px;
background-color: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.close-btn:hover {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.settings-content {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.settings-section {
margin-bottom: 24px;
}
.settings-section:last-child {
margin-bottom: 0;
}
.settings-section h3 {
font-size: 14px;
font-weight: 600;
color: var(--accent-blue);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border-color);
}
.field {
margin-bottom: 12px;
}
.field label {
display: block;
margin-bottom: 4px;
font-size: 12px;
color: var(--text-secondary);
}
.field-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.field-row label {
font-size: 13px;
color: var(--text-primary);
}
.radio-group {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.radio-group label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
}
.managed-auth {
margin-top: 8px;
padding: 12px;
background-color: var(--bg-secondary);
border-radius: 8px;
}
.auth-buttons {
display: flex;
gap: 8px;
margin-top: 8px;
}
.settings-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--border-color);
flex-shrink: 0;
}
</style>
+384
View File
@@ -0,0 +1,384 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { onMount } from "svelte";
interface Props {
onComplete: () => void;
}
let { onComplete }: Props = $props();
type SetupState = "choose" | "downloading" | "error" | "success";
let setupState = $state<SetupState>("choose");
let variant = $state<"cpu" | "cuda">("cpu");
let progress = $state(0);
let progressMessage = $state("");
let errorMessage = $state("");
let unlisten: (() => void) | null = null;
onMount(() => {
return () => {
if (unlisten) {
unlisten();
unlisten = null;
}
};
});
async function startDownload() {
setupState = "downloading";
progress = 0;
progressMessage = "Starting download...";
errorMessage = "";
try {
// Listen for progress events from the Tauri backend
unlisten = await listen<{ progress: number; message: string }>(
"sidecar-download-progress",
(event) => {
progress = event.payload.progress;
progressMessage = event.payload.message;
}
);
await invoke("download_sidecar", { variant });
// Download complete
setupState = "success";
if (unlisten) {
unlisten();
unlisten = null;
}
// Brief pause to show success, then proceed
setTimeout(() => {
onComplete();
}, 1500);
} catch (err) {
setupState = "error";
errorMessage = err instanceof Error ? err.message : String(err);
if (unlisten) {
unlisten();
unlisten = null;
}
}
}
function retry() {
setupState = "choose";
progress = 0;
progressMessage = "";
errorMessage = "";
}
</script>
<div class="setup-overlay">
<div class="setup-card">
<div class="setup-header">
<h1 class="app-title">Local Transcription</h1>
<h2 class="setup-heading">First-Time Setup</h2>
</div>
{#if setupState === "choose"}
<p class="setup-description">
The app needs to download its transcription engine before you can start.
Choose the version that best fits your hardware.
</p>
<div class="variant-options">
<label class="variant-option" class:selected={variant === "cpu"}>
<input
type="radio"
name="variant"
value="cpu"
bind:group={variant}
/>
<div class="variant-info">
<span class="variant-name">Standard (CPU)</span>
<span class="variant-desc">Works on all computers (~500 MB download)</span>
</div>
</label>
<label class="variant-option" class:selected={variant === "cuda"}>
<input
type="radio"
name="variant"
value="cuda"
bind:group={variant}
/>
<div class="variant-info">
<span class="variant-name">GPU Accelerated (CUDA)</span>
<span class="variant-desc">Faster transcription with NVIDIA GPU (~2 GB download)</span>
</div>
</label>
</div>
<button class="download-btn" onclick={startDownload}>
Download & Install
</button>
{:else if setupState === "downloading"}
<div class="progress-section">
<p class="progress-message">{progressMessage}</p>
<div class="progress-bar-track">
<div
class="progress-bar-fill"
style="width: {progress}%"
></div>
</div>
<p class="progress-percent">{Math.round(progress)}%</p>
</div>
{:else if setupState === "error"}
<div class="error-section">
<div class="error-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#f44336" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
</div>
<p class="error-title">Download Failed</p>
<p class="error-message">{errorMessage}</p>
<button class="retry-btn" onclick={retry}>
Try Again
</button>
</div>
{:else if setupState === "success"}
<div class="success-section">
<div class="success-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#4CAF50" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="16 9 10.5 15 8 12.5"/>
</svg>
</div>
<p class="success-title">Setup Complete</p>
<p class="success-message">The transcription engine is ready to go.</p>
</div>
{/if}
</div>
</div>
<style>
.setup-overlay {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
background-color: #1e1e1e;
}
.setup-card {
background-color: #2a2a2a;
border-radius: 12px;
padding: 40px;
max-width: 480px;
width: 100%;
margin: 20px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.setup-header {
text-align: center;
margin-bottom: 24px;
}
.app-title {
font-size: 24px;
font-weight: 700;
color: #e0e0e0;
margin-bottom: 4px;
}
.setup-heading {
font-size: 16px;
font-weight: 500;
color: #a0a0a0;
}
.setup-description {
font-size: 14px;
color: #a0a0a0;
line-height: 1.6;
text-align: center;
margin-bottom: 24px;
}
.variant-options {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 24px;
}
.variant-option {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border: 2px solid #444;
border-radius: 8px;
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.variant-option:hover {
background-color: #333;
border-color: #555;
}
.variant-option.selected {
border-color: #4CAF50;
background-color: rgba(76, 175, 80, 0.08);
}
.variant-option input[type="radio"] {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.variant-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.variant-name {
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
}
.variant-desc {
font-size: 12px;
color: #888;
}
.download-btn {
display: block;
width: 100%;
padding: 12px 24px;
font-size: 15px;
font-weight: 600;
color: white;
background-color: #4CAF50;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.15s ease;
}
.download-btn:hover {
background-color: #45a049;
}
.download-btn:active {
transform: scale(0.98);
}
/* Progress state */
.progress-section {
text-align: center;
padding: 20px 0;
}
.progress-message {
font-size: 14px;
color: #a0a0a0;
margin-bottom: 16px;
}
.progress-bar-track {
width: 100%;
height: 8px;
background-color: #3a3a3a;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.progress-bar-fill {
height: 100%;
background-color: #4CAF50;
border-radius: 4px;
transition: width 0.3s ease;
}
.progress-percent {
font-size: 13px;
color: #707070;
}
/* Error state */
.error-section {
text-align: center;
padding: 10px 0;
}
.error-icon {
display: flex;
justify-content: center;
margin-bottom: 12px;
}
.error-title {
font-size: 18px;
font-weight: 600;
color: #f44336;
margin-bottom: 8px;
}
.error-message {
font-size: 13px;
color: #a0a0a0;
margin-bottom: 20px;
word-break: break-word;
}
.retry-btn {
display: inline-block;
padding: 10px 28px;
font-size: 14px;
font-weight: 600;
color: white;
background-color: #4CAF50;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.15s ease;
}
.retry-btn:hover {
background-color: #45a049;
}
/* Success state */
.success-section {
text-align: center;
padding: 20px 0;
}
.success-icon {
display: flex;
justify-content: center;
margin-bottom: 12px;
}
.success-title {
font-size: 18px;
font-weight: 600;
color: #4CAF50;
margin-bottom: 4px;
}
.success-message {
font-size: 14px;
color: #a0a0a0;
}
</style>
+106
View File
@@ -0,0 +1,106 @@
<script lang="ts">
import { backendStore } from "$lib/stores/backend";
import { configStore } from "$lib/stores/config";
let statusColor = $derived.by(() => {
switch (backendStore.appState) {
case "initializing":
return "#ff9800";
case "ready":
return "#4caf50";
case "transcribing":
return "#f44336";
case "error":
return "#f44336";
default:
return "#888";
}
});
let isPulsing = $derived(backendStore.appState === "transcribing");
let userName = $derived(configStore.config.user.name);
</script>
<div class="status-bar">
<div class="status-left">
<span
class="status-indicator"
class:pulsing={isPulsing}
style="background-color: {statusColor}"
></span>
<span class="state-message">{backendStore.stateMessage}</span>
</div>
<div class="status-right">
{#if backendStore.deviceInfo}
<span class="device-info">{backendStore.deviceInfo}</span>
<span class="separator">|</span>
{/if}
<span class="user-name">{userName}</span>
</div>
</div>
<style>
.status-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 20px;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
font-size: 12px;
flex-shrink: 0;
}
.status-left {
display: flex;
align-items: center;
gap: 8px;
}
.status-right {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-indicator.pulsing {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
box-shadow: 0 0 0 0 rgba(244, 67, 54, 0.4);
}
50% {
opacity: 0.7;
box-shadow: 0 0 0 6px rgba(244, 67, 54, 0);
}
}
.state-message {
color: var(--text-primary);
}
.device-info {
color: var(--text-secondary);
}
.separator {
color: var(--text-muted);
}
.user-name {
color: var(--accent-green);
font-weight: 500;
}
</style>
@@ -0,0 +1,110 @@
<script lang="ts">
import { transcriptionStore } from "$lib/stores/transcriptions";
import { configStore } from "$lib/stores/config";
let container: HTMLDivElement | undefined = $state();
let showTimestamps = $derived(configStore.config.display.show_timestamps);
let items = $derived(transcriptionStore.items);
$effect(() => {
// Trigger on items length change to auto-scroll
const _len = items.length;
if (container) {
requestAnimationFrame(() => {
if (container) {
container.scrollTop = container.scrollHeight;
}
});
}
});
</script>
<div class="transcription-display" bind:this={container}>
{#each items as item (item.id)}
<div class="transcription-item" class:preview={item.isPreview}>
{#if showTimestamps && item.timestamp}
<span class="timestamp">[{item.timestamp}]</span>
{/if}
{#if item.userName}
<span class="user-name">{item.userName}:</span>
{/if}
{#if item.isPreview}
<span class="preview-indicator">[...]</span>
{/if}
<span class="text">{item.text}</span>
</div>
{:else}
<div class="empty-state">
Transcriptions will appear here...
</div>
{/each}
</div>
<style>
.transcription-display {
flex: 1;
overflow-y: auto;
padding: 12px 20px;
display: flex;
flex-direction: column;
gap: 6px;
}
.transcription-item {
padding: 6px 10px;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.03);
animation: fadeIn 0.2s ease-out;
line-height: 1.6;
word-wrap: break-word;
}
.transcription-item.preview {
font-style: italic;
opacity: 0.7;
}
.timestamp {
color: #888;
font-size: 0.85em;
margin-right: 8px;
font-family: monospace;
}
.user-name {
color: #4caf50;
font-weight: 700;
margin-right: 6px;
}
.preview-indicator {
color: #888;
font-size: 0.85em;
margin-right: 4px;
}
.text {
color: #ffffff;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 15px;
font-style: italic;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
+266
View File
@@ -0,0 +1,266 @@
/**
* Backend store - manages WebSocket connection and REST API communication
* with the Python backend server running on localhost.
*
* The backend port defaults to 8081 but can be updated at runtime via
* `setPort()`. The WebSocket connects to /ws/control for real-time push
* of transcriptions, previews, and state changes.
*/
export type ConnectionState = "connecting" | "connected" | "disconnected" | "error";
export type AppState = "initializing" | "ready" | "transcribing" | "reloading" | "error";
interface BackendState {
port: number;
connectionState: ConnectionState;
appState: AppState;
stateMessage: string;
deviceInfo: string;
wsConnection: WebSocket | null;
version: string;
lastError: string;
}
let state = $state<BackendState>({
port: 8081,
connectionState: "disconnected",
appState: "initializing",
stateMessage: "Connecting to backend...",
deviceInfo: "",
wsConnection: null,
version: "1.4.0",
lastError: "",
});
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_DELAY_MS = 30_000;
const BASE_RECONNECT_DELAY_MS = 1_000;
// ── URL helpers ──────────────────────────────────────────────────────
function apiUrl(path: string): string {
const normalised = path.startsWith("/") ? path : `/${path}`;
return `http://localhost:${state.port}${normalised}`;
}
async function apiFetch(path: string, options?: RequestInit): Promise<Response> {
const url = apiUrl(path);
const method = options?.method?.toUpperCase() ?? "GET";
const headers = new Headers(options?.headers);
if (method !== "GET" && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
return fetch(url, { ...options, headers });
}
// ── WebSocket management ─────────────────────────────────────────────
function connectWebSocket() {
// Tear down any existing connection
disconnect();
state.connectionState = "connecting";
reconnectAttempts = 0;
_openSocket();
}
function _openSocket() {
const wsUrl = `ws://localhost:${state.port}/ws/control`;
try {
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
state.connectionState = "connected";
state.lastError = "";
reconnectAttempts = 0;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleWebSocketMessage(data);
} catch {
// ignore parse errors
}
};
ws.onclose = () => {
state.wsConnection = null;
if (state.connectionState !== "disconnected") {
state.connectionState = "error";
state.stateMessage = "Disconnected from backend";
_scheduleReconnect();
}
};
ws.onerror = () => {
state.lastError = "WebSocket error";
// onclose fires after this, which handles reconnect
};
state.wsConnection = ws;
} catch {
state.connectionState = "error";
state.stateMessage = "Failed to connect";
_scheduleReconnect();
}
}
function _scheduleReconnect() {
if (reconnectTimer) return;
const delay = Math.min(
BASE_RECONNECT_DELAY_MS * Math.pow(2, reconnectAttempts),
MAX_RECONNECT_DELAY_MS,
);
reconnectAttempts++;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (state.connectionState !== "disconnected") {
state.connectionState = "connecting";
_openSocket();
}
}, delay);
}
function disconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
state.connectionState = "disconnected";
if (state.wsConnection) {
const ws = state.wsConnection;
ws.onclose = null;
ws.onerror = null;
ws.close();
state.wsConnection = null;
}
}
// ── WebSocket message handling ───────────────────────────────────────
function handleWebSocketMessage(data: Record<string, unknown>) {
// Handle state changes locally
if (data.type === "state_changed") {
if (data.state) {
state.appState = data.state as AppState;
}
if (data.message) {
state.stateMessage = data.message as string;
}
}
if (data.type === "error") {
state.lastError = (data.message as string) ?? "Unknown error";
}
// Dispatch to window for other stores (transcriptions, etc.)
if (data.type === "transcription") {
window.dispatchEvent(
new CustomEvent("backend:transcription", { detail: data })
);
} else if (data.type === "preview") {
window.dispatchEvent(
new CustomEvent("backend:preview", { detail: data })
);
} else if (data.type === "credits_low") {
window.dispatchEvent(
new CustomEvent("backend:credits_low", { detail: data })
);
}
}
// ── Port management ──────────────────────────────────────────────────
function setPort(newPort: number) {
if (newPort === state.port) return;
state.port = newPort;
// Reconnect with new port if we had a connection
if (state.connectionState !== "disconnected") {
connectWebSocket();
}
}
// ── Typed REST helpers ───────────────────────────────────────────────
async function apiGet<T = unknown>(path: string): Promise<T> {
const resp = await apiFetch(path);
if (!resp.ok) throw new Error(`GET ${path} failed: ${resp.status}`);
return resp.json();
}
async function apiPost<T = unknown>(
path: string,
body?: unknown
): Promise<T> {
const resp = await apiFetch(path, {
method: "POST",
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!resp.ok) throw new Error(`POST ${path} failed: ${resp.status}`);
return resp.json();
}
async function apiPut<T = unknown>(
path: string,
body?: unknown
): Promise<T> {
const resp = await apiFetch(path, {
method: "PUT",
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!resp.ok) throw new Error(`PUT ${path} failed: ${resp.status}`);
return resp.json();
}
// ── Public API ───────────────────────────────────────────────────────
export const backendStore = {
get port() {
return state.port;
},
get connectionState() {
return state.connectionState;
},
get connected() {
return state.connectionState === "connected";
},
get appState() {
return state.appState;
},
get stateMessage() {
return state.stateMessage;
},
get deviceInfo() {
return state.deviceInfo;
},
get version() {
return state.version;
},
get lastError() {
return state.lastError;
},
get apiBaseUrl() {
return `http://localhost:${state.port}`;
},
get wsUrl() {
return `ws://localhost:${state.port}/ws/control`;
},
setPort,
connect: connectWebSocket,
disconnect,
apiUrl,
apiFetch,
apiGet,
apiPost,
apiPut,
};
+243
View File
@@ -0,0 +1,243 @@
/**
* Config store - manages application configuration loaded from
* and saved to the Python backend via the backend store's API helpers.
*
* The backend accepts PUT /api/config with `{ settings: { "dot.key": value } }`.
*/
import { backendStore } from "$lib/stores/backend";
export interface AppConfig {
user: {
name: string;
id: string;
};
audio: {
input_device: string;
sample_rate: number;
};
transcription: {
model: string;
device: string;
language: string;
compute_type: string;
enable_realtime_transcription: boolean;
realtime_model: string;
realtime_processing_pause: number;
silero_sensitivity: number;
silero_use_onnx: boolean;
webrtc_sensitivity: number;
post_speech_silence_duration: number;
min_length_of_recording: number;
min_gap_between_recordings: number;
pre_recording_buffer_duration: number;
beam_size: number;
initial_prompt: string;
no_log_file: boolean;
continuous_mode: boolean;
};
server_sync: {
enabled: boolean;
url: string;
room: string;
passphrase: string;
};
display: {
show_timestamps: boolean;
max_lines: number;
font_source: string;
font_family: string;
websafe_font: string;
google_font: string;
custom_font_file: string;
font_size: number;
theme: string;
fade_after_seconds: number;
user_color: string;
text_color: string;
background_color: string;
};
web_server: {
port: number;
host: string;
};
remote: {
mode: string;
server_url: string;
auth_token: string;
byok_api_key: string;
deepgram_model: string;
language: string;
fallback_to_local: boolean;
};
updates: {
auto_check: boolean;
gitea_url: string;
owner: string;
repo: string;
skipped_versions: string[];
last_check: string;
check_interval_hours: number;
};
}
function getDefaultConfig(): AppConfig {
return {
user: { name: "User", id: "" },
audio: { input_device: "default", sample_rate: 16000 },
transcription: {
model: "base.en",
device: "auto",
language: "en",
compute_type: "default",
enable_realtime_transcription: false,
realtime_model: "tiny.en",
realtime_processing_pause: 0.1,
silero_sensitivity: 0.4,
silero_use_onnx: true,
webrtc_sensitivity: 3,
post_speech_silence_duration: 0.3,
min_length_of_recording: 0.5,
min_gap_between_recordings: 0,
pre_recording_buffer_duration: 0.2,
beam_size: 5,
initial_prompt: "",
no_log_file: true,
continuous_mode: false,
},
server_sync: {
enabled: false,
url: "http://localhost:3000/api/send",
room: "default",
passphrase: "",
},
display: {
show_timestamps: true,
max_lines: 100,
font_source: "System Font",
font_family: "Courier",
websafe_font: "Arial",
google_font: "Roboto",
custom_font_file: "",
font_size: 12,
theme: "dark",
fade_after_seconds: 10,
user_color: "#4CAF50",
text_color: "#FFFFFF",
background_color: "#000000B3",
},
web_server: { port: 8080, host: "127.0.0.1" },
remote: {
mode: "local",
server_url: "",
auth_token: "",
byok_api_key: "",
deepgram_model: "nova-2",
language: "en-US",
fallback_to_local: true,
},
updates: {
auto_check: true,
gitea_url: "https://repo.anhonesthost.net",
owner: "streamer-tools",
repo: "local-transcription",
skipped_versions: [],
last_check: "",
check_interval_hours: 24,
},
};
}
let config = $state<AppConfig>(getDefaultConfig());
let loading = $state(false);
let error = $state("");
/**
* Fetch the full configuration tree from the backend.
* GET /api/config
*/
async function fetchConfig(): Promise<void> {
loading = true;
error = "";
try {
const data = await backendStore.apiGet<Record<string, unknown>>("/api/config");
// Deep merge with defaults to ensure all keys exist
config = deepMerge(getDefaultConfig(), data) as AppConfig;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
console.error("[config] fetchConfig failed:", error);
} finally {
loading = false;
}
}
function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
const result = { ...target };
for (const key of Object.keys(source)) {
if (
source[key] &&
typeof source[key] === "object" &&
!Array.isArray(source[key]) &&
target[key] &&
typeof target[key] === "object" &&
!Array.isArray(target[key])
) {
result[key] = deepMerge(
target[key] as Record<string, unknown>,
source[key] as Record<string, unknown>
);
} else {
result[key] = source[key];
}
}
return result;
}
/**
* Send a batch of setting updates to the backend.
* PUT /api/config with body `{ settings: { "dot.key": value, ... } }`
*
* Keys use dot-notation, e.g. `{ "transcription.model": "small.en" }`.
*
* Returns the response payload on success, or throws on failure.
*/
async function updateConfig(
settings: Record<string, unknown>,
): Promise<{ status: string; message: string; engine_reloaded: boolean }> {
loading = true;
error = "";
try {
const result = await backendStore.apiPut<{
status: string;
message: string;
engine_reloaded: boolean;
}>("/api/config", { settings });
// Refresh the local config tree so the UI stays in sync
await fetchConfig();
return result;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
console.error("[config] updateConfig failed:", error);
throw err;
} finally {
loading = false;
}
}
export const configStore = {
get config() {
return config;
},
get loading() {
return loading;
},
get error() {
return error;
},
fetchConfig,
updateConfig,
};
+109
View File
@@ -0,0 +1,109 @@
/**
* Transcriptions store - manages the list of transcription items
* received from the backend via WebSocket.
*/
export interface TranscriptionItem {
id: string;
text: string;
userName: string;
timestamp: string;
isPreview: boolean;
}
let items = $state<TranscriptionItem[]>([]);
let nextId = 0;
function generateId(): string {
return `t-${Date.now()}-${nextId++}`;
}
function addTranscription(data: {
text?: string;
user_name?: string;
timestamp?: string;
}) {
// When a final transcription arrives, remove any existing preview
const previewIndex = items.findIndex((item) => item.isPreview);
if (previewIndex !== -1) {
items.splice(previewIndex, 1);
}
items.push({
id: generateId(),
text: data.text ?? "",
userName: data.user_name ?? "",
timestamp: data.timestamp ?? "",
isPreview: false,
});
// Keep a reasonable limit
if (items.length > 500) {
items.splice(0, items.length - 500);
}
}
function setPreview(data: {
text?: string;
user_name?: string;
timestamp?: string;
}) {
const existingIndex = items.findIndex((item) => item.isPreview);
const previewItem: TranscriptionItem = {
id: existingIndex !== -1 ? items[existingIndex].id : generateId(),
text: data.text ?? "",
userName: data.user_name ?? "",
timestamp: data.timestamp ?? "",
isPreview: true,
};
if (existingIndex !== -1) {
items[existingIndex] = previewItem;
} else {
items.push(previewItem);
}
}
function clearAll() {
items.length = 0;
}
function getPlainText(): string {
return items
.filter((item) => !item.isPreview)
.map((item) => {
let line = "";
if (item.timestamp) line += `[${item.timestamp}] `;
if (item.userName) line += `${item.userName}: `;
line += item.text;
return line;
})
.join("\n");
}
// Listen for backend events
if (typeof window !== "undefined") {
window.addEventListener("backend:transcription", ((e: CustomEvent) => {
addTranscription(e.detail);
}) as EventListener);
window.addEventListener("backend:preview", ((e: CustomEvent) => {
setPreview(e.detail);
}) as EventListener);
}
export const transcriptionStore = {
get items() {
return items;
},
get currentPreview(): TranscriptionItem | null {
return items.find((item) => item.isPreview) ?? null;
},
get transcriptions(): TranscriptionItem[] {
return items.filter((item) => !item.isPreview);
},
addTranscription,
setPreview,
clearAll,
getPlainText,
};
+2 -2
View File
@@ -1,7 +1,7 @@
"""Version information for Local Transcription."""
__version__ = "1.4.0"
__version_info__ = (1, 4, 0)
__version__ = "1.4.5"
__version_info__ = (1, 0, 3)
# Version history:
# 1.4.0 - Auto-update feature: