Compare commits

...

61 Commits

Author SHA1 Message Date
Gitea Actions
1c586738f3 chore: bump version to 2.0.8 [skip ci] 2026-04-08 16:58:00 +00:00
Developer
fb02a24334 Remove CUDA sidecar builds, keep CPU + Cloud only
All checks were successful
Tests / Python Backend Tests (push) Successful in 6s
Tests / Frontend Tests (push) Successful in 8s
Tests / Rust Sidecar Tests (push) Successful in 2m3s
CUDA sidecars are ~2GB and too slow to upload from the Windows runner.
Cloud (Deepgram) provides faster transcription anyway. Removed:

- CUDA build steps from Windows and Linux sidecar workflows
- CUDA option from the SidecarSetup download screen

Remaining sidecar variants:
- Cloud (Deepgram): ~50 MB - recommended for most users
- Local CPU: ~500 MB - for offline/privacy use

CUDA can be revisited once the managed Deepgram service is ready.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:49:36 -07:00
Developer
ce64cacc5e Use max compression for sidecar zips to reduce upload size
All checks were successful
Tests / Python Backend Tests (push) Successful in 5s
Tests / Frontend Tests (push) Successful in 7s
Tests / Rust Sidecar Tests (push) Successful in 2m1s
zip -9 on Linux, 7z -mx=9 on Windows. Compression takes longer but
produces smaller files which upload faster over the network.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:42:26 -07:00
Gitea Actions
14a7ca3b30 chore: bump sidecar version to 1.0.6 [skip ci] 2026-04-08 16:26:36 +00:00
Gitea Actions
5b7387f9c6 chore: bump version to 2.0.7 [skip ci] 2026-04-08 16:21:51 +00:00
Developer
293362baa1 Cloud sidecar auto-detects variant and guides user to configure
All checks were successful
Tests / Python Backend Tests (push) Successful in 5s
Tests / Frontend Tests (push) Successful in 8s
Tests / Rust Sidecar Tests (push) Successful in 2m7s
On first launch, the cloud sidecar now:
1. Detects it's the cloud variant (DeviceManager import fails)
2. Auto-switches config from "local" to "byok" mode
3. Shows "Setup needed: Open Settings > Remote Transcription >
   enter your Deepgram API key" as a friendly status message
4. Stays in READY state so the UI is fully accessible

The user can then open Settings, enter their Deepgram API key,
save, and start transcribing without needing to know about modes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:17:06 -07:00
Developer
41f50dedec Fix cloud sidecar crash on first launch
All checks were successful
Tests / Python Backend Tests (push) Successful in 5s
Tests / Frontend Tests (push) Successful in 8s
Tests / Rust Sidecar Tests (push) Successful in 3m11s
The cloud sidecar excludes the local Whisper engine module, but on
first launch the config defaults to remote.mode="local" which tries
to import it. Now catches the ImportError gracefully and shows an
error message telling the user to switch to Cloud (Deepgram) mode
in Settings. The API server still starts so Settings is accessible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:12:17 -07:00
Developer
d8b7811153 Fix NaN% in sidecar download progress
All checks were successful
Tests / Python Backend Tests (push) Successful in 6s
Tests / Frontend Tests (push) Successful in 8s
Tests / Rust Sidecar Tests (push) Successful in 2m4s
The Rust backend emits {downloaded, total, phase, message} but the
Svelte component was reading event.payload.progress which doesn't
exist, resulting in NaN. Now calculates percentage from downloaded/total.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:06:39 -07:00
Developer
ec8922672c Fix Stop Transcription button not updating after click
All checks were successful
Tests / Python Backend Tests (push) Successful in 6s
Tests / Frontend Tests (push) Successful in 8s
Tests / Rust Sidecar Tests (push) Successful in 2m9s
After calling POST /api/stop, the button stayed on "Stop Transcription"
because the state update depended on the WebSocket broadcast which can
be delayed or missed (event loop threading issue).

Fix: poll GET /api/status immediately after start/stop API calls to
update the UI state directly, rather than waiting for the WebSocket.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 07:26:06 -07:00
Gitea Actions
375669f657 chore: bump sidecar version to 1.0.5 [skip ci] 2026-04-08 00:43:01 +00:00
Gitea Actions
c8b11fb0ad chore: bump version to 2.0.6 [skip ci] 2026-04-08 00:37:28 +00:00
Developer
273a926f03 Fix YAML parse error: use block scalar for echo with colons
All checks were successful
Tests / Python Backend Tests (push) Successful in 5s
Tests / Frontend Tests (push) Successful in 7s
Tests / Rust Sidecar Tests (push) Successful in 2m7s
Gitea's YAML parser treats `echo "text: value"` as a mapping when
on a single `run:` line. Using block scalar (`run: |`) avoids this.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:21:42 -07:00
Gitea Actions
5bbbc38875 chore: bump version to 2.0.5 [skip ci] 2026-04-08 00:19:25 +00:00
Developer
d50be6654d Fix dispatch failures and disable automatic cleanup
All checks were successful
Tests / Python Backend Tests (push) Successful in 5s
Tests / Frontend Tests (push) Successful in 8s
Tests / Rust Sidecar Tests (push) Successful in 2m8s
1. Quote RELEASE_TAG env vars in all workflow files. Unquoted
   ${{ inputs.tag }} caused YAML parse errors on some Gitea runners,
   making dispatch return HTTP 500 for Linux/macOS.

2. Disable automatic release cleanup in both coordinators. The cleanup
   races with async builds -- it deletes the release before builds
   finish uploading their assets. Clean up old releases manually
   from the Gitea UI instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:16:36 -07:00
Developer
68abf49018 Log dispatch error responses for debugging
Some checks failed
Tests / Python Backend Tests (push) Successful in 5s
Tests / Frontend Tests (push) Successful in 7s
Tests / Rust Sidecar Tests (push) Has been cancelled
Show the Gitea API response body when dispatch returns non-204,
to help diagnose why Linux/macOS dispatches return HTTP 500.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:14:23 -07:00
Gitea Actions
8cc2a3ec7a chore: bump version to 2.0.4 [skip ci] 2026-04-08 00:09:39 +00:00
Developer
8aa9dfc644 Update Cargo.lock and generated Tauri schemas
All checks were successful
Tests / Python Backend Tests (push) Successful in 5s
Tests / Frontend Tests (push) Successful in 7s
Tests / Rust Sidecar Tests (push) Successful in 2m10s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:03:40 -07:00
Developer
3f16aa838d Add ability to change transcription engine from Settings
Some checks failed
Tests / Python Backend Tests (push) Successful in 6s
Tests / Frontend Tests (push) Successful in 8s
Tests / Rust Sidecar Tests (push) Has been cancelled
New features:
- Settings > Transcription Engine > "Change Transcription Engine"
  button stops the sidecar, deletes downloaded files, and reloads
  the app to show the engine selection screen
- Improved SidecarSetup descriptions with detailed explanations
  of each variant and "Recommended" tag on Cloud (Deepgram)
- Cloud option listed first as the recommended choice
- New reset_sidecar Tauri command that cleans up sidecar files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:02:31 -07:00
Developer
3d3d7ec3c5 Add cloud-only sidecar variant (~50MB vs 500MB-2GB)
All checks were successful
Tests / Python Backend Tests (push) Successful in 6s
Tests / Frontend Tests (push) Successful in 7s
Tests / Rust Sidecar Tests (push) Successful in 1m59s
Lightweight Deepgram-only sidecar that excludes PyTorch, faster-whisper,
RealtimeSTT, and CUDA. Only includes audio capture + WebSocket streaming
to Deepgram. Requires a Deepgram API key (BYOK or managed mode).

Changes:
- client/models.py: Extracted TranscriptionResult into standalone module
  so deepgram_transcription.py doesn't transitively import torch
- backend/app_controller.py: Made RealtimeTranscriptionEngine and
  DeviceManager imports lazy (only loaded when remote.mode == "local")
- local-transcription-cloud.spec: PyInstaller spec excluding all ML deps
- SidecarSetup.svelte: Added "Cloud Only (Deepgram)" variant option
- build-sidecar-cloud.yml: CI workflow building cloud sidecar for all 3 OS
- sidecar-release.yml: Dispatches cloud build alongside CPU/CUDA builds

Sidecar download options are now:
- Standard (CPU): ~500 MB - local Whisper on any computer
- GPU Accelerated (CUDA): ~2 GB - local Whisper with NVIDIA GPU
- Cloud Only (Deepgram): ~50 MB - requires API key, no local models

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:57:43 -07:00
Developer
bb039399fc Add font source/family settings matching v1.4.0 feature set
All checks were successful
Tests / Python Backend Tests (push) Successful in 6s
Tests / Frontend Tests (push) Successful in 8s
Tests / Rust Sidecar Tests (push) Successful in 2m11s
Restored the font configuration that was missing from the Tauri
rewrite. Settings now include:

- Font Source: System Font, Web-Safe, Google Font
- System Font: text input for any installed font family
- Web-Safe: dropdown with 13 universal fonts (Arial, Courier New, etc.)
- Google Font: dropdown with 35 fonts organized by category
  (Sans Serif, Serif, Monospace, Display, Handwriting)
- Font Size: range slider (8-32px)

All font settings are saved to config and applied to the OBS web
display and server sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:40:52 -07:00
Developer
9dcb14e92c Fix Deepgram streaming latency
All checks were successful
Tests / Python Backend Tests (push) Successful in 5s
Tests / Frontend Tests (push) Successful in 9s
Tests / Rust Sidecar Tests (push) Successful in 2m5s
Three changes to reduce transcription delay:

1. Send loop: queue.get() was blocking the asyncio event loop, stalling
   the receive loop and delaying transcription results. Now uses
   run_in_executor() to avoid blocking the event loop.

2. Block size: reduced from 4096 (~256ms) to 1024 (~64ms) for more
   frequent, smaller audio chunks. Deepgram handles streaming better
   with smaller packets.

3. Added punctuate=true and smart_format=true to Deepgram BYOK
   params for cleaner transcription output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:31:50 -07:00
Developer
8db9b8298b Fix dev mode sidecar launch and engine reload on mode change
All checks were successful
Tests / Python Backend Tests (push) Successful in 6s
Tests / Frontend Tests (push) Successful in 7s
Tests / Rust Sidecar Tests (push) Successful in 1m57s
1. Dev mode: use `uv run python` instead of bare `python` to ensure
   the project venv is used. Also use CARGO_MANIFEST_DIR to find the
   project root reliably.

2. Engine reload: changing remote.mode (local/managed/byok) now
   triggers a full engine reload. Previously only model and device
   changes triggered reload, so switching to Deepgram had no effect
   until the app was restarted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:25:07 -07:00
Developer
411779f578 Make release and sidecar-release manual-only while testing
All checks were successful
Tests / Python Backend Tests (push) Successful in 4s
Tests / Frontend Tests (push) Successful in 7s
Tests / Rust Sidecar Tests (push) Successful in 1m59s
Removed push triggers from both coordinator workflows. They now
only run via workflow_dispatch (manual "Run workflow" button).
Re-enable push triggers once the build pipeline is stable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:04:06 -07:00
Developer
bc6055a707 Add workflow_dispatch trigger to release.yml
Some checks failed
Tests / Python Backend Tests (push) Successful in 6s
Tests / Frontend Tests (push) Successful in 8s
Tests / Rust Sidecar Tests (push) Has been cancelled
Allows manually triggering app releases from the Gitea Actions UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:03:18 -07:00
Gitea Actions
e42a922507 chore: bump sidecar version to 1.0.4 [skip ci] 2026-04-07 20:01:20 +00:00
Developer
8fc2d11c5f Fix builds failing to checkout: stop deleting tags, fix tag passing
All checks were successful
Tests / Python Backend Tests (push) Successful in 5s
Tests / Frontend Tests (push) Successful in 8s
Tests / Rust Sidecar Tests (push) Successful in 2m3s
Two issues causing all builds to fail:

1. Cleanup steps deleted git tags along with releases. Since builds
   are dispatched asynchronously, they tried to checkout tags that
   had already been deleted. Now cleanup only deletes releases (which
   frees storage by removing assets) but preserves git tags.

2. Linux/macOS build workflows used $GITHUB_OUTPUT step outputs for
   the tag, which is unreliable on Gitea runners. Switched to the
   same job-level env var pattern (RELEASE_TAG) that works on Windows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:27:13 -07:00
Gitea Actions
11832e911b chore: bump version to 2.0.3 [skip ci] 2026-04-07 19:21:16 +00:00
Developer
18e6b974c0 Fix sidecar stdout buffering: set PYTHONUNBUFFERED=1
All checks were successful
Release / Run Tests (push) Successful in 24s
Tests / Python Backend Tests (push) Successful in 5s
Tests / Frontend Tests (push) Successful in 7s
Tests / Rust Sidecar Tests (push) Successful in 3m17s
Release / Bump version and tag (push) Successful in 4s
PyInstaller frozen executables buffer stdout when piped to a
subprocess (no TTY). Even with flush=True in Python, the OS-level
pipe buffer can delay output. This prevented the ready event from
reaching the Tauri app, causing the "Starting sidecar..." hang.

Fix: set PYTHONUNBUFFERED=1 env var on both prod and dev sidecar
commands, plus -u flag for dev mode Python.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:17:18 -07:00
Gitea Actions
08e464daaf chore: bump version to 2.0.2 [skip ci] 2026-04-07 19:15:05 +00:00
Developer
5d22adcaa4 Fix app hanging on sidecar startup
All checks were successful
Release / Run Tests (push) Successful in 11s
Tests / Python Backend Tests (push) Successful in 4s
Tests / Frontend Tests (push) Successful in 7s
Tests / Rust Sidecar Tests (push) Successful in 3m13s
Release / Bump version and tag (push) Successful in 14s
Two issues caused the app to freeze on "Starting sidecar...":

1. wait_for_ready() used a blocking BufReader::lines() iterator
   with a timeout check between lines. If the sidecar produced no
   stdout output (crashed, missing binary, or slow model loading),
   the read blocked forever. Now uses a background thread with
   mpsc::recv_timeout() for a real 120s deadline.

2. start_sidecar was a synchronous Tauri command that blocked the
   main thread during the entire sidecar startup (up to 120s).
   Now async via tokio::spawn_blocking, keeping the UI responsive.

Also logs all sidecar stdout lines to stderr with [sidecar-stdout]
prefix for debugging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:11:13 -07:00
Gitea Actions
36b4f7dad5 chore: bump version to 2.0.1 [skip ci] 2026-04-07 15:58:01 +00:00
Developer
1ecb23b83f Bump to v2.0.0 — cross-platform Tauri rewrite
All checks were successful
Release / Run Tests (push) Successful in 9s
Tests / Python Backend Tests (push) Successful in 5s
Tests / Frontend Tests (push) Successful in 6s
Tests / Rust Sidecar Tests (push) Successful in 2m7s
Release / Bump version and tag (push) Successful in 4s
Major version bump reflecting the architecture change from PySide6/Qt
to Tauri v2 + Svelte 5 with cross-platform support for Windows,
macOS, and Linux.

Key changes since v1.4.0:
- Tauri v2 native desktop shell replacing PySide6/Qt
- Svelte 5 reactive frontend
- Headless Python backend as a downloadable sidecar
- Deepgram cloud transcription (managed + BYOK)
- Gitea CI/CD with per-OS builds and automated releases
- Sidecar auto-update checking on startup
- 63-test suite (Python + Svelte + Rust)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:55:25 -07:00
Developer
4b88871a9b Add sidecar update check on startup
Some checks failed
Release / Run Tests (push) Successful in 11s
Tests / Python Backend Tests (push) Successful in 5s
Tests / Frontend Tests (push) Successful in 7s
Release / Bump version and tag (push) Has been cancelled
Tests / Rust Sidecar Tests (push) Has been cancelled
On launch, after confirming the sidecar is installed, the app now
checks for a newer sidecar version via the Gitea API. If an update
is available, shows a prompt with "Update Now" or "Skip":

- Update Now: shows the SidecarSetup download screen
- Skip: launches the existing sidecar version

The update check is non-blocking -- if it fails (no internet, API
error), the app silently proceeds with the current version.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:54:18 -07:00
Gitea Actions
0ae48a67d5 chore: bump version to 1.4.21 [skip ci] 2026-04-07 15:51:32 +00:00
Developer
924cae6c75 Fix double-prefix sidecar dir and stale PID lock on startup
All checks were successful
Release / Run Tests (push) Successful in 29s
Tests / Python Backend Tests (push) Successful in 8s
Tests / Frontend Tests (push) Successful in 10s
Tests / Rust Sidecar Tests (push) Successful in 4m7s
Release / Bump version and tag (push) Successful in 4s
Two bugs preventing sidecar from starting:

1. Directory was "sidecar-sidecar-v1.0.3" (double prefix) because
   sidecar_dir_for_version() prepended "sidecar-" to a version that
   already contained it. Now uses the tag directly as the dir name.

2. After a crash, the Python InstanceLock PID file at
   ~/.local-transcription/app.lock remained, blocking the next launch
   with "Another instance is already running". Now clears the stale
   lock file before spawning the sidecar.

Also fixed cleanup_old_versions() and tests to match the corrected
directory naming.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:46:31 -07:00
Gitea Actions
5139936e18 chore: bump version to 1.4.20 [skip ci] 2026-04-07 15:44:54 +00:00
Developer
47724f1ac0 Capture sidecar stderr to sidecar.log for crash debugging
All checks were successful
Release / Run Tests (push) Successful in 15s
Tests / Python Backend Tests (push) Successful in 7s
Tests / Frontend Tests (push) Successful in 10s
Tests / Rust Sidecar Tests (push) Successful in 2m30s
Release / Bump version and tag (push) Successful in 12s
When the sidecar process exits before sending the ready event, the
error message now includes the last 10 lines of stderr. Stderr is
captured in a background thread and written to sidecar.log in the
app data directory.

This helps diagnose why the PyInstaller sidecar fails to start
(missing DLLs, import errors, permission issues, etc.).

Log location: %APPDATA%\net.anhonesthost.local-transcription\sidecar.log

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:41:40 -07:00
Developer
3b204be37e Add automatic cleanup of old releases to save storage
All checks were successful
Tests / Python Backend Tests (push) Successful in 13s
Tests / Frontend Tests (push) Successful in 23s
Tests / Rust Sidecar Tests (push) Successful in 2m8s
- App releases: keeps latest 3 + v1.4.0 (last pre-Tauri version),
  deletes older releases and their tags
- Sidecar releases: keeps latest 2, deletes older releases and tags
  (sidecars are large, ~500MB-2GB each)

Cleanup runs after creating new releases, before triggering builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:10:55 -07:00
Developer
4c02a48135 Fix CI: use uv for test venv, gate builds on tests, reduce build triggers
All checks were successful
Tests / Python Backend Tests (push) Successful in 6s
Tests / Frontend Tests (push) Successful in 7s
Tests / Rust Sidecar Tests (push) Successful in 2m7s
- test.yml: use uv venv instead of pip --break-system-packages
- release.yml: inline test job that must pass before version bump;
  only triggers on source file changes (src/, src-tauri/, package.json)
- sidecar-release.yml: inline Python test job that must pass before
  sidecar version bump
- Both coordinators use `needs: test` so builds never start if tests fail

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:05:49 -07:00
Gitea Actions
997e97c19a chore: bump version to 1.4.19 [skip ci] 2026-04-07 15:00:21 +00:00
Developer
6ca8fc41b2 Fix sidecar release conflict: stop modifying version.py
Some checks failed
Release / Bump version and tag (push) Successful in 4s
Tests / Python Backend Tests (push) Failing after 3s
Tests / Frontend Tests (push) Successful in 7s
Tests / Rust Sidecar Tests (push) Successful in 2m21s
Both release.yml and sidecar-release.yml were updating version.py,
causing merge conflicts when both ran on the same push. Now:
- release.yml (app) owns: package.json, tauri.conf.json, Cargo.toml, version.py
- sidecar-release.yml owns: pyproject.toml only

Also deleted the stale sidecar-v1.0.4 tag that failed to push.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:00:09 -07:00
Gitea Actions
d9d90563cc chore: bump version to 1.4.18 [skip ci] 2026-04-07 14:49:51 +00:00
Developer
5a674ed199 Add test suite (63 tests) and CI workflow, fix Settings API bugs
Some checks failed
Release / Bump version and tag (push) Successful in 4s
Sidecar Release / Bump sidecar version and tag (push) Failing after 3s
Tests / Python Backend Tests (push) Failing after 3s
Tests / Frontend Tests (push) Successful in 8s
Tests / Rust Sidecar Tests (push) Successful in 3m10s
Test suite covering all three layers:

Python backend (25 tests):
- AppController: state machine, start/stop, callbacks, settings reload
- API server: REST endpoints, config CRUD, status, devices
- Config: dot-notation get/set, persistence, nested paths
- Main headless: ready event port format validation

Svelte frontend (14 tests via Vitest):
- Backend store: exported properties/methods, port derivation, URLs
- Config store: method names (fetchConfig not loadConfig), defaults
- Transcriptions store: add/clear/plaintext
- File extension regression: ensures $state runes only in .svelte.ts

Rust sidecar (24 tests via cargo test):
- Platform/arch detection, asset name construction
- Ready event deserialization (with extra fields tolerance)
- Path construction, version read/write, old version cleanup
- Zip extraction, SidecarManager lifecycle

CI workflow (.gitea/workflows/test.yml):
- Runs on push to main and PRs
- Three parallel jobs: Python, Frontend, Rust

Also fixes three bugs found during test planning:
- Settings: /api/check-updates -> GET /api/check-update
- Settings: /api/remote/login -> /api/login
- Settings: /api/remote/register -> /api/register

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:48:36 -07:00
Gitea Actions
9d78fce3f0 chore: bump version to 1.4.17 [skip ci] 2026-04-07 14:35:53 +00:00
Developer
a8de39de84 Fix OBS display and Start button not working
All checks were successful
Release / Bump version and tag (push) Successful in 12s
Sidecar Release / Bump sidecar version and tag (push) Successful in 6s
Three issues fixed:

1. Port mismatch: The sidecar reported the OBS port (8080) in the
   ready event but the frontend needs the API port (8081). Now reports
   the API port so WebSocket/REST connects to the right place.

2. Broadcast from wrong thread: Engine init fires state_changed from
   a background thread, but _broadcast_control used get_event_loop()
   which returns the wrong loop. Now captures the uvicorn event loop
   at startup via on_event("startup").

3. Missed ready state: If the engine finishes before the WebSocket
   client connects, the "ready" state_changed was never received.
   Added status polling (GET /api/status) on WebSocket connect that
   retries every 2s while appState is "initializing".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:35:41 -07:00
Gitea Actions
bc82584dff chore: bump version to 1.4.16 [skip ci] 2026-04-07 13:47:41 +00:00
Developer
4d0b4ee1c5 Add save confirmation and fix saveConfig -> updateConfig
All checks were successful
Release / Bump version and tag (push) Successful in 16s
- Fixed method call from saveConfig (doesn't exist) to updateConfig
- Save button shows "Saving..." while in progress, disabled during save
- Green "Settings saved!" message appears on success before closing
- Red error message shown on failure
- Cancel button disabled during save

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 06:41:01 -07:00
Gitea Actions
c73e9de0ac chore: bump version to 1.4.15 [skip ci] 2026-04-07 13:39:50 +00:00
Developer
288c6ad6a3 Fix BYOK settings: show Deepgram API key instead of server URL
All checks were successful
Release / Bump version and tag (push) Successful in 20s
BYOK mode connects directly to Deepgram (wss://api.deepgram.com),
so the server URL field was incorrect. Now:
- BYOK shows a Deepgram API Key field with link to console.deepgram.com
- Managed shows the Server URL field (for the transcription proxy)
- Local shows neither
- API key is saved as remote.byok_api_key in config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 06:39:31 -07:00
Gitea Actions
af8046f9b1 chore: bump version to 1.4.14 [skip ci] 2026-04-07 02:33:33 +00:00
Developer
6003885519 Fix configStore.loadConfig -> fetchConfig method name
All checks were successful
Release / Bump version and tag (push) Successful in 10s
The config store exports fetchConfig() but App.svelte was calling
the nonexistent loadConfig(), causing a TypeError that prevented
the sidecar from launching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:33:24 -07:00
Gitea Actions
8829846b53 chore: bump version to 1.4.13 [skip ci] 2026-04-07 02:20:51 +00:00
Developer
cf449d9338 Add Tauri ACL capabilities for event listener
All checks were successful
Release / Bump version and tag (push) Successful in 3s
Tauri v2 requires explicit permission grants. The SidecarSetup
component uses listen() from @tauri-apps/api/event to receive
download progress, which requires core:event:allow-listen.

Added default capability with core, event, shell, dialog, and
process permissions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:20:44 -07:00
Gitea Actions
5a6910834c chore: bump version to 1.4.12 [skip ci] 2026-04-07 02:16:01 +00:00
Developer
a6c7eb5d5e Fix blank screen: rename stores to .svelte.ts for rune support
All checks were successful
Release / Bump version and tag (push) Successful in 7s
Svelte 5 runes ($state, $derived, $effect) are only compiled in
.svelte and .svelte.ts files. The stores used runes in plain .ts
files, which meant $state was treated as an undefined function at
runtime, crashing the JS before anything rendered.

- Renamed backend.ts -> backend.svelte.ts
- Renamed config.ts -> config.svelte.ts
- Renamed transcriptions.ts -> transcriptions.svelte.ts
- Added .svelte.ts to Vite resolve extensions
- Added missing obsUrl/syncUrl getters to backend store

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:15:52 -07:00
Gitea Actions
135d5d534b chore: bump version to 1.4.11 [skip ci] 2026-04-07 02:06:55 +00:00
Developer
76f34fe17d Fix Windows tag passing: use env var instead of step outputs
All checks were successful
Release / Bump version and tag (push) Successful in 4s
Step outputs via GITHUB_OUTPUT are unreliable with act runner on
Windows (BOM encoding issues). Replaced with job-level env var
RELEASE_TAG set directly from inputs.tag, and checkout ref also
uses inputs.tag directly. Eliminated the Determine tag step
entirely — no intermediate output needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:06:50 -07:00
Gitea Actions
68ad31b6a7 chore: bump version to 1.4.10 [skip ci] 2026-04-07 02:01:11 +00:00
Developer
fcbe405e23 Fix Windows tag step: use PowerShell instead of bash
All checks were successful
Release / Bump version and tag (push) Successful in 4s
The act runner on Windows doesn't have bash available. Switched back
to PowerShell with the inputs.tag fallback chain. Uses Out-File for
GITHUB_OUTPUT instead of echo redirection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:00:34 -07:00
Gitea Actions
4adfd2adc6 chore: bump version to 1.4.9 [skip ci] 2026-04-07 01:59:23 +00:00
Developer
f3843d59f1 Fix empty tag in dispatched Windows builds
All checks were successful
Release / Bump version and tag (push) Successful in 7s
The workflow_dispatch input was accessed as github.event.inputs.tag
which can be empty depending on the Gitea runner. Now tries both
inputs.tag (modern syntax) and github.event.inputs.tag as fallback,
with a final fallback to the latest matching git tag.

Also switched Windows Determine-tag steps from PowerShell to bash
(via Git Bash) for consistency with the other platforms.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 18:59:17 -07:00
47 changed files with 3562 additions and 264 deletions

View File

@@ -13,20 +13,15 @@ jobs:
runs-on: ubuntu-latest
env:
NODE_VERSION: "20"
RELEASE_TAG: "${{ inputs.tag }}"
steps:
- name: Determine tag
id: tag
- name: Show tag
run: |
TAG="${{ github.event.inputs.tag }}"
if [ -z "$TAG" ]; then
TAG=$(git ls-remote --tags --sort=-v:refname origin 'refs/tags/v*' | head -1 | sed 's|.*refs/tags/||')
fi
echo "Building for tag: ${TAG}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "Building for tag: ${RELEASE_TAG}"
- uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag }}
ref: ${{ inputs.tag }}
- name: Set up Node.js
uses: actions/setup-node@v4
@@ -55,7 +50,7 @@ jobs:
run: |
sudo apt-get install -y jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${{ steps.tag.outputs.tag }}"
TAG="${RELEASE_TAG}"
echo "Release tag: ${TAG}"
echo "Waiting for release ${TAG} to be available..."

View File

@@ -13,20 +13,15 @@ jobs:
runs-on: macos-latest
env:
NODE_VERSION: "20"
RELEASE_TAG: "${{ inputs.tag }}"
steps:
- name: Determine tag
id: tag
- name: Show tag
run: |
TAG="${{ github.event.inputs.tag }}"
if [ -z "$TAG" ]; then
TAG=$(git ls-remote --tags --sort=-v:refname origin 'refs/tags/v*' | head -1 | sed 's|.*refs/tags/||')
fi
echo "Building for tag: ${TAG}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "Building for tag: ${RELEASE_TAG}"
- uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag }}
ref: ${{ inputs.tag }}
- name: Set up Node.js
uses: actions/setup-node@v4
@@ -53,7 +48,7 @@ jobs:
run: |
which jq || brew install jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${{ steps.tag.outputs.tag }}"
TAG="${RELEASE_TAG}"
echo "Release tag: ${TAG}"
echo "Waiting for release ${TAG} to be available..."

View File

@@ -7,27 +7,24 @@ on:
description: 'Release tag to build (e.g. v1.4.5)'
required: true
env:
NODE_VERSION: "20"
jobs:
build-windows:
name: Build App (Windows)
runs-on: windows-latest
env:
NODE_VERSION: "20"
RELEASE_TAG: "${{ inputs.tag }}"
steps:
- name: Determine tag
id: tag
- name: Show tag
shell: powershell
run: |
$TAG = "${{ github.event.inputs.tag }}"
if (-not $TAG) {
$TAG = (git ls-remote --tags --sort=-v:refname origin 'refs/tags/v*' | Select-Object -First 1) -replace '.*refs/tags/', ''
}
Write-Host "Building for tag: ${TAG}"
echo "tag=${TAG}" >> $env:GITHUB_OUTPUT
Write-Host "Building for tag: $env:RELEASE_TAG"
- uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag }}
ref: ${{ inputs.tag }}
- name: Set up Node.js
uses: actions/setup-node@v4
@@ -60,19 +57,24 @@ jobs:
run: |
$REPO_API = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
$Headers = @{ "Authorization" = "token $env:BUILD_TOKEN" }
$TAG = "${{ steps.tag.outputs.tag }}"
Write-Host "Release tag: ${TAG}"
$TAG = $env:RELEASE_TAG
Write-Host "Release tag: $TAG"
Write-Host "Waiting for release ${TAG} to be available..."
if (-not $TAG) {
Write-Host "ERROR: RELEASE_TAG is empty"
exit 1
}
Write-Host "Waiting for release $TAG to be available..."
$RELEASE_ID = $null
for ($i = 1; $i -le 30; $i++) {
try {
$release = Invoke-RestMethod -Uri "${REPO_API}/releases/tags/${TAG}" -Headers $Headers -ErrorAction Stop
$release = Invoke-RestMethod -Uri "$REPO_API/releases/tags/$TAG" -Headers $Headers -ErrorAction Stop
$RELEASE_ID = $release.id
if ($RELEASE_ID) {
Write-Host "Found release: ${TAG} (ID: ${RELEASE_ID})"
Write-Host "Found release: $TAG (ID: $RELEASE_ID)"
break
}
} catch {}
@@ -82,7 +84,7 @@ jobs:
}
if (-not $RELEASE_ID) {
Write-Host "ERROR: Failed to find release for tag ${TAG} after 30 attempts."
Write-Host "ERROR: Failed to find release for tag $TAG after 30 attempts."
exit 1
}
@@ -90,17 +92,17 @@ jobs:
$filename = $_.Name
$encodedName = [System.Uri]::EscapeDataString($filename)
$size = [math]::Round($_.Length / 1MB, 1)
Write-Host "Uploading ${filename} (${size} MB)..."
Write-Host "Uploading $filename ($size MB)..."
try {
$assets = Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets" -Headers $Headers
$assets = Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets" -Headers $Headers
$existing = $assets | Where-Object { $_.name -eq $filename }
if ($existing) {
Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets/$($existing.id)" -Method Delete -Headers $Headers
Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets/$($existing.id)" -Method Delete -Headers $Headers
}
} catch {}
$uploadUrl = "${REPO_API}/releases/${RELEASE_ID}/assets?name=${encodedName}"
$uploadUrl = "$REPO_API/releases/$RELEASE_ID/assets?name=$encodedName"
$result = curl.exe --fail --silent --show-error `
-X POST `
-H "Authorization: token $env:BUILD_TOKEN" `
@@ -108,8 +110,8 @@ jobs:
-T "$($_.FullName)" `
"$uploadUrl" 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "Upload successful: ${filename}"
Write-Host "Upload successful: $filename"
} else {
Write-Host "WARNING: Upload failed for ${filename}: ${result}"
Write-Host "WARNING: Upload failed for ${filename}: $result"
}
}

View File

@@ -0,0 +1,229 @@
name: Build Sidecar (Cloud)
on:
workflow_dispatch:
inputs:
tag:
description: 'Sidecar release tag to build (e.g. sidecar-v1.0.5)'
required: true
jobs:
build-cloud-linux:
name: Build Cloud Sidecar (Linux)
runs-on: ubuntu-latest
env:
PYTHON_VERSION: "3.11"
RELEASE_TAG: "${{ inputs.tag }}"
steps:
- name: Show tag
run: |
echo "Building cloud sidecar for tag ${RELEASE_TAG}"
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Set up Python
run: uv python install ${{ env.PYTHON_VERSION }}
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y portaudio19-dev
- name: Build cloud sidecar
env:
UV_NO_SOURCES: "1"
run: |
uv venv
uv pip install pyinstaller numpy sounddevice fastapi uvicorn websockets pydantic requests pyyaml packaging
.venv/bin/pyinstaller local-transcription-cloud.spec
- name: Package
run: |
cd dist/local-transcription-backend && zip -r ../../sidecar-linux-x86_64-cloud.zip .
- name: Upload to release
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
sudo apt-get install -y jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${RELEASE_TAG}"
for i in $(seq 1 30); do
RELEASE_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/tags/${TAG}" | jq -r '.id // empty')
if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then
echo "Found release ${TAG} (ID: ${RELEASE_ID})"
break
fi
echo "Attempt ${i}/30: waiting for release..."
sleep 10
done
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
echo "ERROR: Release not found"; exit 1
fi
for file in sidecar-*-cloud.zip; do
filename=$(basename "$file")
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
[ -n "${ASSET_ID}" ] && curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" "${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
curl -s -o /dev/null -w "Upload ${filename}: HTTP %{http_code}\n" -X POST \
-H "Authorization: token ${BUILD_TOKEN}" -H "Content-Type: application/octet-stream" \
-T "$file" "${REPO_API}/releases/${RELEASE_ID}/assets?name=${filename}"
done
build-cloud-windows:
name: Build Cloud Sidecar (Windows)
runs-on: windows-latest
env:
PYTHON_VERSION: "3.11"
RELEASE_TAG: "${{ inputs.tag }}"
steps:
- name: Show tag
shell: powershell
run: Write-Host "Building cloud sidecar for tag $env:RELEASE_TAG"
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: Install uv
shell: powershell
run: |
if (Get-Command uv -ErrorAction SilentlyContinue) {
Write-Host "uv already installed"
} else {
irm https://astral.sh/uv/install.ps1 | iex
$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
shell: powershell
run: uv python install ${{ env.PYTHON_VERSION }}
- name: Build cloud sidecar
shell: powershell
env:
UV_NO_SOURCES: "1"
run: |
uv venv
uv pip install pyinstaller numpy sounddevice fastapi uvicorn websockets pydantic requests pyyaml packaging
.venv\Scripts\pyinstaller.exe local-transcription-cloud.spec
- name: Package
shell: powershell
run: |
if (-not (Get-Command 7z -ErrorAction SilentlyContinue)) { choco install 7zip -y }
7z a -tzip -mx=5 sidecar-windows-x86_64-cloud.zip .\dist\local-transcription-backend\*
- name: Upload to release
shell: powershell
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
$REPO_API = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
$Headers = @{ "Authorization" = "token $env:BUILD_TOKEN" }
$TAG = $env:RELEASE_TAG
$RELEASE_ID = $null
for ($i = 1; $i -le 30; $i++) {
try {
$release = Invoke-RestMethod -Uri "$REPO_API/releases/tags/$TAG" -Headers $Headers -ErrorAction Stop
$RELEASE_ID = $release.id
if ($RELEASE_ID) { Write-Host "Found release $TAG (ID: $RELEASE_ID)"; break }
} catch {}
Write-Host "Attempt ${i}/30: waiting..."; Start-Sleep -Seconds 10
}
if (-not $RELEASE_ID) { Write-Host "ERROR: Release not found"; exit 1 }
Get-ChildItem -Path . -Filter "sidecar-*-cloud.zip" | ForEach-Object {
$fn = $_.Name; $enc = [System.Uri]::EscapeDataString($fn)
try {
$assets = Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets" -Headers $Headers
$existing = $assets | Where-Object { $_.name -eq $fn }
if ($existing) { Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets/$($existing.id)" -Method Delete -Headers $Headers }
} catch {}
curl.exe --fail -s -X POST -H "Authorization: token $env:BUILD_TOKEN" -H "Content-Type: application/octet-stream" -T "$($_.FullName)" "$REPO_API/releases/$RELEASE_ID/assets?name=$enc"
Write-Host "Uploaded $fn"
}
build-cloud-macos:
name: Build Cloud Sidecar (macOS)
runs-on: macos-latest
env:
PYTHON_VERSION: "3.11"
RELEASE_TAG: "${{ inputs.tag }}"
steps:
- name: Show tag
run: |
echo "Building cloud sidecar for tag ${RELEASE_TAG}"
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Set up Python
run: uv python install ${{ env.PYTHON_VERSION }}
- name: Install system dependencies
run: brew install portaudio
- name: Build cloud sidecar
env:
UV_NO_SOURCES: "1"
run: |
uv venv
uv pip install pyinstaller numpy sounddevice fastapi uvicorn websockets pydantic requests pyyaml packaging
.venv/bin/pyinstaller local-transcription-cloud.spec
- name: Package
run: |
cd dist/local-transcription-backend && zip -r ../../sidecar-macos-aarch64-cloud.zip .
- name: Upload to release
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
which jq || brew install jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${RELEASE_TAG}"
for i in $(seq 1 30); do
RELEASE_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/tags/${TAG}" | jq -r '.id // empty')
if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then
echo "Found release ${TAG} (ID: ${RELEASE_ID})"
break
fi
echo "Attempt ${i}/30: waiting for release..."
sleep 10
done
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
echo "ERROR: Release not found"; exit 1
fi
for file in sidecar-*-cloud.zip; do
filename=$(basename "$file")
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
[ -n "${ASSET_ID}" ] && curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" "${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
curl -s -o /dev/null -w "Upload ${filename}: HTTP %{http_code}\n" -X POST \
-H "Authorization: token ${BUILD_TOKEN}" -H "Content-Type: application/octet-stream" \
-T "$file" "${REPO_API}/releases/${RELEASE_ID}/assets?name=${filename}"
done

View File

@@ -13,20 +13,15 @@ jobs:
runs-on: ubuntu-latest
env:
PYTHON_VERSION: "3.11"
RELEASE_TAG: "${{ inputs.tag }}"
steps:
- name: Determine tag
id: tag
- name: Show tag
run: |
TAG="${{ github.event.inputs.tag }}"
if [ -z "$TAG" ]; then
TAG=$(git ls-remote --tags --sort=-v:refname origin 'refs/tags/sidecar-v*' | head -1 | sed 's|.*refs/tags/||')
fi
echo "Building for tag: ${TAG}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "Building for tag: ${RELEASE_TAG}"
- uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag }}
ref: ${{ inputs.tag }}
- name: Install uv
run: |
@@ -45,26 +40,16 @@ jobs:
sudo apt-get update
sudo apt-get install -y portaudio19-dev
- name: Build sidecar (CUDA)
run: |
uv sync --frozen || uv sync
uv run pyinstaller local-transcription-headless.spec
- name: Package sidecar (CUDA)
run: |
cd dist/local-transcription-backend && zip -r ../../sidecar-linux-x86_64-cuda.zip .
- name: Build sidecar (CPU)
env:
UV_NO_SOURCES: "1"
run: |
rm -rf dist/local-transcription-backend build/
uv pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu --force-reinstall
# Run pyinstaller directly from venv to prevent uv run from
# re-resolving torch back to the CUDA version via pyproject.toml sources
uv sync
.venv/bin/pyinstaller local-transcription-headless.spec
- name: Package sidecar (CPU)
run: |
cd dist/local-transcription-backend && zip -r ../../sidecar-linux-x86_64-cpu.zip .
cd dist/local-transcription-backend && zip -9 -r ../../sidecar-linux-x86_64-cpu.zip .
- name: Upload to sidecar release
env:
@@ -72,7 +57,7 @@ jobs:
run: |
sudo apt-get install -y jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${{ steps.tag.outputs.tag }}"
TAG="${RELEASE_TAG}"
echo "Waiting for sidecar release ${TAG} to be available..."
for i in $(seq 1 30); do

View File

@@ -13,20 +13,15 @@ jobs:
runs-on: macos-latest
env:
PYTHON_VERSION: "3.11"
RELEASE_TAG: "${{ inputs.tag }}"
steps:
- name: Determine tag
id: tag
- name: Show tag
run: |
TAG="${{ github.event.inputs.tag }}"
if [ -z "$TAG" ]; then
TAG=$(git ls-remote --tags --sort=-v:refname origin 'refs/tags/sidecar-v*' | head -1 | sed 's|.*refs/tags/||')
fi
echo "Building for tag: ${TAG}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "Building for tag: ${RELEASE_TAG}"
- uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag }}
ref: ${{ inputs.tag }}
- name: Install uv
run: |
@@ -63,7 +58,7 @@ jobs:
run: |
which jq || brew install jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${{ steps.tag.outputs.tag }}"
TAG="${RELEASE_TAG}"
echo "Waiting for sidecar release ${TAG} to be available..."
for i in $(seq 1 30); do

View File

@@ -13,21 +13,16 @@ jobs:
runs-on: windows-latest
env:
PYTHON_VERSION: "3.11"
RELEASE_TAG: "${{ inputs.tag }}"
steps:
- name: Determine tag
id: tag
- name: Show tag
shell: powershell
run: |
$TAG = "${{ github.event.inputs.tag }}"
if (-not $TAG) {
$TAG = (git ls-remote --tags --sort=-v:refname origin 'refs/tags/sidecar-v*' | Select-Object -First 1) -replace '.*refs/tags/', ''
}
Write-Host "Building for tag: ${TAG}"
echo "tag=${TAG}" >> $env:GITHUB_OUTPUT
Write-Host "Building for tag: $env:RELEASE_TAG"
- uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag }}
ref: ${{ inputs.tag }}
- name: Install uv
shell: powershell
@@ -36,7 +31,6 @@ jobs:
Write-Host "uv already installed: $(uv --version)"
} else {
irm https://astral.sh/uv/install.ps1 | iex
# Add both possible uv install locations to PATH
$uvPaths = @(
"$env:USERPROFILE\.local\bin",
"$env:USERPROFILE\.cargo\bin",
@@ -60,31 +54,18 @@ jobs:
choco install 7zip -y
}
- name: Build sidecar (CUDA)
shell: powershell
run: |
uv sync --frozen
if ($LASTEXITCODE -ne 0) { uv sync }
uv run pyinstaller local-transcription-headless.spec
- name: Package sidecar (CUDA)
shell: powershell
run: |
7z a -tzip -mx=5 sidecar-windows-x86_64-cuda.zip .\dist\local-transcription-backend\*
- name: Build sidecar (CPU)
shell: powershell
env:
UV_NO_SOURCES: "1"
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
# Run pyinstaller directly from venv to prevent uv run from
# re-resolving torch back to the CUDA version via pyproject.toml sources
uv sync
.venv\Scripts\pyinstaller.exe local-transcription-headless.spec
- name: Package sidecar (CPU)
shell: powershell
run: |
7z a -tzip -mx=5 sidecar-windows-x86_64-cpu.zip .\dist\local-transcription-backend\*
7z a -tzip -mx=9 sidecar-windows-x86_64-cpu.zip .\dist\local-transcription-backend\*
- name: Upload to sidecar release
shell: powershell
@@ -93,18 +74,24 @@ jobs:
run: |
$REPO_API = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
$Headers = @{ "Authorization" = "token $env:BUILD_TOKEN" }
$TAG = "${{ steps.tag.outputs.tag }}"
$TAG = $env:RELEASE_TAG
Write-Host "Release tag: $TAG"
Write-Host "Waiting for sidecar release ${TAG} to be available..."
if (-not $TAG) {
Write-Host "ERROR: RELEASE_TAG is empty"
exit 1
}
Write-Host "Waiting for sidecar release $TAG to be available..."
$RELEASE_ID = $null
for ($i = 1; $i -le 30; $i++) {
try {
$release = Invoke-RestMethod -Uri "${REPO_API}/releases/tags/${TAG}" -Headers $Headers -ErrorAction Stop
$release = Invoke-RestMethod -Uri "$REPO_API/releases/tags/$TAG" -Headers $Headers -ErrorAction Stop
$RELEASE_ID = $release.id
if ($RELEASE_ID) {
Write-Host "Found sidecar release: ${TAG} (ID: ${RELEASE_ID})"
Write-Host "Found sidecar release: $TAG (ID: $RELEASE_ID)"
break
}
} catch {}
@@ -114,7 +101,7 @@ jobs:
}
if (-not $RELEASE_ID) {
Write-Host "ERROR: Failed to find sidecar release for tag ${TAG} after 30 attempts."
Write-Host "ERROR: Failed to find sidecar release for tag $TAG after 30 attempts."
exit 1
}
@@ -122,17 +109,17 @@ jobs:
$filename = $_.Name
$encodedName = [System.Uri]::EscapeDataString($filename)
$size = [math]::Round($_.Length / 1MB, 1)
Write-Host "Uploading ${filename} (${size} MB)..."
Write-Host "Uploading $filename ($size MB)..."
try {
$assets = Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets" -Headers $Headers
$assets = Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets" -Headers $Headers
$existing = $assets | Where-Object { $_.name -eq $filename }
if ($existing) {
Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets/$($existing.id)" -Method Delete -Headers $Headers
Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets/$($existing.id)" -Method Delete -Headers $Headers
}
} catch {}
$uploadUrl = "${REPO_API}/releases/${RELEASE_ID}/assets?name=${encodedName}"
$uploadUrl = "$REPO_API/releases/$RELEASE_ID/assets?name=$encodedName"
$result = curl.exe --fail --silent --show-error `
-X POST `
-H "Authorization: token $env:BUILD_TOKEN" `
@@ -140,8 +127,8 @@ jobs:
-T "$($_.FullName)" `
"$uploadUrl" 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "Upload successful: ${filename}"
Write-Host "Upload successful: $filename"
} else {
Write-Host "WARNING: Upload failed for ${filename}: ${result}"
Write-Host "WARNING: Upload failed for ${filename}: $result"
}
}

View File

@@ -1,13 +1,40 @@
name: Release
on:
push:
branches: [main]
workflow_dispatch:
jobs:
test:
name: Run Tests
if: "!contains(github.event.head_commit.message, '[skip ci]')"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install npm deps
run: npm ci
- name: Frontend tests
run: npx vitest run
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Python tests
run: |
uv venv .testvenv
VIRTUAL_ENV=.testvenv uv pip install pytest httpx pytest-asyncio anyio fastapi pydantic pyyaml uvicorn requests
.testvenv/bin/python -m pytest backend/tests/ client/tests/ -v --tb=short
bump-version:
name: Bump version and tag
if: "!contains(github.event.head_commit.message, '[skip ci]')"
needs: test
runs-on: ubuntu-latest
outputs:
new_version: ${{ steps.bump.outputs.new_version }}
@@ -82,10 +109,14 @@ jobs:
for workflow in build-app-linux.yml build-app-windows.yml build-app-macos.yml; do
echo "Dispatching ${workflow} for ${TAG}..."
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
HTTP_CODE=$(curl -s -w "%{http_code}" -o /tmp/dispatch_resp.txt -X POST \
-H "Authorization: token ${BUILD_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"ref\": \"main\", \"inputs\": {\"tag\": \"${TAG}\"}}" \
"${REPO_API}/actions/workflows/${workflow}/dispatches")
echo " -> HTTP ${HTTP_CODE}"
[ "$HTTP_CODE" != "204" ] && cat /tmp/dispatch_resp.txt && echo ""
done
# NOTE: Automatic cleanup disabled -- it races with async builds.
# Clean up old releases manually from the Gitea UI when needed.

View File

@@ -1,19 +1,30 @@
name: Sidecar Release
on:
push:
branches: [main]
paths:
- 'client/**'
- 'server/**'
- 'backend/**'
- 'pyproject.toml'
- 'local-transcription-headless.spec'
workflow_dispatch:
jobs:
test:
name: Run Tests
if: "!contains(github.event.head_commit.message, '[skip ci]')"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Python tests
run: |
uv venv .testvenv
VIRTUAL_ENV=.testvenv uv pip install pytest httpx pytest-asyncio anyio fastapi pydantic pyyaml uvicorn requests
.testvenv/bin/python -m pytest backend/tests/ client/tests/ -v --tb=short
bump-sidecar-version:
name: Bump sidecar version and tag
needs: test
if: "!contains(github.event.head_commit.message, '[skip ci]')"
runs-on: ubuntu-latest
outputs:
@@ -62,8 +73,6 @@ jobs:
echo "New sidecar version: ${NEW_VERSION}"
sed -i "s/^version = \"${CURRENT}\"/version = \"${NEW_VERSION}\"/" pyproject.toml
sed -i "s/__version__ = \"${CURRENT}\"/__version__ = \"${NEW_VERSION}\"/" version.py
sed -i "s/__version_info__ = .*/__version_info__ = (${MAJOR}, ${MINOR}, ${NEW_PATCH})/" version.py
echo "version=${NEW_VERSION}" >> $GITHUB_OUTPUT
echo "tag=sidecar-v${NEW_VERSION}" >> $GITHUB_OUTPUT
@@ -75,7 +84,7 @@ jobs:
run: |
NEW_VERSION="${{ steps.bump.outputs.version }}"
TAG="${{ steps.bump.outputs.tag }}"
git add pyproject.toml version.py
git add pyproject.toml
git commit -m "chore: bump sidecar version to ${NEW_VERSION} [skip ci]"
git tag "${TAG}"
@@ -109,7 +118,7 @@ jobs:
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${{ steps.bump.outputs.tag }}"
for workflow in build-sidecar-linux.yml build-sidecar-windows.yml build-sidecar-macos.yml; do
for workflow in build-sidecar-linux.yml build-sidecar-windows.yml build-sidecar-macos.yml build-sidecar-cloud.yml; do
echo "Dispatching ${workflow} for ${TAG}..."
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${BUILD_TOKEN}" \
@@ -118,3 +127,6 @@ jobs:
"${REPO_API}/actions/workflows/${workflow}/dispatches")
echo " -> HTTP ${HTTP_CODE}"
done
# NOTE: Automatic cleanup disabled -- it races with async builds.
# Clean up old releases manually from the Gitea UI when needed.

66
.gitea/workflows/test.yml Normal file
View File

@@ -0,0 +1,66 @@
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
python-tests:
name: Python Backend Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
run: |
if command -v uv &> /dev/null; then
echo "uv already installed: $(uv --version)"
else
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
fi
- name: Run pytest
run: |
uv venv .testvenv
VIRTUAL_ENV=.testvenv uv pip install pytest httpx pytest-asyncio anyio fastapi pydantic pyyaml uvicorn requests
.testvenv/bin/python -m pytest backend/tests/ client/tests/ -v --tb=short
frontend-tests:
name: Frontend Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Run Vitest
run: npx vitest run
rust-tests:
name: Rust Sidecar Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install Tauri system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Run cargo test
working-directory: src-tauri
run: cargo test

View File

@@ -99,11 +99,19 @@ class APIServer:
self.controller.on_credits_low = on_credits_low
def set_event_loop(self, loop: asyncio.AbstractEventLoop):
"""Set the event loop used for broadcasting (call from uvicorn startup)."""
self._event_loop = loop
def _broadcast_control(self, data: dict):
"""Send a message to all connected /ws/control clients."""
if not self.control_connections:
return
loop = getattr(self, '_event_loop', None)
if loop is None:
return
message = json.dumps(data)
disconnected = []
@@ -111,7 +119,7 @@ class APIServer:
try:
asyncio.run_coroutine_threadsafe(
ws.send_text(message),
asyncio.get_event_loop(),
loop,
)
except Exception:
disconnected.append(ws)
@@ -124,6 +132,10 @@ class APIServer:
app = self.app
ctrl = self.controller
@app.on_event("startup")
async def on_startup():
self.set_event_loop(asyncio.get_event_loop())
# ── Status ─────────────────────────────────────────────
@app.get("/api/status")

View File

@@ -18,13 +18,18 @@ import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from client.config import Config
from client.device_utils import DeviceManager
from client.transcription_engine_realtime import RealtimeTranscriptionEngine, TranscriptionResult
from client.models import TranscriptionResult
from client.deepgram_transcription import DeepgramTranscriptionEngine
from client.server_sync import ServerSyncClient
from server.web_display import TranscriptionWebServer
from version import __version__
# Heavy imports (torch, RealtimeSTT, faster-whisper) are deferred so
# the cloud-only sidecar build can exclude them entirely.
# Imported lazily in _initialize_engine() when remote.mode == "local".
RealtimeTranscriptionEngine = None
DeviceManager = None
class AppState:
"""Enum-like class for application states."""
@@ -89,7 +94,24 @@ class AppController:
def __init__(self, config: Optional[Config] = None):
self.config = config or Config()
self.device_manager = DeviceManager()
# DeviceManager is only needed for local Whisper mode.
# Lazy-import to keep the cloud-only sidecar lightweight.
global DeviceManager
if DeviceManager is None:
try:
from client.device_utils import DeviceManager as _DM
DeviceManager = _DM
except ImportError:
DeviceManager = None
self.device_manager = DeviceManager() if DeviceManager else None
self.is_cloud_only = DeviceManager is None
# If this is the cloud-only sidecar and mode is still "local",
# auto-switch to "byok" so the engine doesn't try to load Whisper.
if self.is_cloud_only and self.config.get('remote.mode', 'local') == 'local':
self.config.set('remote.mode', 'byok')
# State
self._state = AppState.INITIALIZING
@@ -243,15 +265,12 @@ class AppController:
def _initialize_engine(self):
"""Initialize the transcription engine in a background thread."""
device_config = self.config.get('transcription.device', 'auto')
self.device_manager.set_device(device_config)
audio_device_str = self.config.get('audio.input_device', 'default')
audio_device = None if audio_device_str == 'default' else int(audio_device_str)
model = self.config.get('transcription.model', 'base.en')
language = self.config.get('transcription.language', 'en')
device = self.device_manager.get_device_for_whisper()
device_config = self.config.get('transcription.device', 'auto')
compute_type = self.config.get('transcription.compute_type', 'default')
self.current_model_size = model
@@ -284,6 +303,27 @@ class AppController:
self.transcription_engine.set_error_callback(self._on_remote_error)
self.transcription_engine.set_credits_low_callback(self._on_credits_low)
else:
# Lazy-import heavy local transcription dependencies
global RealtimeTranscriptionEngine
if RealtimeTranscriptionEngine is None:
try:
from client.transcription_engine_realtime import RealtimeTranscriptionEngine as _RTE
RealtimeTranscriptionEngine = _RTE
except ImportError:
# Cloud-only sidecar -- local engine not available
self._set_state(
AppState.ERROR,
"Local transcription not available in this build. "
"Please switch to Cloud (Deepgram) mode in Settings."
)
return
if self.device_manager:
self.device_manager.set_device(device_config)
device = self.device_manager.get_device_for_whisper()
else:
device = "cpu"
self.transcription_engine = RealtimeTranscriptionEngine(
model=model,
device=device,
@@ -333,7 +373,15 @@ class AppController:
self._set_state(AppState.READY, f"Ready | Device: {device_display}")
else:
self._set_state(AppState.ERROR, message)
# Cloud sidecar with no API key -- show helpful setup message
# instead of a scary error. The user needs to enter their key.
if self.is_cloud_only:
self._set_state(
AppState.READY,
"Setup needed: Open Settings > Remote Transcription > enter your Deepgram API key"
)
else:
self._set_state(AppState.ERROR, message)
# ── Transcription Control ──────────────────────────────────────
@@ -577,12 +625,18 @@ class AppController:
if self.config.get('server_sync.enabled', False):
self._start_server_sync()
# Check if model/device changed
# Check if model/device/remote mode changed -- any of these require
# a full engine reload since they change which engine class is used
new_model = self.config.get('transcription.model', 'base.en')
new_device = self.config.get('transcription.device', 'auto')
new_remote_mode = self.config.get('remote.mode', 'local')
current_remote_mode = 'local'
if self.transcription_engine:
current_remote_mode = getattr(self.transcription_engine, 'mode', 'local')
engine_reload_needed = (
self.current_model_size != new_model
or self.current_device_config != new_device
or current_remote_mode != new_remote_mode
)
if engine_reload_needed:
@@ -596,7 +650,7 @@ class AppController:
host = self.config.get('web_server.host', '127.0.0.1')
port = self.actual_web_port or self.config.get('web_server.port', 8080)
device_info = self.device_manager.get_device_info()
device_info = self.device_manager.get_device_info() if self.device_manager else []
remote_mode = self.config.get('remote.mode', 'local')
if remote_mode in ('managed', 'byok') and self.transcription_engine:
@@ -640,10 +694,13 @@ class AppController:
def get_compute_devices(self) -> list[dict]:
"""List available compute devices."""
device_info = self.device_manager.get_device_info()
devices = [{"id": "auto", "name": "Auto-detect"}]
for dev_id, dev_name in device_info:
devices.append({"id": dev_id, "name": dev_name})
if self.device_manager:
device_info = self.device_manager.get_device_info()
for dev_id, dev_name in device_info:
devices.append({"id": dev_id, "name": dev_name})
else:
devices.append({"id": "cloud", "name": "Cloud (Deepgram)"})
return devices
# ── Update Checking ────────────────────────────────────────────

View File

@@ -88,11 +88,16 @@ def main():
# Create API server wrapping the controller
api_server = APIServer(controller)
# Determine actual port (web server may have shifted if port was in use)
actual_port = controller.actual_web_port or args.port
# OBS display runs on the configured port, API server on port+1
obs_port = controller.actual_web_port or args.port
api_port = obs_port + 1
# Print ready event so Tauri can discover the port
print(json.dumps({"event": "ready", "port": actual_port}), flush=True)
# Print ready event so Tauri can discover the API port
print(json.dumps({
"event": "ready",
"port": api_port,
"obs_port": obs_port,
}), flush=True)
# Run the API server (blocks)
import uvicorn
@@ -104,7 +109,7 @@ def main():
uvicorn.run(
api_server.app,
host=args.host,
port=actual_port + 1, # API on port+1, OBS display on the main port
port=api_port,
log_level="error",
access_log=False,
)

View File

159
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,159 @@
"""Shared fixtures for backend tests.
Heavy third-party modules (torch, sounddevice, numpy, RealtimeSTT, etc.) are
stubbed at the *sys.modules* level before any backend code is imported. This
lets the test suite run on a plain Python install without GPU drivers, audio
hardware, or heavyweight ML libraries.
"""
import sys
import types
from pathlib import Path
from unittest.mock import MagicMock
import pytest
# ── Project root on sys.path ────────────────────────────────────────
project_root = Path(__file__).resolve().parent.parent.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
# ── Stub heavy modules before anything imports them ─────────────────
def _stub(name: str) -> types.ModuleType:
"""Create a stub module and register it in sys.modules if not already present."""
if name in sys.modules:
return sys.modules[name]
mod = types.ModuleType(name)
sys.modules[name] = mod
return mod
# numpy -- must behave like a real module for `import numpy as np`
_np = _stub("numpy")
_np.float32 = float
_np.float64 = float
_np.int16 = int
_np.ndarray = MagicMock
_np.array = MagicMock(return_value=MagicMock())
_np.zeros = MagicMock(return_value=MagicMock())
_np.frombuffer = MagicMock(return_value=MagicMock())
# torch + sub-modules
_torch = _stub("torch")
_torch.cuda = MagicMock()
_torch.cuda.is_available = MagicMock(return_value=False)
_torch.backends = MagicMock()
_torch.backends.mps = MagicMock()
_torch.backends.mps.is_available = MagicMock(return_value=False)
_stub("torch.cuda")
_stub("torch.backends")
_stub("torch.backends.mps")
_stub("torchaudio")
# sounddevice
_sd = _stub("sounddevice")
_sd.query_devices = MagicMock(return_value=[])
# RealtimeSTT (imported by transcription_engine_realtime)
_rtstt = _stub("RealtimeSTT")
_rtstt.AudioToTextRecorder = MagicMock
# faster_whisper (sometimes imported transitively)
_stub("faster_whisper")
# noisereduce
_stub("noisereduce")
# scipy
_scipy = _stub("scipy")
_stub("scipy.signal")
_stub("scipy.io")
_stub("scipy.io.wavfile")
# webrtcvad
_stub("webrtcvad")
# openwakeword
_stub("openwakeword")
# pvporcupine
_stub("pvporcupine")
# PySide6 (should not be needed, but just in case)
_stub("PySide6")
_stub("PySide6.QtWidgets")
_stub("PySide6.QtCore")
_stub("PySide6.QtGui")
# websockets
_ws = _stub("websockets")
_ws.connect = MagicMock
# deepgram (cloud transcription)
_stub("deepgram")
# ── Fixtures ────────────────────────────────────────────────────────
@pytest.fixture
def mock_config(tmp_path):
"""Return a Config object backed by a temporary file.
This avoids touching the real user config at ~/.local-transcription/.
"""
config_file = tmp_path / "test_config.yaml"
from client.config import Config
config = Config(config_path=str(config_file))
return config
@pytest.fixture
def controller(mock_config):
"""Return an AppController wired to *mock_config* without starting heavy
subsystems (engine, web server, device manager).
The transcription engine, web server thread, and DeviceManager are all
replaced with lightweight mocks so the test suite can run without a GPU,
audio hardware, or a free network port.
"""
from unittest.mock import patch
with patch("backend.app_controller.DeviceManager") as MockDM, \
patch("backend.app_controller.RealtimeTranscriptionEngine"), \
patch("backend.app_controller.DeepgramTranscriptionEngine"), \
patch("backend.app_controller.TranscriptionWebServer"), \
patch("backend.app_controller.ServerSyncClient"):
# DeviceManager stub
dm_instance = MagicMock()
dm_instance.get_device_info.return_value = [("cpu", "CPU")]
dm_instance.get_device_for_whisper.return_value = "cpu"
MockDM.return_value = dm_instance
from backend.app_controller import AppController
ctrl = AppController(config=mock_config)
yield ctrl
@pytest.fixture
def api_client(controller):
"""Return an httpx.AsyncClient speaking ASGI to the APIServer's FastAPI app.
Usage in tests::
async def test_something(api_client):
resp = await api_client.get("/api/status")
assert resp.status_code == 200
"""
from backend.api_server import APIServer
import httpx
api = APIServer(controller)
transport = httpx.ASGITransport(app=api.app)
client = httpx.AsyncClient(transport=transport, base_url="http://testserver")
return client

View File

@@ -0,0 +1,150 @@
"""Tests for backend.api_server.APIServer REST endpoints."""
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import pytest_asyncio
# Ensure project root is on path
project_root = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(project_root))
# ── GET /api/status ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_status(api_client):
resp = await api_client.get("/api/status")
assert resp.status_code == 200
data = resp.json()
assert "state" in data
assert "is_transcribing" in data
assert "version" in data
assert "web_server" in data
# ── GET /api/config ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_config(api_client):
resp = await api_client.get("/api/config")
assert resp.status_code == 200
data = resp.json()
# The config should be a dict (the raw config mapping)
assert isinstance(data, dict)
# ── PUT /api/config ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_put_config(api_client, controller):
"""Updating config via PUT should persist and return success."""
# Patch reload_engine to avoid heavy lifting
controller.reload_engine = MagicMock(return_value=(True, "ok"))
controller.current_model_size = "base.en"
controller.current_device_config = "auto"
controller.config.set("transcription.model", "base.en")
controller.config.set("transcription.device", "auto")
resp = await api_client.put(
"/api/config",
json={"settings": {"display.font_size": 24}},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
# Verify the value was actually saved
assert controller.config.get("display.font_size") == 24
# ── POST /api/start (engine not ready) ─────────────────────────────
@pytest.mark.asyncio
async def test_start_when_not_ready(api_client, controller):
"""Starting transcription without an engine should return 400."""
controller.transcription_engine = None
resp = await api_client.post("/api/start")
assert resp.status_code == 400
# ── POST /api/clear ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_clear(api_client, controller):
from client.models import TranscriptionResult
from datetime import datetime
controller.transcriptions = [
TranscriptionResult(text="One", is_final=True, timestamp=datetime.now(), user_name="U"),
]
resp = await api_client.post("/api/clear")
assert resp.status_code == 200
body = resp.json()
assert body["cleared"] == 1
assert len(controller.transcriptions) == 0
# ── GET /api/audio-devices ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_audio_devices(api_client, controller):
"""Audio devices endpoint should return a list, even when mocked."""
# Mock sounddevice so the test works without audio hardware
with patch("backend.app_controller.AppController.get_audio_devices",
return_value=[{"index": 0, "name": "Mock Mic"}]):
resp = await api_client.get("/api/audio-devices")
assert resp.status_code == 200
data = resp.json()
assert "devices" in data
assert len(data["devices"]) >= 1
# ── GET /api/compute-devices ────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_compute_devices(api_client, controller):
resp = await api_client.get("/api/compute-devices")
assert resp.status_code == 200
data = resp.json()
assert "devices" in data
# At minimum we get the "Auto-detect" entry
assert any(d["id"] == "auto" for d in data["devices"])
# ── GET /api/check-update ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_check_update(api_client, controller):
"""check-update should return a dict with an 'available' key."""
with patch.object(controller, "check_for_updates",
return_value={"available": False, "current_version": "1.0.0"}):
resp = await api_client.get("/api/check-update")
assert resp.status_code == 200
data = resp.json()
assert "available" in data
# ── GET /api/version ────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_version(api_client):
resp = await api_client.get("/api/version")
assert resp.status_code == 200
data = resp.json()
assert "version" in data
# Should be a non-empty string
assert isinstance(data["version"], str)
assert len(data["version"]) > 0

View File

@@ -0,0 +1,181 @@
"""Tests for backend.app_controller.AppController."""
import sys
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Ensure project root is on path
project_root = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(project_root))
from backend.app_controller import AppState
# ── basic state ─────────────────────────────────────────────────────
def test_initial_state(controller):
"""A freshly constructed controller should be INITIALIZING and not transcribing."""
assert controller.state == AppState.INITIALIZING
assert controller.is_transcribing is False
# ── start / stop ────────────────────────────────────────────────────
def test_start_transcription_without_engine(controller):
"""Starting transcription before the engine is ready should fail gracefully."""
controller.transcription_engine = None
success, message = controller.start_transcription()
assert success is False
assert "not ready" in message.lower()
def test_start_stop_cycle(controller):
"""Full start -> stop cycle with a mocked engine that reports ready."""
engine = MagicMock()
engine.is_ready.return_value = True
engine.start_recording.return_value = True
controller.transcription_engine = engine
# Start
ok, msg = controller.start_transcription()
assert ok is True
assert controller.is_transcribing is True
assert controller.state == AppState.TRANSCRIBING
# Stop
ok, msg = controller.stop_transcription()
assert ok is True
assert controller.is_transcribing is False
engine.stop_recording.assert_called_once()
def test_double_start_rejected(controller):
"""Calling start_transcription twice should reject the second call."""
engine = MagicMock()
engine.is_ready.return_value = True
engine.start_recording.return_value = True
controller.transcription_engine = engine
controller.start_transcription()
success, message = controller.start_transcription()
assert success is False
assert "already" in message.lower()
# ── transcription storage ───────────────────────────────────────────
def test_clear_transcriptions(controller):
"""clear_transcriptions should empty the list and return the count."""
from client.models import TranscriptionResult
controller.transcriptions = [
TranscriptionResult(text="Hello", is_final=True, timestamp=datetime.now(), user_name="Alice"),
TranscriptionResult(text="World", is_final=True, timestamp=datetime.now(), user_name="Bob"),
]
count = controller.clear_transcriptions()
assert count == 2
assert len(controller.transcriptions) == 0
def test_get_transcriptions_text_with_timestamps(controller):
"""get_transcriptions_text should include [HH:MM:SS] prefixes when requested."""
from client.models import TranscriptionResult
ts = datetime(2025, 1, 15, 10, 30, 45)
controller.transcriptions = [
TranscriptionResult(text="Test line", is_final=True, timestamp=ts, user_name="User"),
]
text = controller.get_transcriptions_text(include_timestamps=True)
assert "[10:30:45]" in text
assert "User:" in text
assert "Test line" in text
# ── settings / engine reload ────────────────────────────────────────
def test_apply_settings_triggers_reload_on_model_change(controller):
"""Changing the transcription model should trigger an engine reload."""
controller.current_model_size = "base.en"
controller.current_device_config = "auto"
# Patch reload_engine so it doesn't actually try to spin up threads
controller.reload_engine = MagicMock(return_value=(True, "reloaded"))
reloaded, msg = controller.apply_settings({
"transcription.model": "small.en",
})
assert reloaded is True
controller.reload_engine.assert_called_once()
def test_apply_settings_no_reload_when_same(controller):
"""If model and device haven't changed, no reload should happen."""
controller.current_model_size = "base.en"
controller.current_device_config = "auto"
# Ensure config returns the same values
controller.config.set("transcription.model", "base.en")
controller.config.set("transcription.device", "auto")
controller.reload_engine = MagicMock(return_value=(True, "reloaded"))
reloaded, msg = controller.apply_settings({
"display.font_size": 20,
})
assert reloaded is False
controller.reload_engine.assert_not_called()
# ── transcription callbacks ─────────────────────────────────────────
def test_on_final_transcription_callback_fires(controller):
"""_on_final_transcription should append and invoke on_transcription callback."""
from client.models import TranscriptionResult
received = []
controller.on_transcription = lambda data: received.append(data)
controller.is_transcribing = True
controller._set_state(AppState.TRANSCRIBING)
result = TranscriptionResult(
text="Hello world",
is_final=True,
timestamp=datetime.now(),
user_name="Tester",
)
controller._on_final_transcription(result)
assert len(controller.transcriptions) == 1
assert len(received) == 1
assert received[0]["text"] == "Hello world"
assert received[0]["user_name"] == "Tester"
assert received[0]["is_preview"] is False
def test_on_final_transcription_ignored_when_not_transcribing(controller):
"""If the controller is not in transcribing state the callback should be a no-op."""
from client.models import TranscriptionResult
controller.is_transcribing = False
result = TranscriptionResult(
text="Should be ignored",
is_final=True,
timestamp=datetime.now(),
user_name="Ghost",
)
controller._on_final_transcription(result)
assert len(controller.transcriptions) == 0

View File

@@ -0,0 +1,56 @@
"""Tests for backend.main_headless ready-event JSON format."""
import sys
from pathlib import Path
import pytest
# Ensure project root is on path
project_root = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(project_root))
def test_ready_event_reports_api_port_not_obs_port():
"""The ready JSON printed by main_headless must set ``port`` to
``obs_port + 1`` (the API port), not the OBS display port.
From main_headless.py::
obs_port = controller.actual_web_port or args.port
api_port = obs_port + 1
print(json.dumps({
"event": "ready",
"port": api_port,
"obs_port": obs_port,
}), flush=True)
We verify this contract by reading the source and checking the
structure directly (running main() would start a real server).
"""
import ast
import textwrap
source_path = project_root / "backend" / "main_headless.py"
source = source_path.read_text()
# Verify the key relationships exist in the source:
# 1. api_port = obs_port + 1
assert "api_port = obs_port + 1" in source, (
"Expected `api_port = obs_port + 1` in main_headless.py"
)
# 2. The ready event JSON uses api_port for "port", not obs_port
assert '"port": api_port' in source or "'port': api_port" in source, (
"The ready event should report api_port as 'port'"
)
# 3. obs_port is also included separately
assert '"obs_port": obs_port' in source or "'obs_port': obs_port" in source, (
"The ready event should also include 'obs_port'"
)
# 4. Verify the event name
assert '"event": "ready"' in source or "'event': 'ready'" in source, (
"The ready event should have event='ready'"
)

View File

@@ -17,7 +17,7 @@ from datetime import datetime
from queue import Queue, Empty
from typing import Optional, Callable
from client.transcription_engine_realtime import TranscriptionResult
from client.models import TranscriptionResult
logger = logging.getLogger(__name__)
@@ -67,7 +67,7 @@ class DeepgramTranscriptionEngine:
# Audio parameters
self.sample_rate: int = 16000
self.channels: int = 1
self.blocksize: int = 4096
self.blocksize: int = 1024 # ~64ms chunks for lower latency streaming
# Callbacks
self.realtime_callback: Optional[Callable[[TranscriptionResult], None]] = None
@@ -314,6 +314,8 @@ class DeepgramTranscriptionEngine:
f"model={self.deepgram_model}"
f"&language={self.language}"
"&interim_results=true"
"&punctuate=true"
"&smart_format=true"
"&encoding=linear16"
f"&sample_rate={self.sample_rate}"
f"&channels={self.channels}"
@@ -370,10 +372,16 @@ class DeepgramTranscriptionEngine:
async def _send_loop(self):
"""Drain the audio queue and push raw PCM bytes over the WebSocket."""
loop = asyncio.get_event_loop()
while not self._stop_event.is_set():
try:
pcm_bytes = self._audio_queue.get(timeout=0.1)
except Empty:
# Use run_in_executor to avoid blocking the async event loop
# (which would stall the receive loop and delay transcriptions)
pcm_bytes = await asyncio.wait_for(
loop.run_in_executor(None, lambda: self._audio_queue.get(timeout=0.5)),
timeout=1.0,
)
except (Empty, asyncio.TimeoutError):
continue
try:

29
client/models.py Normal file
View File

@@ -0,0 +1,29 @@
"""Shared data models used across transcription engines."""
from datetime import datetime
class TranscriptionResult:
"""Represents a transcription result."""
def __init__(self, text: str, is_final: bool, timestamp: datetime, user_name: str = ""):
"""
Initialize transcription result.
Args:
text: Transcribed text
is_final: Whether this is a final transcription or realtime preview
timestamp: Timestamp of transcription
user_name: Name of the user/speaker
"""
self.text = text.strip()
self.is_final = is_final
self.timestamp = timestamp
self.user_name = user_name
def __repr__(self) -> str:
time_str = self.timestamp.strftime("%H:%M:%S")
prefix = "[FINAL]" if self.is_final else "[PREVIEW]"
if self.user_name and self.user_name.strip():
return f"{prefix} [{time_str}] {self.user_name}: {self.text}"
return f"{prefix} [{time_str}] {self.text}"

0
client/tests/__init__.py Normal file
View File

View File

@@ -0,0 +1,78 @@
"""Tests for client.config.Config."""
import sys
from pathlib import Path
import pytest
# Ensure project root is on path
project_root = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(project_root))
from client.config import Config
@pytest.fixture
def cfg(tmp_path):
"""A Config backed by a temp file so we never touch the real user config."""
return Config(config_path=str(tmp_path / "test_config.yaml"))
# ── dot-notation get ────────────────────────────────────────────────
def test_dot_notation_get(cfg):
"""Config.get should traverse nested dicts using dot-separated keys."""
cfg.config = {"audio": {"sample_rate": 16000}}
assert cfg.get("audio.sample_rate") == 16000
# ── dot-notation set ────────────────────────────────────────────────
def test_dot_notation_set(cfg):
"""Config.set should create/update nested values via dot-separated keys."""
cfg.set("audio.sample_rate", 44100)
assert cfg.config["audio"]["sample_rate"] == 44100
# Also readable via .get
assert cfg.get("audio.sample_rate") == 44100
# ── missing key returns default ─────────────────────────────────────
def test_missing_key_returns_default(cfg):
"""Accessing a nonexistent key should return the supplied default."""
assert cfg.get("nonexistent.path", "fallback") == "fallback"
assert cfg.get("also.missing") is None # default default is None
# ── nested set creates intermediate dicts ───────────────────────────
def test_nested_set_creates_path(cfg):
"""Setting a deeply nested key should create all intermediate dicts."""
cfg.config = {}
cfg.set("a.b.c.d", 42)
assert cfg.config["a"]["b"]["c"]["d"] == 42
assert cfg.get("a.b.c.d") == 42
# ── save and reload round-trip ──────────────────────────────────────
def test_save_and_reload(tmp_path):
"""Values persisted via save() should survive a fresh Config load."""
config_file = str(tmp_path / "roundtrip.yaml")
# Create and populate
cfg1 = Config(config_path=config_file)
cfg1.set("user.name", "TestUser")
cfg1.set("transcription.model", "tiny.en")
# Load a fresh instance from the same file
cfg2 = Config(config_path=config_file)
assert cfg2.get("user.name") == "TestUser"
assert cfg2.get("transcription.model") == "tiny.en"

View File

@@ -8,30 +8,8 @@ from threading import Lock
import logging
class TranscriptionResult:
"""Represents a transcription result."""
def __init__(self, text: str, is_final: bool, timestamp: datetime, user_name: str = ""):
"""
Initialize transcription result.
Args:
text: Transcribed text
is_final: Whether this is a final transcription or realtime preview
timestamp: Timestamp of transcription
user_name: Name of the user/speaker
"""
self.text = text.strip()
self.is_final = is_final
self.timestamp = timestamp
self.user_name = user_name
def __repr__(self) -> str:
time_str = self.timestamp.strftime("%H:%M:%S")
prefix = "[FINAL]" if self.is_final else "[PREVIEW]"
if self.user_name and self.user_name.strip():
return f"{prefix} [{time_str}] {self.user_name}: {self.text}"
return f"{prefix} [{time_str}] {self.text}"
# Re-export TranscriptionResult from the shared models module for backward compatibility
from client.models import TranscriptionResult # noqa: F401
def to_dict(self) -> dict:
"""Convert to dictionary."""

View File

@@ -0,0 +1,152 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller spec file for cloud-only Local Transcription backend.
This builds a lightweight sidecar (~50MB) that only supports Deepgram
cloud transcription (managed + BYOK). No local Whisper models, no
PyTorch, no CUDA -- just audio capture and WebSocket streaming.
"""
import sys
import os
block_cipher = None
is_windows = sys.platform == 'win32'
from PyInstaller.utils.hooks import collect_submodules, collect_data_files
# Data files
datas = [
('config/default_config.yaml', 'config'),
]
# Hidden imports -- only lightweight deps needed for Deepgram streaming
hiddenimports = [
'sounddevice',
'numpy',
# FastAPI and dependencies
'fastapi',
'fastapi.routing',
'fastapi.responses',
'starlette',
'starlette.applications',
'starlette.routing',
'starlette.responses',
'starlette.websockets',
'starlette.middleware',
'starlette.middleware.cors',
'pydantic',
'pydantic.fields',
'pydantic.main',
'anyio',
'anyio._backends',
'anyio._backends._asyncio',
'sniffio',
# Uvicorn
'uvicorn',
'uvicorn.logging',
'uvicorn.loops',
'uvicorn.loops.auto',
'uvicorn.protocols',
'uvicorn.protocols.http',
'uvicorn.protocols.http.auto',
'uvicorn.protocols.http.h11_impl',
'uvicorn.protocols.websockets',
'uvicorn.protocols.websockets.auto',
'uvicorn.protocols.websockets.wsproto_impl',
'uvicorn.lifespan',
'uvicorn.lifespan.on',
'h11',
'websockets',
'websockets.legacy',
'websockets.legacy.server',
# HTTP client
'requests',
'urllib3',
'certifi',
'charset_normalizer',
]
# Collect submodules for key packages
print("Collecting submodules for cloud backend packages...")
for package in ['fastapi', 'starlette', 'pydantic', 'pydantic_core', 'anyio', 'uvicorn', 'websockets', 'h11']:
try:
submodules = collect_submodules(package)
hiddenimports += submodules
print(f" + Collected {len(submodules)} submodules from {package}")
except Exception as e:
print(f" - Warning: Could not collect {package}: {e}")
# Collect data files
for package in ['fastapi', 'starlette', 'pydantic', 'uvicorn']:
try:
data_files = collect_data_files(package)
if data_files:
datas += data_files
except Exception:
pass
# Pydantic critical deps
hiddenimports += [
'colorsys', 'decimal', 'json', 'ipaddress', 'pathlib', 'uuid',
'email.message', 'typing_extensions',
]
a = Analysis(
['backend/main_headless.py'],
pathex=[],
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=['hooks'],
hooksconfig={},
runtime_hooks=[],
excludes=[
# Exclude all heavy ML/local transcription deps
'torch', 'torchaudio', 'torchvision',
'faster_whisper', 'ctranslate2',
'RealtimeSTT', 'webrtcvad', 'webrtcvad_wheels',
'silero_vad', 'onnxruntime',
'openwakeword', 'pvporcupine', 'pyaudio',
'noisereduce', 'scipy',
# Exclude GUI frameworks
'PySide6', 'PyQt5', 'PyQt6', 'tkinter',
# Exclude other unnecessary heavy packages
'matplotlib', 'PIL', 'cv2',
],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='local-transcription-backend',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='LocalTranscription.ico' if is_windows else None,
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='local-transcription-backend',
)

1085
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "local-transcription",
"private": true,
"version": "1.4.8",
"version": "2.0.8",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -12,16 +12,19 @@
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2.0.0",
"@testing-library/svelte": "^5.3.1",
"@tsconfig/svelte": "^5.0.0",
"jsdom": "^29.0.2",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "~5.6.0",
"vite": "^6.0.0"
"vite": "^6.0.0",
"vitest": "^4.1.3"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"@tauri-apps/plugin-process": "^2.0.0"
"@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0"
}
}

View File

@@ -1,6 +1,6 @@
[project]
name = "local-transcription"
version = "1.0.3"
version = "1.0.6"
description = "A standalone desktop application for real-time speech-to-text transcription using Whisper models"
readme = "README.md"
requires-python = ">=3.9"

3
src-tauri/Cargo.lock generated
View File

@@ -1881,7 +1881,7 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "local-transcription"
version = "1.4.5"
version = "2.0.3"
dependencies = [
"bytes",
"chrono",
@@ -1894,6 +1894,7 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-process",
"tauri-plugin-shell",
"tempfile",
"tokio",
"zip",
]

View File

@@ -1,6 +1,6 @@
[package]
name = "local-transcription"
version = "1.4.8"
version = "2.0.8"
description = "Real-time speech-to-text transcription for streamers"
authors = ["Local Transcription Contributors"]
edition = "2021"
@@ -25,3 +25,6 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
bytes = "1"
tokio = { version = "1", features = ["full"] }
chrono = "0.4"
[dev-dependencies]
tempfile = "3"

View File

@@ -0,0 +1,14 @@
{
"identifier": "default",
"description": "Default permissions for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
"core:event:allow-listen",
"core:event:allow-emit",
"shell:default",
"dialog:default",
"process:default"
]
}

View File

@@ -1 +1 @@
{}
{"default":{"identifier":"default","description":"Default permissions for the main window","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-listen","core:event:allow-emit","shell:default","dialog:default","process:default"]}}

View File

@@ -29,9 +29,9 @@ pub fn run() {
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init())
.manage(sidecar::ManagedSidecar(Mutex::new(
.manage(sidecar::ManagedSidecar(std::sync::Arc::new(Mutex::new(
sidecar::SidecarManager::new(),
)))
))))
.setup(|app| {
let resource_dir = app
.path()
@@ -68,6 +68,7 @@ pub fn run() {
sidecar::get_sidecar_port,
sidecar::start_sidecar,
sidecar::stop_sidecar,
sidecar::reset_sidecar,
write_log,
])
.run(tauri::generate_context!())

View File

@@ -54,7 +54,8 @@ fn read_installed_version() -> Option<String> {
}
fn sidecar_dir_for_version(version: &str) -> PathBuf {
data_dir().join(format!("sidecar-{version}"))
// version is the full tag name, e.g. "sidecar-v1.0.3" -- use it directly
data_dir().join(version)
}
fn binary_path_for_version(version: &str) -> PathBuf {
@@ -371,12 +372,12 @@ fn extract_zip(zip_path: &std::path::Path, dest: &std::path::Path) -> Result<(),
fn cleanup_old_versions(current_version: &str) {
let data = data_dir();
let current_dir_name = format!("sidecar-{current_version}");
// current_version is already the full tag, e.g. "sidecar-v1.0.3"
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
if name.starts_with("sidecar-")
&& name != current_version
&& entry.path().is_dir()
{
let _ = std::fs::remove_dir_all(entry.path());
@@ -433,6 +434,20 @@ impl SidecarManager {
.ok_or_else(|| "Sidecar running but port unknown".into());
}
// Clear stale PID lock from a previous crash so the sidecar can start.
// The Python InstanceLock writes to ~/.local-transcription/app.lock
if let Ok(home) = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
{
let lock_file = PathBuf::from(home)
.join(".local-transcription")
.join("app.lock");
if lock_file.exists() {
eprintln!("[sidecar] Removing stale lock file: {}", lock_file.display());
let _ = std::fs::remove_file(&lock_file);
}
}
let is_dev = cfg!(debug_assertions)
|| std::env::var("LOCAL_TRANSCRIPTION_DEV")
.map(|v| v == "1")
@@ -463,11 +478,63 @@ impl SidecarManager {
.take()
.ok_or("Failed to capture sidecar stdout")?;
let port = Self::wait_for_ready(stdout)?;
// Capture stderr in a background thread so we can log it
let stderr = child
.stderr
.take()
.ok_or("Failed to capture sidecar stderr")?;
self.child = Some(child);
self.port = Some(port);
Ok(port)
let log_dir = DIRS.get().map(|d| d.data_dir.clone());
std::thread::spawn(move || {
use std::io::BufRead;
let reader = std::io::BufReader::new(stderr);
let mut log_file = log_dir.and_then(|d| {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(d.join("sidecar.log"))
.ok()
});
for line in reader.lines() {
if let Ok(line) = line {
eprintln!("[sidecar-stderr] {}", line);
if let Some(ref mut f) = log_file {
use std::io::Write;
let _ = writeln!(f, "{}", line);
}
}
}
});
match Self::wait_for_ready(stdout) {
Ok(port) => {
self.child = Some(child);
self.port = Some(port);
Ok(port)
}
Err(e) => {
// Kill the child if ready failed
let _ = child.kill();
let _ = child.wait();
// Read the sidecar.log for context
let log_hint = DIRS
.get()
.and_then(|d| std::fs::read_to_string(d.data_dir.join("sidecar.log")).ok())
.and_then(|s| {
let lines: Vec<&str> = s.lines().collect();
let tail: Vec<&str> = lines.iter().rev().take(10).rev().cloned().collect();
if tail.is_empty() { None } else { Some(tail.join("\n")) }
})
.unwrap_or_default();
if log_hint.is_empty() {
Err(e)
} else {
Err(format!("{e}\n\nSidecar stderr (last 10 lines):\n{log_hint}"))
}
}
}
}
/// Stop the sidecar process if running.
@@ -487,20 +554,30 @@ impl SidecarManager {
// -- 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"]);
// Use `uv run` to ensure we use the project's venv, not system Python
let mut cmd = std::process::Command::new("uv");
cmd.args(["run", "python", "-u", "-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);
}
// Find the project root: try CARGO_MANIFEST_DIR first (set at compile time),
// then fall back to resource_dir parent chain
let manifest_dir = option_env!("CARGO_MANIFEST_DIR").map(std::path::PathBuf::from);
let project_root = manifest_dir
.as_ref()
.and_then(|d| d.parent()) // src-tauri -> project root
.or_else(|| {
DIRS.get()
.and_then(|d| d.resource_dir.parent())
.and_then(|p| p.parent())
});
if let Some(root) = project_root {
eprintln!("[sidecar] Dev mode: working dir = {}", root.display());
cmd.current_dir(root);
} else {
eprintln!("[sidecar] Dev mode: WARNING - could not determine project root");
}
cmd.env("PYTHONUNBUFFERED", "1");
Ok(cmd)
}
@@ -516,27 +593,51 @@ impl SidecarManager {
bin.parent()
.ok_or("Cannot determine sidecar parent dir")?,
);
// Force unbuffered stdout so the ready event is sent immediately.
// PyInstaller frozen executables buffer stdout when piped.
cmd.env("PYTHONUNBUFFERED", "1");
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();
use std::sync::mpsc;
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);
let timeout = std::time::Duration::from_secs(120);
// Read stdout in a background thread so we can enforce a real timeout.
// BufReader::lines() blocks indefinitely if no data arrives.
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let reader = std::io::BufReader::new(stdout);
for line in reader.lines() {
match line {
Ok(line) => {
eprintln!("[sidecar-stdout] {}", line);
if let Ok(evt) = serde_json::from_str::<ReadyEvent>(&line) {
if evt.event == "ready" {
let _ = tx.send(Ok(evt.port));
return;
}
}
}
Err(e) => {
let _ = tx.send(Err(format!("IO error reading stdout: {e}")));
return;
}
}
}
// Ignore other lines (e.g. log output)
}
Err("Sidecar process exited before sending ready event".into())
let _ = tx.send(Err(
"Sidecar process exited before sending ready event".into(),
));
});
rx.recv_timeout(timeout).unwrap_or_else(|_| {
Err(format!(
"Timed out after {}s waiting for sidecar ready event",
timeout.as_secs()
))
})
}
}
@@ -545,7 +646,8 @@ impl SidecarManager {
// ---------------------------------------------------------------------------
/// Wrapper so we can store `SidecarManager` in Tauri's managed state.
pub struct ManagedSidecar(pub Mutex<SidecarManager>);
/// Uses Arc so it can be cloned into background threads for async commands.
pub struct ManagedSidecar(pub std::sync::Arc<Mutex<SidecarManager>>);
#[tauri::command]
pub fn get_sidecar_port(state: tauri::State<'_, ManagedSidecar>) -> Result<Option<u16>, String> {
@@ -561,12 +663,16 @@ pub fn get_sidecar_port(state: tauri::State<'_, ManagedSidecar>) -> Result<Optio
}
#[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()
pub async fn start_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<u16, String> {
let mgr = state.0.clone();
// Run blocking sidecar launch in a background thread so it doesn't
// freeze the Tauri UI while waiting for the ready event (up to 120s).
tokio::task::spawn_blocking(move || {
let mut mgr = mgr.lock().map_err(|e| format!("Lock error: {e}"))?;
mgr.ensure_running()
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
#[tauri::command]
@@ -578,3 +684,399 @@ pub fn stop_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<(), Strin
mgr.stop();
Ok(())
}
/// Stop the running sidecar, delete its files and version marker.
/// The next app launch will show the sidecar download prompt.
#[tauri::command]
pub fn reset_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<(), String> {
// Stop the running sidecar first
{
let mut mgr = state
.0
.lock()
.map_err(|e| format!("Lock error: {e}"))?;
mgr.stop();
}
let data = data_dir();
// Delete the version file so check_sidecar returns false
let vf = version_file();
if vf.exists() {
std::fs::remove_file(&vf)
.map_err(|e| format!("Failed to delete version file: {e}"))?;
}
// Delete all sidecar directories
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-") && entry.path().is_dir() {
eprintln!("[sidecar] Removing {}", entry.path().display());
let _ = std::fs::remove_dir_all(entry.path());
}
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
// -----------------------------------------------------------------------
// 1. Platform / arch detection
// -----------------------------------------------------------------------
#[test]
fn platform_token_returns_valid_value() {
let token = platform_token();
assert!(
["windows", "macos", "linux"].contains(&token),
"unexpected platform token: {token}"
);
}
#[test]
fn arch_token_returns_valid_value() {
let token = arch_token();
assert!(
["x86_64", "aarch64"].contains(&token),
"unexpected arch token: {token}"
);
}
// -----------------------------------------------------------------------
// 2. Asset name construction
// -----------------------------------------------------------------------
#[test]
fn asset_prefix_cpu() {
let prefix = asset_prefix("cpu");
let expected = format!("sidecar-{}-{}-cpu", platform_token(), arch_token());
assert_eq!(prefix, expected);
}
#[test]
fn asset_prefix_cuda() {
let prefix = asset_prefix("cuda");
let expected = format!("sidecar-{}-{}-cuda", platform_token(), arch_token());
assert_eq!(prefix, expected);
}
#[test]
fn asset_prefix_format_matches_zip_convention() {
// The download function looks for assets matching
// `{prefix}*.zip`, so verify the prefix starts with "sidecar-"
// and contains exactly three hyphens (sidecar-OS-ARCH-VARIANT).
let prefix = asset_prefix("cpu");
assert!(prefix.starts_with("sidecar-"));
assert_eq!(prefix.matches('-').count(), 3, "expected 3 hyphens in '{prefix}'");
}
// -----------------------------------------------------------------------
// 3. Version parsing — tag_name format "sidecar-vX.Y.Z"
// -----------------------------------------------------------------------
#[test]
fn sidecar_tag_starts_with_expected_prefix() {
// The code filters releases by `tag_name.starts_with("sidecar-v")`.
// Verify the convention: a version string like "sidecar-v1.0.2" passes
// the filter, while "v1.0.2" does not.
let tag = "sidecar-v1.0.2";
assert!(tag.starts_with("sidecar-v"));
let bad_tag = "v1.0.2";
assert!(!bad_tag.starts_with("sidecar-v"));
}
#[test]
fn strip_sidecar_v_prefix() {
// The codebase stores the full tag as the version (e.g. "sidecar-v1.0.2").
// Verify we can strip the prefix to get just "1.0.2" when needed.
let tag = "sidecar-v1.0.2";
let semver = tag.strip_prefix("sidecar-v").unwrap();
assert_eq!(semver, "1.0.2");
}
// -----------------------------------------------------------------------
// 4. ReadyEvent deserialization
// -----------------------------------------------------------------------
#[test]
fn ready_event_deserializes_basic() {
let json = r#"{"event": "ready", "port": 8081}"#;
let evt: ReadyEvent = serde_json::from_str(json).unwrap();
assert_eq!(evt.event, "ready");
assert_eq!(evt.port, 8081);
}
#[test]
fn ready_event_deserializes_with_extra_fields() {
// The backend may emit additional fields like `obs_port`.
// serde should ignore unknown fields by default (deny_unknown_fields
// is NOT set on ReadyEvent).
let json = r#"{"event": "ready", "port": 8081, "obs_port": 8080}"#;
let evt: ReadyEvent = serde_json::from_str(json).unwrap();
assert_eq!(evt.event, "ready");
assert_eq!(evt.port, 8081);
}
#[test]
fn ready_event_rejects_missing_port() {
let json = r#"{"event": "ready"}"#;
assert!(serde_json::from_str::<ReadyEvent>(json).is_err());
}
#[test]
fn ready_event_rejects_invalid_port_type() {
let json = r#"{"event": "ready", "port": "not_a_number"}"#;
assert!(serde_json::from_str::<ReadyEvent>(json).is_err());
}
// -----------------------------------------------------------------------
// Helper: initialise DIRS with a temp directory so path-related functions
// work. Because OnceLock can only be set once per process, all tests that
// need DIRS must coordinate. We use std::sync::Once + a global temp path.
// -----------------------------------------------------------------------
static TEST_DATA_DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
/// Ensure `DIRS` is initialised (idempotent within a test run).
/// Returns the data_dir path.
fn ensure_dirs_initialised() -> PathBuf {
TEST_DATA_DIR
.get_or_init(|| {
let tmp = tempfile::tempdir().expect("create tempdir");
let data = tmp.path().to_path_buf();
// We intentionally leak `tmp` so the directory lives for the
// entire test-run.
std::mem::forget(tmp);
let resource = data.join("resource"); // dummy
std::fs::create_dir_all(&resource).ok();
init_dirs(resource, data.clone());
data
})
.clone()
}
// -----------------------------------------------------------------------
// 5. Path construction (requires init_dirs)
// -----------------------------------------------------------------------
#[test]
fn version_file_path_is_in_data_dir() {
let data = ensure_dirs_initialised();
let vf = version_file();
assert_eq!(vf, data.join("sidecar-version.txt"));
}
#[test]
fn sidecar_dir_for_version_contains_version() {
let data = ensure_dirs_initialised();
let dir = sidecar_dir_for_version("sidecar-v1.2.3");
assert_eq!(dir, data.join("sidecar-v1.2.3"));
}
#[test]
fn binary_path_for_version_has_correct_filename() {
let _data = ensure_dirs_initialised();
let bin = binary_path_for_version("sidecar-v1.2.3");
assert_eq!(bin.file_name().unwrap(), BINARY_NAME);
}
#[test]
fn read_installed_version_none_when_missing() {
let _data = ensure_dirs_initialised();
// The version file should not exist yet (clean temp dir).
// If another test wrote it, this still validates the function
// doesn't panic.
let _ = read_installed_version(); // should not panic
}
#[test]
fn write_then_read_installed_version() {
let _data = ensure_dirs_initialised();
let vf = version_file();
std::fs::write(&vf, "sidecar-v2.0.0\n").unwrap();
let v = read_installed_version().expect("should read version");
assert_eq!(v, "sidecar-v2.0.0");
}
// -----------------------------------------------------------------------
// 6. Cleanup old versions
// -----------------------------------------------------------------------
/// Cleanup tests are combined into one function because
/// `cleanup_old_versions` operates on the shared `data_dir()` and
/// tests run in parallel, so separate tests would race.
#[test]
fn cleanup_old_versions_behaviour() {
let data = ensure_dirs_initialised();
// -- Part A: removes stale version dirs, keeps current ----------------
let dirs_to_create = ["sidecar-v1.0.0", "sidecar-v1.0.1", "sidecar-v1.0.2"];
for d in &dirs_to_create {
std::fs::create_dir_all(data.join(d)).unwrap();
}
// current_version is the full tag, e.g. "sidecar-v1.0.2"
cleanup_old_versions("sidecar-v1.0.2");
assert!(
!data.join("sidecar-v1.0.0").exists(),
"sidecar-v1.0.0 should be removed"
);
assert!(
!data.join("sidecar-v1.0.1").exists(),
"sidecar-v1.0.1 should be removed"
);
assert!(
data.join("sidecar-v1.0.2").exists(),
"sidecar-v1.0.2 should be kept"
);
// -- Part B: ignores non-sidecar directories --------------------------
let other = data.join("some-other-dir");
std::fs::create_dir_all(&other).unwrap();
cleanup_old_versions("v1.0.2"); // run again — should leave other alone
assert!(other.exists(), "non-sidecar dir should not be removed");
// Clean up so we don't affect other tests that share data_dir.
let _ = std::fs::remove_dir_all(data.join("sidecar-v1.0.2"));
let _ = std::fs::remove_dir_all(&other);
}
// -----------------------------------------------------------------------
// 7. Zip extraction
// -----------------------------------------------------------------------
#[test]
fn extract_zip_creates_files() {
let tmp = tempfile::tempdir().unwrap();
let zip_path = tmp.path().join("test.zip");
let dest_dir = tmp.path().join("output");
std::fs::create_dir_all(&dest_dir).unwrap();
// Build a simple zip in memory.
{
let file = std::fs::File::create(&zip_path).unwrap();
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
writer.start_file("hello.txt", options).unwrap();
writer.write_all(b"Hello, world!").unwrap();
writer.start_file("subdir/nested.txt", options).unwrap();
writer.write_all(b"Nested content").unwrap();
writer.finish().unwrap();
}
extract_zip(&zip_path, &dest_dir).expect("extraction should succeed");
let hello = dest_dir.join("hello.txt");
assert!(hello.exists(), "hello.txt should exist");
assert_eq!(std::fs::read_to_string(&hello).unwrap(), "Hello, world!");
let nested = dest_dir.join("subdir/nested.txt");
assert!(nested.exists(), "subdir/nested.txt should exist");
assert_eq!(std::fs::read_to_string(&nested).unwrap(), "Nested content");
}
#[test]
fn extract_zip_error_on_invalid_file() {
let tmp = tempfile::tempdir().unwrap();
let bad_zip = tmp.path().join("bad.zip");
std::fs::write(&bad_zip, b"not a zip file").unwrap();
let dest = tmp.path().join("dest");
std::fs::create_dir_all(&dest).unwrap();
let result = extract_zip(&bad_zip, &dest);
assert!(result.is_err(), "should fail on invalid zip");
}
// -----------------------------------------------------------------------
// SidecarManager unit tests (no process spawning)
// -----------------------------------------------------------------------
#[test]
fn sidecar_manager_new_is_not_running() {
let mut mgr = SidecarManager::new();
assert!(!mgr.is_running());
assert!(mgr.port().is_none());
}
#[test]
fn sidecar_manager_stop_when_not_running() {
let mut mgr = SidecarManager::new();
mgr.stop(); // should not panic
assert!(!mgr.is_running());
}
// -----------------------------------------------------------------------
// GiteaRelease / GiteaAsset deserialization
// -----------------------------------------------------------------------
#[test]
fn gitea_release_deserializes() {
let json = r#"{
"tag_name": "sidecar-v1.0.0",
"assets": [
{
"name": "sidecar-linux-x86_64-cuda.zip",
"browser_download_url": "https://example.com/file.zip",
"size": 12345
}
]
}"#;
let release: GiteaRelease = serde_json::from_str(json).unwrap();
assert_eq!(release.tag_name, "sidecar-v1.0.0");
assert_eq!(release.assets.len(), 1);
assert_eq!(release.assets[0].name, "sidecar-linux-x86_64-cuda.zip");
assert_eq!(release.assets[0].size, 12345);
}
#[test]
fn gitea_release_with_extra_fields() {
// Gitea responses include many more fields; serde should ignore them.
let json = r#"{
"id": 42,
"tag_name": "sidecar-v2.0.0",
"name": "Release 2.0.0",
"body": "changelog here",
"draft": false,
"prerelease": false,
"assets": []
}"#;
let release: GiteaRelease = serde_json::from_str(json).unwrap();
assert_eq!(release.tag_name, "sidecar-v2.0.0");
assert!(release.assets.is_empty());
}
// -----------------------------------------------------------------------
// DownloadProgress serialization round-trip
// -----------------------------------------------------------------------
#[test]
fn download_progress_serializes() {
let progress = DownloadProgress {
downloaded: 1024,
total: 4096,
phase: "downloading".into(),
message: "50%".into(),
};
let json = serde_json::to_string(&progress).unwrap();
assert!(json.contains("\"downloaded\":1024"));
assert!(json.contains("\"phase\":\"downloading\""));
}
}

View File

@@ -1,6 +1,6 @@
{
"productName": "Local Transcription",
"version": "1.4.8",
"version": "2.0.8",
"identifier": "net.anhonesthost.local-transcription",
"build": {
"frontendDist": "../dist",

View File

@@ -9,11 +9,12 @@
import { backendStore } from "$lib/stores/backend";
import { configStore } from "$lib/stores/config";
type SidecarState = "checking" | "needs_setup" | "starting" | "connected";
type SidecarState = "checking" | "needs_setup" | "update_available" | "starting" | "connected";
let showSettings = $state(false);
let sidecarState = $state<SidecarState>("checking");
let debugLog = $state("");
let availableUpdate = $state("");
let obsDisplayUrl = $derived(backendStore.obsUrl);
let syncDisplayUrl = $derived(backendStore.syncUrl);
@@ -53,6 +54,20 @@
return;
}
// Check for sidecar updates before launching
try {
log("Checking for sidecar updates...");
const update = await invoke<string | null>("check_sidecar_update");
if (update) {
log(`Sidecar update available: ${update}`);
availableUpdate = update;
sidecarState = "update_available";
return;
}
} catch (err) {
log(`Update check failed (non-fatal): ${err}`);
}
await launchSidecar();
} catch (err) {
// Not running in Tauri (browser dev mode) - skip sidecar check
@@ -61,7 +76,7 @@
sidecarState = "starting";
backendStore.setPort(8081);
backendStore.connect();
configStore.loadConfig();
configStore.fetchConfig();
}
}
@@ -78,13 +93,13 @@
log(`Sidecar ready on port ${port}`);
backendStore.setPort(port);
backendStore.connect();
configStore.loadConfig();
configStore.fetchConfig();
} catch (err) {
// If sidecar launch fails, still try connecting to default port
log(`Sidecar launch failed: ${err}, trying default port`);
sidecarState = "starting";
backendStore.connect();
configStore.loadConfig();
configStore.fetchConfig();
}
}
@@ -118,6 +133,26 @@
{:else if sidecarState === "needs_setup"}
<SidecarSetup onComplete={onSidecarReady} />
{:else if sidecarState === "update_available"}
<div class="connecting-overlay" style="background:#1e1e1e;color:#e0e0e0;display:flex;align-items:center;justify-content:center;height:100%;width:100%;">
<div class="connecting-content" style="text-align:center;max-width:400px;">
<h2 style="font-size:20px;margin:0 0 12px;">Sidecar Update Available</h2>
<p style="color:#a0a0a0;font-size:14px;margin:0 0 20px;">
A new version of the transcription engine is available ({availableUpdate}).
</p>
<div style="display:flex;gap:10px;justify-content:center;">
<button
style="padding:8px 20px;border:1px solid #555;border-radius:6px;background:transparent;color:#e0e0e0;cursor:pointer;"
onclick={() => launchSidecar()}
>Skip</button>
<button
style="padding:8px 20px;border:none;border-radius:6px;background:#4CAF50;color:white;cursor:pointer;font-weight:500;"
onclick={() => { sidecarState = "needs_setup"; }}
>Update Now</button>
</div>
</div>
</div>
{:else if !isConnected}
<div class="connecting-overlay" style="background:#1e1e1e;color:#e0e0e0;display:flex;align-items:center;justify-content:center;height:100%;width:100%;">
<div class="connecting-content" style="text-align:center;">

View File

@@ -17,6 +17,9 @@
} else {
await backendStore.apiPost("/api/start");
}
// Poll status to update UI immediately instead of waiting
// for WebSocket broadcast (which can be delayed or missed)
await backendStore.pollStatus();
} catch (err) {
console.error("Failed to toggle transcription:", err);
} finally {

View File

@@ -27,6 +27,10 @@
let showTimestamps = $state(true);
let fadeSeconds = $state(10);
let maxLines = $state(100);
let fontSource = $state("System Font");
let fontFamily = $state("Courier");
let websafeFont = $state("Arial");
let googleFont = $state("Roboto");
let fontSize = $state(12);
let userColor = $state("#4CAF50");
let textColor = $state("#FFFFFF");
@@ -37,10 +41,14 @@
let syncPassphrase = $state("");
let remoteMode = $state("local");
let remoteServerUrl = $state("");
let byokApiKey = $state("");
let managedEmail = $state("");
let managedPassword = $state("");
let autoCheckUpdates = $state(true);
let saving = $state(false);
let saveMessage = $state("");
// Fetched device lists
let audioDevices = $state<{ id: string; name: string }[]>([]);
let computeDevices = $state<{ id: string; name: string }[]>([]);
@@ -95,6 +103,10 @@
showTimestamps = cfg.display.show_timestamps;
fadeSeconds = cfg.display.fade_after_seconds;
maxLines = cfg.display.max_lines;
fontSource = cfg.display.font_source ?? "System Font";
fontFamily = cfg.display.font_family ?? "Courier";
websafeFont = cfg.display.websafe_font ?? "Arial";
googleFont = cfg.display.google_font ?? "Roboto";
fontSize = cfg.display.font_size;
userColor = cfg.display.user_color;
textColor = cfg.display.text_color;
@@ -107,6 +119,7 @@
syncPassphrase = cfg.server_sync.passphrase;
remoteMode = cfg.remote.mode;
remoteServerUrl = cfg.remote.server_url;
byokApiKey = cfg.remote.byok_api_key ?? "";
autoCheckUpdates = cfg.updates.auto_check;
});
@@ -169,6 +182,10 @@
show_timestamps: showTimestamps,
fade_after_seconds: fadeSeconds,
max_lines: maxLines,
font_source: fontSource,
font_family: fontFamily,
websafe_font: websafeFont,
google_font: googleFont,
font_size: fontSize,
user_color: userColor,
text_color: textColor,
@@ -183,17 +200,23 @@
remote: {
mode: remoteMode,
server_url: remoteServerUrl,
byok_api_key: byokApiKey,
},
updates: {
auto_check: autoCheckUpdates,
},
};
saving = true;
saveMessage = "";
try {
await configStore.saveConfig(updates);
onClose();
await configStore.updateConfig(updates);
saveMessage = "Settings saved!";
setTimeout(() => onClose(), 600);
} catch (err) {
console.error("Failed to save settings:", err);
saveMessage = `Error: ${err}`;
saving = false;
}
}
@@ -203,15 +226,27 @@
async function handleCheckUpdates() {
try {
await backendStore.apiPost("/api/check-updates");
await backendStore.apiGet("/api/check-update");
} catch (err) {
console.error("Failed to check for updates:", err);
}
}
async function handleChangeSidecar() {
try {
const { invoke } = await import("@tauri-apps/api/core");
await invoke("reset_sidecar");
// Force a page reload which will re-trigger the setup flow
window.location.reload();
} catch (err) {
console.error("Failed to reset sidecar:", err);
saveMessage = `Error: ${err}`;
}
}
async function handleManagedLogin() {
try {
await backendStore.apiPost("/api/remote/login", {
await backendStore.apiPost("/api/login", {
email: managedEmail,
password: managedPassword,
});
@@ -222,7 +257,7 @@
async function handleManagedRegister() {
try {
await backendStore.apiPost("/api/remote/register", {
await backendStore.apiPost("/api/register", {
email: managedEmail,
password: managedPassword,
});
@@ -474,6 +509,95 @@
bind:value={maxLines}
/>
</div>
<div class="field">
<label for="font-source">Font Source</label>
<select id="font-source" bind:value={fontSource}>
<option value="System Font">System Font</option>
<option value="Web-Safe">Web-Safe</option>
<option value="Google Font">Google Font</option>
</select>
</div>
{#if fontSource === "System Font"}
<div class="field">
<label for="font-family">System Font Family</label>
<input id="font-family" type="text" bind:value={fontFamily} placeholder="Courier" />
</div>
{/if}
{#if fontSource === "Web-Safe"}
<div class="field">
<label for="websafe-font">Web-Safe Font</label>
<select id="websafe-font" bind:value={websafeFont}>
<option value="Arial">Arial</option>
<option value="Arial Black">Arial Black</option>
<option value="Comic Sans MS">Comic Sans MS</option>
<option value="Courier New">Courier New</option>
<option value="Georgia">Georgia</option>
<option value="Impact">Impact</option>
<option value="Lucida Console">Lucida Console</option>
<option value="Lucida Sans Unicode">Lucida Sans Unicode</option>
<option value="Palatino Linotype">Palatino Linotype</option>
<option value="Tahoma">Tahoma</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Trebuchet MS">Trebuchet MS</option>
<option value="Verdana">Verdana</option>
</select>
</div>
{/if}
{#if fontSource === "Google Font"}
<div class="field">
<label for="google-font">Google Font</label>
<select id="google-font" bind:value={googleFont}>
<optgroup label="Sans Serif">
<option value="Roboto">Roboto</option>
<option value="Open Sans">Open Sans</option>
<option value="Lato">Lato</option>
<option value="Montserrat">Montserrat</option>
<option value="Poppins">Poppins</option>
<option value="Nunito">Nunito</option>
<option value="Raleway">Raleway</option>
<option value="Ubuntu">Ubuntu</option>
<option value="Rubik">Rubik</option>
<option value="Work Sans">Work Sans</option>
<option value="Inter">Inter</option>
<option value="Outfit">Outfit</option>
<option value="Quicksand">Quicksand</option>
<option value="Comfortaa">Comfortaa</option>
<option value="Varela Round">Varela Round</option>
</optgroup>
<optgroup label="Serif">
<option value="Playfair Display">Playfair Display</option>
<option value="Merriweather">Merriweather</option>
<option value="Lora">Lora</option>
<option value="PT Serif">PT Serif</option>
<option value="Crimson Text">Crimson Text</option>
</optgroup>
<optgroup label="Monospace">
<option value="Roboto Mono">Roboto Mono</option>
<option value="Source Code Pro">Source Code Pro</option>
<option value="Fira Code">Fira Code</option>
<option value="JetBrains Mono">JetBrains Mono</option>
<option value="IBM Plex Mono">IBM Plex Mono</option>
</optgroup>
<optgroup label="Display">
<option value="Bebas Neue">Bebas Neue</option>
<option value="Oswald">Oswald</option>
<option value="Righteous">Righteous</option>
<option value="Bangers">Bangers</option>
<option value="Permanent Marker">Permanent Marker</option>
</optgroup>
<optgroup label="Handwriting">
<option value="Pacifico">Pacifico</option>
<option value="Lobster">Lobster</option>
<option value="Dancing Script">Dancing Script</option>
<option value="Caveat">Caveat</option>
<option value="Satisfy">Satisfy</option>
</optgroup>
</select>
<p style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">
Browse more at <a href="https://fonts.google.com" target="_blank" rel="noopener" style="color: var(--accent-blue);">fonts.google.com</a>
</p>
</div>
{/if}
<div class="field">
<label for="font-size">Font Size: {fontSize}px</label>
<input
@@ -572,7 +696,7 @@
BYOK (Bring Your Own Key)
</label>
</div>
{#if remoteMode !== "local"}
{#if remoteMode === "managed"}
<div class="field">
<label for="remote-url">Server URL</label>
<input
@@ -583,6 +707,20 @@
/>
</div>
{/if}
{#if remoteMode === "byok"}
<div class="field">
<label for="byok-key">Deepgram API Key</label>
<input
id="byok-key"
type="password"
bind:value={byokApiKey}
placeholder="Enter your Deepgram API key"
/>
<p style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">
Get a key at <a href="https://console.deepgram.com" target="_blank" rel="noopener" style="color: var(--accent-blue);">console.deepgram.com</a>
</p>
</div>
{/if}
{#if remoteMode === "managed"}
<div class="managed-auth">
<div class="field">
@@ -623,11 +761,27 @@
</div>
<button onclick={handleCheckUpdates}>Check Now</button>
</section>
<!-- Transcription Engine -->
<section class="settings-section">
<h3>Transcription Engine</h3>
<p style="font-size: 12px; color: var(--text-secondary); margin-bottom: 12px;">
Switch between local (Whisper) and cloud (Deepgram) transcription engines.
This will stop the current engine, remove the downloaded files, and restart
with the new engine selection.
</p>
<button class="danger-btn" onclick={handleChangeSidecar}>Change Transcription Engine</button>
</section>
</div>
<div class="settings-footer">
<button onclick={handleCancel}>Cancel</button>
<button class="primary" onclick={handleSave}>Save</button>
{#if saveMessage}
<span class="save-message" class:error={saveMessage.startsWith("Error")}>{saveMessage}</span>
{/if}
<button onclick={handleCancel} disabled={saving}>Cancel</button>
<button class="primary" onclick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
</div>
@@ -771,10 +925,35 @@
.settings-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--border-color);
flex-shrink: 0;
}
.save-message {
margin-right: auto;
font-size: 13px;
color: #4CAF50;
}
.save-message.error {
color: #f44336;
}
.danger-btn {
background: transparent;
border: 1px solid var(--accent-red, #f44336);
color: var(--accent-red, #f44336);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.danger-btn:hover {
background: rgba(244, 67, 54, 0.1);
}
</style>

View File

@@ -36,11 +36,12 @@
try {
// Listen for progress events from the Tauri backend
unlisten = await listen<{ progress: number; message: string }>(
unlisten = await listen<{ downloaded: number; total: number; phase: string; message: string }>(
"sidecar-download-progress",
(event) => {
progress = event.payload.progress;
progressMessage = event.payload.message;
const { downloaded, total, message } = event.payload;
progress = total > 0 ? (downloaded / total) * 100 : 0;
progressMessage = message;
}
);
@@ -84,11 +85,29 @@
{#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.
Choose a transcription engine. You can change this later in Settings.
</p>
<div class="variant-options">
<label class="variant-option" class:selected={variant === "cloud"}>
<input
type="radio"
name="variant"
value="cloud"
bind:group={variant}
/>
<div class="variant-info">
<span class="variant-name">Cloud (Deepgram)</span>
<span class="variant-desc">~50 MB download</span>
<span class="variant-detail">
Fast, accurate streaming transcription via Deepgram's servers.
Requires internet and a Deepgram API key.
Best for most users — low resource usage, works on any hardware.
</span>
<span class="variant-tag recommended">Recommended</span>
</div>
</label>
<label class="variant-option" class:selected={variant === "cpu"}>
<input
type="radio"
@@ -97,23 +116,16 @@
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>
<span class="variant-name">Local - CPU</span>
<span class="variant-desc">~500 MB download</span>
<span class="variant-detail">
Runs Whisper AI models locally on your CPU. No internet needed
after download. Good for privacy or offline use, but slower and
uses more system resources than cloud.
</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}>
@@ -260,6 +272,30 @@
color: #888;
}
.variant-detail {
font-size: 11px;
color: #666;
line-height: 1.4;
margin-top: 2px;
}
.variant-tag {
display: inline-block;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 6px;
border-radius: 3px;
margin-top: 4px;
width: fit-content;
}
.variant-tag.recommended {
background: rgba(76, 175, 80, 0.15);
color: #4CAF50;
}
.download-btn {
display: block;
width: 100%;

View File

@@ -54,6 +54,35 @@ async function apiFetch(path: string, options?: RequestInit): Promise<Response>
return fetch(url, { ...options, headers });
}
// ── Status polling ──────────────────────────────────────────────────
let statusPollTimer: ReturnType<typeof setTimeout> | null = null;
async function pollStatus() {
try {
const resp = await fetch(apiUrl("/api/status"));
if (resp.ok) {
const data = await resp.json();
if (data.state) {
state.appState = data.state as AppState;
}
if (data.engine_device) {
state.deviceInfo = data.engine_device;
}
if (data.version) {
state.version = data.version;
}
}
} catch {
// API not ready yet, will retry
}
// Keep polling every 2s while still initializing
if (state.appState === "initializing" && state.connectionState === "connected") {
statusPollTimer = setTimeout(pollStatus, 2000);
}
}
// ── WebSocket management ─────────────────────────────────────────────
function connectWebSocket() {
@@ -80,6 +109,9 @@ function _openSocket() {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
// Poll status to catch engine ready state that may have been
// missed (engine can finish before WebSocket connects)
pollStatus();
};
ws.onmessage = (event) => {
@@ -132,6 +164,10 @@ function _scheduleReconnect() {
}
function disconnect() {
if (statusPollTimer) {
clearTimeout(statusPollTimer);
statusPollTimer = null;
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
@@ -255,9 +291,18 @@ export const backendStore = {
get wsUrl() {
return `ws://localhost:${state.port}/ws/control`;
},
get obsUrl() {
// OBS display runs on the web server port (one below the API port)
const obsPort = state.port > 0 ? state.port - 1 : 8080;
return `http://localhost:${obsPort}`;
},
get syncUrl() {
return "";
},
setPort,
connect: connectWebSocket,
disconnect,
pollStatus,
apiUrl,
apiFetch,
apiGet,

View File

@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { backendStore } from "./backend.svelte.ts";
// Mock WebSocket globally so the store module can reference it
class MockWebSocket {
onopen: ((ev: Event) => void) | null = null;
onclose: ((ev: CloseEvent) => void) | null = null;
onmessage: ((ev: MessageEvent) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
close = vi.fn();
}
vi.stubGlobal("WebSocket", MockWebSocket);
// Mock fetch to prevent real network calls
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
})
)
);
describe("backend store", () => {
beforeEach(() => {
backendStore.disconnect();
backendStore.setPort(8081);
});
it("test_exports_expected_properties", () => {
expect(backendStore).toHaveProperty("port");
expect(backendStore).toHaveProperty("connectionState");
expect(backendStore).toHaveProperty("connected");
expect(backendStore).toHaveProperty("appState");
expect(backendStore).toHaveProperty("stateMessage");
expect(backendStore).toHaveProperty("deviceInfo");
expect(backendStore).toHaveProperty("version");
expect(backendStore).toHaveProperty("lastError");
expect(backendStore).toHaveProperty("apiBaseUrl");
expect(backendStore).toHaveProperty("wsUrl");
expect(backendStore).toHaveProperty("obsUrl");
expect(backendStore).toHaveProperty("syncUrl");
});
it("test_exports_expected_methods", () => {
expect(typeof backendStore.setPort).toBe("function");
expect(typeof backendStore.connect).toBe("function");
expect(typeof backendStore.disconnect).toBe("function");
expect(typeof backendStore.apiUrl).toBe("function");
expect(typeof backendStore.apiFetch).toBe("function");
expect(typeof backendStore.apiGet).toBe("function");
expect(typeof backendStore.apiPost).toBe("function");
expect(typeof backendStore.apiPut).toBe("function");
});
it("test_obsUrl_derives_from_port", () => {
backendStore.setPort(8081);
expect(backendStore.obsUrl).toBe("http://localhost:8080");
});
it("test_apiBaseUrl_uses_port", () => {
backendStore.setPort(8081);
expect(backendStore.apiBaseUrl).toBe("http://localhost:8081");
});
it("test_wsUrl_uses_port", () => {
backendStore.setPort(8081);
expect(backendStore.wsUrl).toBe("ws://localhost:8081/ws/control");
});
it("test_initial_state", () => {
// After disconnect() in beforeEach, state should be disconnected
expect(backendStore.connectionState).toBe("disconnected");
expect(backendStore.appState).toBe("initializing");
});
});

View File

@@ -0,0 +1,48 @@
import { describe, it, expect, vi } from "vitest";
// Mock fetch so the backend store module doesn't make real requests
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
})
)
);
// Mock WebSocket for the backend store dependency
class MockWebSocket {
onopen: ((ev: Event) => void) | null = null;
onclose: ((ev: CloseEvent) => void) | null = null;
onmessage: ((ev: MessageEvent) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
close = vi.fn();
}
vi.stubGlobal("WebSocket", MockWebSocket);
import { configStore } from "./config.svelte.ts";
describe("config store", () => {
it("test_has_fetchConfig_method", () => {
expect(typeof configStore.fetchConfig).toBe("function");
});
it("test_has_updateConfig_method", () => {
expect(typeof configStore.updateConfig).toBe("function");
});
it("test_config_defaults_have_expected_keys", () => {
const cfg = configStore.config;
expect(cfg).toHaveProperty("user");
expect(cfg).toHaveProperty("audio");
expect(cfg).toHaveProperty("transcription");
expect(cfg).toHaveProperty("display");
expect(cfg).toHaveProperty("remote");
expect(cfg).toHaveProperty("updates");
});
it("test_remote_config_has_byok_api_key", () => {
expect(configStore.config.remote.byok_api_key).toBeDefined();
});
});

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
describe("store file extensions", () => {
it("test_store_files_use_svelte_ts_extension", () => {
const storesDir = path.resolve(__dirname);
const files = fs.readdirSync(storesDir);
// Find .ts files that are NOT .svelte.ts and NOT test files
const plainTsFiles = files.filter(
(f) =>
f.endsWith(".ts") &&
!f.endsWith(".svelte.ts") &&
!f.endsWith(".test.ts")
);
for (const file of plainTsFiles) {
const content = fs.readFileSync(path.join(storesDir, file), "utf-8");
expect(content).not.toMatch(
/\$state\s*[<(]/,
`${file} uses $state() but does not have .svelte.ts extension`
);
expect(content).not.toMatch(
/\$derived\s*[<(]/,
`${file} uses $derived() but does not have .svelte.ts extension`
);
expect(content).not.toMatch(
/\$effect\s*[<(]/,
`${file} uses $effect() but does not have .svelte.ts extension`
);
}
});
});

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock WebSocket for the backend store dependency (loaded transitively)
class MockWebSocket {
onopen: ((ev: Event) => void) | null = null;
onclose: ((ev: CloseEvent) => void) | null = null;
onmessage: ((ev: MessageEvent) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
close = vi.fn();
}
vi.stubGlobal("WebSocket", MockWebSocket);
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
})
)
);
import { transcriptionStore } from "./transcriptions.svelte.ts";
describe("transcriptions store", () => {
beforeEach(() => {
transcriptionStore.clearAll();
});
it("test_addTranscription", () => {
transcriptionStore.addTranscription({
text: "Hello world",
user_name: "TestUser",
timestamp: "12:00:00",
});
expect(transcriptionStore.items.length).toBe(1);
expect(transcriptionStore.items[0].text).toBe("Hello world");
expect(transcriptionStore.items[0].userName).toBe("TestUser");
expect(transcriptionStore.items[0].timestamp).toBe("12:00:00");
expect(transcriptionStore.items[0].isPreview).toBe(false);
});
it("test_clearAll", () => {
transcriptionStore.addTranscription({ text: "One" });
transcriptionStore.addTranscription({ text: "Two" });
expect(transcriptionStore.items.length).toBe(2);
transcriptionStore.clearAll();
expect(transcriptionStore.items.length).toBe(0);
});
it("test_getPlainText", () => {
transcriptionStore.addTranscription({
text: "Hello",
user_name: "Alice",
timestamp: "10:00",
});
transcriptionStore.addTranscription({
text: "World",
user_name: "Bob",
timestamp: "10:01",
});
const text = transcriptionStore.getPlainText();
expect(text).toContain("[10:00] Alice: Hello");
expect(text).toContain("[10:01] Bob: World");
// Lines separated by newline
expect(text.split("\n").length).toBe(2);
});
});

View File

@@ -1,7 +1,7 @@
"""Version information for Local Transcription."""
__version__ = "1.4.8"
__version_info__ = (1, 4, 8)
__version__ = "2.0.8"
__version_info__ = (2, 0, 8)
# Version history:
# 1.4.0 - Auto-update feature:

View File

@@ -10,6 +10,7 @@ export default defineConfig({
alias: {
$lib: path.resolve("./src/lib"),
},
extensions: [".svelte.ts", ".ts", ".svelte", ".js", ".mjs", ".mts"],
},
server: {
port: 1420,
@@ -18,4 +19,8 @@ export default defineConfig({
ignored: ["**/src-tauri/**", "**/client/**", "**/server/**", "**/backend/**", "**/gui/**"],
},
},
test: {
environment: "jsdom",
include: ["src/**/*.test.ts"],
},
});