* Phase 3 Plan 03-01: Supertonic-3 engine on SubprocessBackend
Adds Supertonic-3 as a 7th opt-in TTS engine on the Phase 2
SubprocessBackend primitive. Closes TTS-01..06 (REQUIREMENTS.md):
* TTS-01 — _REGISTRY["supertonic3"] resolves to Supertonic3Backend,
a SubprocessBackend subclass.
* TTS-02 — `supertonic==1.3.1` lives under [project.optional-dependencies];
default `uv sync --no-dev` does NOT install it. Exactly one
`onnxruntime` row in `uv pip list` after `--extra supertonic`.
* TTS-03 — Model revision pinned by 40-char commit SHA
(724fb5abbf5502583fb520898d45929e62f02c0b — the "Initial
Supertonic 3 release" SHA, same as the SDK's own pin).
Resolver script for intentional bumps:
scripts/resolve_supertonic3_sha.py.
* TTS-04 — Honest CPU-only reporting. `is_available()` message says
"ready (CPU-only via onnxruntime)" and never mentions
"cuda" or "mps". `gpu_compat = ("cpu",)`.
* TTS-05 — License gate via settings_store helpers
(get/set_license_accepted) + Loopback-only
/api/settings/license endpoint + SupertonicLicenseDialog
frontend modal showing MIT (code) and OpenRAIL-M (model).
Wired into EngineCompatibilityMatrix as an "Accept license"
button on rows whose `reason` mentions "license not
accepted".
* TTS-06 — 3 langs (en/ja/ru) × 3 sec smoke test in
tests/test_supertonic3.py::test_smoke_3langs_3sec
(OMNIVOICE_SMOKE-gated; asserts no onnxruntime-gpu row
post-synthesize).
Package legitimacy gate (Task 1 in plan): supertonic on PyPI verified
to be published by Supertone Inc. (ato@supertone.ai), repo
github.com/supertone-inc/supertonic, wheel is pure-Python with no
postinstall scripts. Same publisher ships supertonic-js on npm under
the same maintainer email.
Test results:
* tests/test_supertonic3.py — 10 passed, 3 skipped (network-gated).
* tests/smoke/ — 4 passed.
* tests/ (full, --ignore=tests/manual) — 412 passed, 0 failed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(tests): uv sync --all-extras so optional-engine tests can import their package
Phase 3 added `supertonic` as an optional dependency. The CI Tests job
runs `uv sync` (no extras), so `test_cpu_only_honest` and `test_license_gate`
in tests/test_supertonic3.py hit the "supertonic package not installed"
fallback instead of the real import path, and fail.
Bare `uv sync` is the right default for users (engines are opt-in), but
the test environment should exercise the full surface. `--all-extras`
keeps the smoke job lean (still bare `uv sync`) while letting Tests
verify the integrated behavior of every optional engine.
Future-proofs against the same failure mode in Phase 4 (GGUF) and any
later optional engines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Phase 2 Plan 02-04: GET /engines/{id}/health + gpu_compat + HF mask
ENGINE-06 backend half. Adds the data + spawn-on-demand endpoint the new
Engine Compatibility Matrix UI will consume:
* `gpu_compat: tuple[str, ...]` class attribute on `TTSBackend`, overridden
per backend with reasonable defaults (cuda+mps+cpu for OmniVoice/VoxCPM2;
cpu-only for KittenTTS; mps+cpu for MLX-Audio; etc.). `list_backends()`
serializes it as a list.
* `_HF_TOKEN_MASK_RE` (`hf_[A-Za-z0-9]{30,}`) scrubs the `reason` and
`last_error` fields before they leave the registry — Phase 1's
HFTokenRedactor logging filter does not run on FastAPI response bodies,
so this closes T-02-12.
* `GET /engines/{engine_id}/health` — loopback-gated route that resolves
the backend across tts/asr/llm registries, then either calls
`SubprocessBackend.health_check()` (spawn-and-ping) for subprocess
engines or falls back to `is_available()` for in-process engines.
Returns `{ id, ok, message, latency_ms }`. Engine instances are cached
per-class so repeated checks don't leak atexit hooks or spawn extra
sidecars. The masked-redactor is reapplied on the way out.
Test coverage (tests/backend/api/test_engines_route_shape.py, 11 tests):
* Response shape includes the new fields for every TTS entry
* IndexTTS2 isolation_mode == "subprocess", OmniVoice == "in-process"
* Health route round-trips with mocked SubprocessBackend success
* Health route falls back to is_available for in-process backends
* Unknown engine id → 404
* Non-loopback origin → 403
* Engine instance cache reuses the singleton across calls
* HF tokens leaked into is_available() / health_check() are masked
in both the /engines and /engines/{id}/health response bodies
Existing tts_backend_registry shape test updated to include `gpu_compat`.
Full suite: 402 passed, 0 failures (up from 391+ baseline).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Phase 2 Plan 02-04: EngineCompatibilityMatrix UI + Settings wiring
ENGINE-06 frontend half. Mounts a new component on Settings → Engines
that surfaces, end-to-end, the data shape Plan 02-01 + Plan 02-03 added
to the backend registry:
* `frontend/src/components/EngineCompatibilityMatrix.jsx` (270 lines) —
semantic <table> with role=row/cell so RTL queries work; one row per
registered backend. Columns:
- Engine name + install hint + Last error line
- Install state badge (Available / Unavailable + inline reason)
- GPU compat chips (CUDA / MPS / ROCm / CPU with colored variants)
- Isolation mode badge (subprocess for IndexTTS, in-process for the
rest — makes the Phase 2 architectural shift legible to users)
- "Test engine" button → `/engines/{id}/health` round-trip; renders
latency in ms inline next to the button; disabled while inflight;
5 s cooldown to prevent click-storms.
Mount does NOT auto-test any engine — per the plan's Open Question #2,
spawning sidecars is gated on user action.
* `frontend/src/components/EngineCompatibilityMatrix.css` — minimal
styling that reuses chrome tokens; chip colors per GPU target.
* `frontend/src/api/engines.ts` — `getEngineHealth(id)` client function
wraps the new backend route through the shared apiJson helper.
* `frontend/src/api/types.ts` — extends EngineBackend with optional
`isolation_mode`, `last_error`, `install_hint`, `gpu_compat` so the
TypeScript surface tracks the backend wire shape, and adds
EngineHealthResponse.
* `frontend/src/pages/Settings.jsx` — replaces the hand-rolled Engines
table inside EnginesTab with `<EngineCompatibilityMatrix family="tts"
onSelect={...} />`. selectEngine still wires up the picker; the
matrix's onSelect prop renders the Use button per row when provided.
Removes the now-unused FAMILY_META local map.
Test coverage (`frontend/src/test/EngineCompatibilityMatrix.test.jsx`,
8 tests via vitest):
* Renders one row per backend with documented columns
* isolation_mode badge: subprocess for IndexTTS2, in-process for
OmniVoice / KittenTTS
* GPU compat chips: omnivoice → cuda/mps/cpu; kittentts → cpu only
* Unavailable rows render the failure reason inline
* last_error line renders below status when populated; masked HF
token sentinel survives verbatim
* Test engine click fires getEngineHealth(id) and renders latency_ms
* Test button disabled while inflight; second click is a no-op
* Failure path (ok=false) renders a failure marker
Frontend suite: 65 passed (8 new). Lint: 0 new errors. typecheck:ci: clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Phase 2 Plan 02-04: SUMMARY
Recap of Engine Compatibility Matrix delivery — backend route +
gpu_compat metadata + HF-token redaction, frontend EngineCompatibility-
Matrix component, full test counts, deviations, gpu_compat confidence
matrix, frontend test-runner command notes for Phase 6 CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrates IndexTTS-2 off the in-process import path and onto the
SubprocessBackend primitive shipped in Plan 02-01. Closes issue #42 with
a structural fix — the parent's transformers>=5.3 and IndexTTS's
transformers<5 now live in separate OS processes and can never collide.
* New: backend/engines/indextts/ — sidecar package (__init__.py hosts
IndexTTS2Backend, main.py is the sidecar entrypoint, bootstrap.py owns
the 3-step venv probe + lazy uv-based bootstrap).
* services.tts_backend: IndexTTS2Backend's in-process body removed;
registry resolves the class lazily via a _LazyRegistry indirection +
PEP 562 __getattr__ re-export. This breaks the import cycle that
arose when both subprocess_backend and tts_backend tried to import
each other at module load.
* docs/engines/indextts.md: install walkthrough + venv resolution order
+ common errors (linked from is_available()'s unavailable message).
* tests:
- test_indextts_backward_compat.py (8) — probe priority, no-spawn
discipline, HF cache marker preservation (ENGINE-07).
- test_indextts_sidecar.py (17) — subclass shape, isolation_mode,
parent-side emotion arbitration (vector/audio/text/description),
coexist-with-OmniVoice (headline #42 closure), env forwarding.
- tests/fixtures/mock_indextts_sidecar.py — stdlib-only sidecar
mimicking the production wire protocol; emits 1 s sine wave.
- test_issue_fixes.py: two obsolete in-process-conflict tests rewritten
to assert the new subprocess contract (no indextts.* import in the
parent).
Hard constraints honored: backend/services/sonitranslate.py and
gpu_sandbox.py are untouched (D1 / D4). Existing v0.2.7 users with
OMNIVOICE_INDEXTTS_DIR and a populated HF cache reach a working
generation with zero re-download and zero re-install.
44 tests pass across the four exercised files. Full suite: 391 passed,
10 skipped, 13 xfailed, 1 xpassed in 57 s. Smoke: 4 passed.
Closes#42. Requirements: ENGINE-02, ENGINE-03, ENGINE-04, ENGINE-07.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Phase 2 Plan 02-01: SubprocessBackend primitive + echo sidecar + ENGINE-05 wrap
Lands the durable SubprocessBackend primitive — the architectural keystone
that Plans 02-03 (IndexTTS migration), Phase 3 (Supertonic-3), and
Phase 4 (GGUF / Singing) plug into.
Files added:
- backend/services/subprocess_backend.py — base class owning spawn,
shutdown, _send/_recv (length-prefixed JSON), GPU-slot acquire-release,
atexit teardown, stderr drain, op allowlist (T-02-04), and 64 MB
frame cap (T-02-01). No multiprocessing — subprocess.Popen
exclusively so subclasses can target a *different* venv's interpreter
(Locked Decision D4 / Pitfall 1).
- backend/engines/_echo/main.py — permanent CI regression sidecar.
Stdlib-only, runs under the parent's sys.executable. Implements
ready/ping-pong/synthesize/shutdown plus test-only probe_env and
emit_unknown ops for env-forwarding and op-allowlist tests. DO NOT
DELETE — the round-trip test depends on this file.
- tests/backend/services/test_subprocess_backend.py — 13 tests:
round-trip, health_check, no-zombie, shutdown idempotency, env
forwarding (HF_TOKEN/HF_HOME/HF_ENDPOINT/HF_HUB_CACHE), oversize
frame, short read, op-allowlist drop, op-allowlist constant shape,
sidecar-crash recovery, no-multiprocessing grep gate, MAX_FRAME_BYTES.
- tests/backend/services/test_tts_backend_registry.py — 6 tests for
list_backends() resilience + shape + isolation_mode + last_error
caching + existing-engines preservation + install_hint passthrough.
Files modified:
- backend/services/tts_backend.py:
* Adds module-level _LAST_ERRORS dict for ENGINE-06.
* Rewrites list_backends() to wrap each is_available() in try/except
so one broken engine cannot blank the picker (ENGINE-05).
* Adds last_error + isolation_mode keys to each response entry
(ENGINE-06 UI in Plan 02-04 consumes via the same /engines route).
* Uses a duck-typed _is_subprocess_isolated marker rather than
issubclass(cls, SubprocessBackend) because test fixtures (token
resolver suite) purge sys.modules["services"] between tests and the
re-imported SubprocessBackend would be a different class object.
Threat-model mitigations (Plan 02-01 frontmatter):
T-02-01 DoS via length-prefix → MAX_FRAME_BYTES = 64 * 1024 * 1024
T-02-02 GPU slot leak on sidecar death → try/finally in generate
T-02-03 token bytes in stderr → drained via parent logger
(HFTokenRedactor from Phase 1 already on root)
T-02-04 unknown ops from compromised sidecar → PARENT_INBOUND_OPS
allowlist, unknown frames logged and dropped
T-02-05 Tauri group-kill scope → start_new_session=True on Unix /
CREATE_NEW_PROCESS_GROUP on Windows
Verification:
- 337 passed, 6 skipped, 12 xfailed, 1 xpassed (full suite,
`uv run pytest tests/ --ignore=tests/manual`)
- All 19 new tests pass on macOS Apple Silicon
- Smoke tests still pass: `uv run pytest tests/smoke/ -q` → 4 passed
- SoniTranslate untouched (D1 locked decision)
- Zero new Python dependencies
Closes part of ENGINE-01 + ENGINE-05.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(02-01): plan summary — public API, invariants, deviations
Documents the SubprocessBackend public API so Plan 02-03 (IndexTTS) and
Phase 3 (Supertonic-3) authors don't need to re-read the source.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Phase 2 02-02: add _safe_torchaudio_save + _safe_soundfile_write helpers
Centralizes WAV/audio writes through a single audited path that defends
against the four documented torchaudio.save failure modes (CUDA/MPS
tensor, non-contiguous, out-of-range, wrong dtype) AND the torchaudio
2.9+ TorchCodec-delegation behavior drift.
* services/audio_io.py:_safe_torchaudio_save now performs:
- .cpu() move (torchaudio cannot serialize CUDA/MPS)
- dtype coercion to torch.float32
- .clamp(-1.0, 1.0) (out-of-range = silent clipping on some backends)
- .unsqueeze(0) for 1D (mono) inputs
- .contiguous() (torch.cat of slices = non-contig = silent corruption)
- explicit encoding="PCM_S/PCM_F" + bits_per_sample so future
torchaudio backend selection cannot drift the on-disk format
- format passthrough for wav/flac/mp3/ogg with encoding-kwarg fallback
for older codec builds
* services/audio_io.py:_safe_soundfile_write — sibling helper for the
one sf.write call site (dub_core.py). Applies the same dtype/contig/
range checks before delegating to soundfile.write.
* services/audio_io.py:atomic_save_wav (existing P0 helper) now
delegates the actual encode to _safe_torchaudio_save so atomicity
and correctness compose: every byte that lands at the target path
was produced by the audited helper.
* tests/backend/services/test_audio_io.py — 29 tests (25 pass + 4
skipped for MPS dtype incompatibility): parametric round-trip across
dtype x device x contiguity, plus out-of-range clamp, format
passthrough, in-memory buffer, empty-tensor rejection, 1D auto-
unsqueeze, and a smoke check that atomic_save_wav inherits the
safety guarantees.
No new Python dependencies. SoniTranslate untouched (D1 locked).
Refs BUG-01 / #48.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Phase 2 02-02: migrate router audio writes through audited helpers
Migrates all 12 grep-audit bare audio-write call sites in
backend/api/routers/ to route through services.audio_io. Closes the
last surface area of BUG-01 / #48 that the P0 atomic-write commit
(fb52140) did not cover.
Sites migrated (grep before → after):
generation.py:148 torchaudio.save → _safe_torchaudio_save
generation.py:162 torchaudio.save → _safe_torchaudio_save
openai_compat.py:155 torchaudio.save → _safe_torchaudio_save
openai_compat.py:160 torchaudio.save → _safe_torchaudio_save
openai_compat.py:168 torchaudio.save → _safe_torchaudio_save
openai_compat.py:172 torchaudio.save → _safe_torchaudio_save
openai_compat.py:178 torchaudio.save → _safe_torchaudio_save
openai_compat.py:182 torchaudio.save → _safe_torchaudio_save
openai_compat.py:193 torchaudio.save → _safe_torchaudio_save
dub_generate.py:509 torchaudio.save → _safe_torchaudio_save
batch.py:341 torchaudio.save → atomic_save_wav (track assembly)
dub_core.py:438 sf.write → _safe_soundfile_write
batch.py:341 specifically swapped to atomic_save_wav (not just the safe
helper) because it writes the final track to disk — same shape as
dub_generate.py:390 — and needs atomic publication, not only audited
encoding. atomic_save_wav already delegates internally to
_safe_torchaudio_save (per the Task 1 commit) so it inherits both
guarantees.
openai_compat.py:185 pcm branch produces raw int16 bytes (no
container), so it can't go through _safe_torchaudio_save; it now
inlines the same .cpu/.float32/.clamp/.contiguous sanity steps the
helper enforces.
tests/backend/test_dub_pipeline_wav.py:
- test_no_bare_audio_writes_in_routers (in-process grep gate)
- test_no_bare_audio_writes_via_subprocess_grep (CI-shell parity gate)
- test_track_assembly_handles_non_contig_after_torch_cat (the #48
smoking-gun reproduction — torch.cat of out-of-range non-contig
slices saved through the helper)
- test_atomic_save_wav_assembly_pattern (same shape, via
atomic_save_wav)
- test_safe_soundfile_write_dub_core_pattern (ASR transcribe-chunk
pattern from dub_core.py)
- test_dub_pipeline_produces_valid_wav (xfailed — Phase 0 fixture
sample_5s.mp4 not present; structural reproduction tests above
already cover the helper code path #48 went through)
Grep gate is green:
grep -nE '(torchaudio\.save|soundfile\.write|sf\.write)\(' \
backend/api/routers/ -r --include='*.py' \
| grep -v '_safe_torchaudio_save\|_safe_soundfile_write' \
| grep -v '^[^:]*:[[:space:]]*#' \
returns 0 lines.
Full suite green: 348 passed, 10 skipped, 13 xfailed, 1 xpassed.
SoniTranslate untouched (D1 locked).
Closes BUG-01 / #48.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Phase 2 02-02: add execution summary
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(install): per-OS install pages + drift validator + CI gate
Splits the 600-line README install section into self-contained per-OS docs
under docs/install/{macos,windows,linux,docker}.md plus a Top-10
troubleshooting index. Each OS doc is end-to-end: a user opens it and
reaches a working app following only commands inside that file.
Adds:
- docs/install/{macos,windows,linux,docker}.md (OS-specific install paths)
- docs/install/troubleshooting.md (top 10 install errors)
- docs/engines/cosyvoice.md (closes#55 docs half)
- docs/features/diarization.md (pyannote license flow)
- docs/setup/huggingface-token.md (3-source cascade guide)
- scripts/validate-install-docs.py (INST-06 docs-drift gate)
- tests/scripts/test_validate_install_docs.py (B-5: validator self-tests)
- .github/workflows/ci.yml step running the validator on every PR
Implements INST-02 (README routing), INST-03 (macOS Gatekeeper anchor),
INST-12 docs half (Windows torch-compile-oom anchor), DOCS-01..05.
The validator is a one-way diff: every `<!-- validate -->`-tagged line
in docs must appear in scripts/desktop-prod.sh after normalisation
(prompt-prefix strip, CRLF, trailing whitespace, blank-and-comment skip).
A `<!-- validate: skip -->` marker opts out for human-readability blocks.
Its own 10 unit tests catch regressions in the gate itself.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(deeplinks): links.py + error_docs_map (Python + TS mirror)
Adds the single source of truth for the project repo URL and the 4-class
error → docs taxonomy that both the in-app ErrorBoundary deeplink button
(Wave 2 Task 3) and the Phase 5 bug reporter will consume.
New:
- backend/core/links.py — PROJECT_REPO_URL + BLOB_MAIN resolver
(Tauri config first, pyproject fallback)
- backend/core/error_docs_map.py — lookup(error_class) → docs URL
- frontend/src/utils/errorDocsMap.ts (TS mirror with classifyError helper)
- tests/backend/core/test_links.py + test_error_docs_map.py
- frontend/src/utils/errorDocsMap.test.ts
Resolves checker B-6 (links.py ownership) and Open Question #3 (which fork
the deeplinks resolve to — the Tauri updater endpoint wins, which points
at the desktop app fork debpalash/OmniVoice-Studio).
The TS BASE constant is documented as the second hardcoded URL drift site;
the keys-sync test (`test_keys_match_python_map` equivalent) guards the
4-class taxonomy contract between Python + TS halves.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ui): Settings → API Keys panel + ErrorBoundary docs deeplink
Wave 2 AUTH-03 UI half + ErrorBoundary deeplink wiring.
ErrorBoundary fallback now renders an "Open docs for this error" button
that classifies the thrown Error message (heuristic: pkg_resources → 401 /
HfHubHTTP → WebKit / white screen → quarantine / Gatekeeper) and opens the
matching docs anchor via Tauri shell.open (with a window.open fallback
in browser dev mode).
ApiKeysPanel consumes the Wave 1 resolver state endpoint:
- 3 source rows (App / Env var / HF CLI) with set/unset indicator,
masked token preview, whoami username + green check
- "Active" badge on whichever source is currently serving the cascade
- App-row only: Save (POST /api/settings/hf-token) +
Clear (DELETE with optional "also clear HF CLI" confirm dialog)
- "Test now" button refetches state (invalidates the resolver's
validation cache via the same endpoint hit)
Panel mounted in the existing Settings → Credentials tab; the legacy
HF_TOKEN row from CREDENTIAL_FIELDS is filtered out so the two paths
don't fight over the same key.
Threat T-02-02: the panel never displays the full token. The masked
value comes from the resolver state endpoint; the full token only
crosses the IPC boundary on Save (POST) and is cleared from local
state on success.
Closes AUTH-03 fully (Wave 1 backend + this Wave 2 UI).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(perf): INST-12 Disable torch.compile (Windows) toggle (backend + UI)
Wave 2 Task 4 — full INST-12 delivery per checker B-2/B-7 v0.3.0 fat-release
decision. Both the docs half (windows.md anchor, shipped in earlier commit)
and the runtime toggle are now in Phase 1.
Backend:
- backend/services/settings_store.py: adds get_text/set_text helpers for
non-secret config (refuses to write to the encrypted hf_token key).
- backend/api/routers/settings.py: GET + PUT
/api/settings/perf/torch-compile-disabled, both under the existing
loopback guard (threat T-02-04).
- backend/services/engine_env.py: new `build_engine_env()` helper that
centralises HF_TOKEN/YOUR_HF_TOKEN injection from the 3-source resolver
AND injects TORCH_COMPILE_DISABLE=1 when the flag is set on win32.
Phase 2 SubprocessBackend launchers should adopt the same helper.
- backend/services/sonitranslate.py: migrated to engine_env.build_engine_env()
while preserving the source-level `env["HF_TOKEN"]` sentinel that
test_sonitranslate_module_uses_resolver checks.
Frontend:
- frontend/src/components/settings/PerformancePanel.{jsx,css,test.jsx}:
toggle UI with the explainer for #65; renders disabled with a "not
applicable" badge on macOS/Linux.
- frontend/src/pages/Settings.jsx: mounts the panel into the Credentials
tab alongside the API Keys panel.
Tests:
- tests/backend/test_perf_settings.py: 7 backend tests (default state,
PUT persistence, T-02-04 non-loopback rejection, settings_store round-
trip, env injection on win32, NO injection on macOS/Linux, NO injection
when disabled).
- frontend PerformancePanel.test.jsx: 5 tests (renders from GET state,
PUT on toggle, disabled on non-Windows platforms, pre-enabled state).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(planning): Wave 2 SUMMARY + REQUIREMENTS status updates
- .planning/phases/01.../01-02-SUMMARY.md: full implementation report
per template (truths, commits, tests, deviations, drift-site
acknowledgments per W-3, launcher seam name for Phase 2,
taxonomy keys for Phase 5).
- .planning/REQUIREMENTS.md: flips Wave 2 closures to Done:
AUTH-03, INST-02, INST-03 (docs half), INST-06, INST-12,
DOCS-01..05.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The smoke test used `os.environ.setdefault()` to point at the frozen
fixture, which silently skipped when a prior test in the suite had
already set the env var. Combined with `core.config` caching `DB_PATH`
at module import time, this left smoke tests pointed at the wrong DB
once Wave 1's services tests pre-imported `main` with their own temp
state.
Exposed by Wave 2's additional tests (PR #94) pushing collection order
past the tipping point, but the underlying pollution existed since Wave
1 merged — Wave 3 CI passed only by collection-order luck.
Fix mirrors the `sys.modules` purge pattern that
`tests/backend/services/conftest.py` already uses.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(appimage): conditional WEBKIT_DISABLE_COMPOSITING_MODE launcher (#56)
WebKitGTK 2.44.x and 2.46.x have a compositing-path regression on Wayland
that blanks the AppImage's first paint on Fedora 44 / Ubuntu 24.04. Setting
WEBKIT_DISABLE_COMPOSITING_MODE=1 forces the software fallback that works,
but blindly setting it on healthy WebKit versions (2.48+) regresses those.
This wave adds a conditional AppRun launcher that detects the WebKit
version via pkg-config and only sets the env var on the broken ranges
(plus a fail-safe when pkg-config is absent or the version is unknown).
The launcher is injected into Tauri's AppImage staging dir via a
beforeBundleCommand hook — see .planning/decisions/apprun-strategy.md for
the spike outcome and rationale (Strategy B chosen).
Phase 1 Wave 3 — Plan 01-03 Task 1. Closes#56 frontend half.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(deb): relocate bundled ffprobe out of /usr/bin to avoid conflicts (#76)
Prior versions placed the bundled ffprobe at /usr/bin/ffprobe via Tauri's
externalBin, which overwrites the system ffprobe on Ubuntu 26.04 and
collides with apt-installed media-package ffprobe.
Relocate the .deb-bundled ffprobe to /usr/lib/omnivoice-studio/bin/ffprobe
via bundle.linux.deb.files, plus defensive maintainer scripts:
- preinst: ensure target dir exists for upgrade flows
- postinst: remove legacy /usr/bin/ffprobe ONLY when dpkg confirms our
package owns it (never touches a user's distro ffprobe)
- postrm: clean up the relocated path tree on purge/remove
Rust side (tools.rs::resolve_ffprobe) now probes the new path on Linux,
and backend spawn (backend.rs) carries both FFPROBE_PATH (legacy alias)
and OMNIVOICE_FFPROBE_PATH (canonical) into the backend env. Python side
(ffmpeg_utils.resolve_ffprobe) reads OMNIVOICE_FFPROBE_PATH first, falls
back to FFPROBE_PATH, then to shutil.which("ffprobe").
6 new unit tests cover the env-cascade resolution.
Phase 1 Wave 3 — Plan 01-03 Task 2. Closes#76.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(frontend): centralised apiBase resolver for Docker LAN access (#80)
Docker / LAN browser users hit the preview API at the LAN host's IP, not
their local machine — the prior frontend/src/utils/media.js:20 hardcoded
http://localhost:3900, which from a LAN client resolved to the client
machine itself.
Centralise via frontend/src/utils/apiBase.ts:
1. VITE_OMNIVOICE_API override (Docker compose / dev) always wins.
2. Tauri webview → http://localhost:3900 (unchanged behaviour).
3. Plain browser → ${window.location.protocol}//${window.location.hostname}:3900
(follows the page's origin — closes#80).
4. SSR / no-window → http://localhost:3900 (safe fallback).
Grep-sweep confirmed media.js:20 was the only hardcode site (Assumption
A4 in 01-RESEARCH.md verified). 6 new vitest cases cover the resolver.
Phase 1 Wave 3 — Plan 01-03 Task 3. Closes#80 frontend half.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(backend): macOS Gatekeeper quarantine probe + INST-01 guard (#54)
Adds backend/core/gatekeeper_detect.py which walks up from sys.executable
to find the .app bundle and runs `xattr -l` to check for the quarantine
extended attribute (com.apple.quarantine). On detection, the lifespan
startup probe logs a structured warning and emits a system_error event
through the existing event bus with error_class="GATEKEEPER_QUARANTINE",
which Wave 2's React ErrorBoundary turns into a docs deeplink.
Detection is informational only — we never auto-run `xattr -cr` (the app
itself is quarantined and cannot fix its own state per Anti-Pattern in
01-RESEARCH.md). Users get a clear pointer to the workaround docs.
GET /system/quarantine-status exposes the structured payload so the
frontend can poll on first load.
INST-01 (setuptools>=75.0 pin from PR #62) gains a PR-time guard in
tests/backend/test_pyproject.py + a user-observable smoke check in
scripts/smoke-test.sh (pkg_resources + whisperx import).
7 gatekeeper tests + 1 pyproject test added — all pass.
Phase 1 Wave 3 — Plan 01-03 Task 4. Closes#54 backend half (Wave 2 owns
the docs page + ErrorBoundary deeplink wiring).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 0 was verified PASS against `main` (7/7 truths, 9/9 artifacts, 6/6 GATE
requirements, 5/5 success criteria; live smoke `tests/smoke/` green in 1.73s).
Add the verifier's report and reconcile the REQUIREMENTS tracker — GATE-01..06
and AUTH-01..06 now show Done now that PR #71 (Phase 0) and PR #91 (Phase 1
Wave 1) are both on `main`.
AUTH-03 is split: backend endpoints landed in Wave 1, UI ships in Wave 2.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(01-01): encrypted settings store + alembic migration (AUTH-02, T-01-01)
Adds the SQLite-backed encrypted settings store that Phase 1 token resolver
will read from. Closes the at-rest plaintext risk for HF tokens (T-01-01).
- backend/services/settings_store.py: get_hf_token / set_hf_token /
clear_hf_token using Fernet symmetric AEAD. Stored value column never
contains the literal "hf_" substring.
- backend/services/_secret_key.py: per-install Fernet key derived via
scrypt(machine-id + 16-byte random salt). machine-id resolution covers
macOS (ioreg IOPlatformUUID), Linux (/etc/machine-id and dbus fallback),
Windows (HKLM Cryptography MachineGuid via winreg). Final fallback to
hostname+user with a warn log.
- backend/migrations/versions/0001_phase1_settings_table.py: alembic
migration adding `settings(key, value, updated_at)`. Idempotent — checks
for an existing table so fresh installs (where _BASE_SCHEMA already
created it) and v0.2.7 upgrades both succeed.
- backend/core/db.py: _BASE_SCHEMA grows the settings table for fresh
installs; init_db() now runs `alembic upgrade head` after the CREATE.
- backend/migrations/env.py: honours an externally-set sqlalchemy.url so
tests can point alembic at a fixture DB; falls back to core.config
DB_PATH for production.
- pyproject.toml: cryptography>=41 added explicitly (RESEARCH.md
Assumption A1 was checked at execute-time and proved false; the dep was
not present transitively, so the install would fail without this).
Tests (10 cases, all green):
- Round-trip encryption + plaintext-leakage check (T-01-01 invariant)
- Salt persistence across clear/set cycles
- InvalidToken decrypt path returns None (Open Question #5 resolution)
- Concurrent reads consistent under sqlite WAL
- Alembic upgrade on a hand-built v0.2.7 fixture DB preserves all
existing tables + seeded rows (CLAUDE.md backward-compat constraint)
- Alembic downgrade -1 drops only the settings table
Refs #35.
* feat(01-01): 3-source HF token resolver + log redactor + 5 read sites patched
Closes the #35 bug class (bare os.environ.get('HF_TOKEN') reads) by routing
every backend HF-token consumer through one resolver, and mitigates
T-01-02 (info disclosure via logs) by stripping `hf_[A-Za-z0-9]{30,}`
substrings from every log record at the root logger.
backend/services/token_resolver.py:
- resolve(skip) — 3-source cascade (App → Env → HF-CLI), each source
validated via huggingface_hub.whoami(); first valid wins.
- on_401(active) — invalidate cache and re-resolve skipping the source
that just 401'd (AUTH-06).
- state() — three SourceState rows for the Settings UI: set,
masked preview (hf_…<last 3>), whoami_user, whoami_ok.
- save_app_token / clear_app_token — wraps settings_store + calls
huggingface_hub.login(add_to_git_credential=False) per Pitfall #2.
- 300-second whoami cache so repeated Settings-page renders don't hit
the HF API.
backend/core/logging_filter.py:
- HFTokenRedactor(logging.Filter) — regex `hf_[A-Za-z0-9]{30,}` so real
tokens are masked but `hf_hub` / `hf_token` literals survive.
- install_redaction_filter() — idempotent attach to root + every handler.
backend/main.py: install the redactor at startup, BEFORE the file
handler is added. Re-installed after the file handler attaches so the
handler-attached filter list includes it too.
Read-side call sites patched (per Pitfall #1 — every HF token read must
flow through token_resolver.resolve()):
- backend/api/routers/dub_core.py:540 (the original #35 site)
- backend/api/routers/system.py:38 (_has_hf_token notification)
- backend/services/model_manager.py:480 (diarization pipeline auth)
- backend/services/sonitranslate.py:143 (Popen env for SoniTranslate child)
- backend/services/sonitranslate.py:217 (gradio_client predict call)
New endpoint:
- GET /system/hf-token/state — returns the 3-source cascade state with
masked tokens for the Wave 2 Settings UI panel.
Grep gate confirmed clean: zero `os.environ.get("HF_TOKEN")` reads remain
outside token_resolver.py.
Tests (17 new cases, all green):
- tests/backend/services/test_token_resolver.py: priority cascade, 401
skip mid-resolve, on_401 fallback, state() shape, save+login
invariant (add_to_git_credential=False), HUGGING_FACE_HUB_TOKEN
alias acceptance.
- tests/backend/core/test_logging_filter.py: msg + args redaction,
multi-token redaction, non-string args pass-through, short-token
literals preserved, install_redaction_filter idempotence.
Refs #35.
* feat(01-01): Settings hf-token API endpoints + subprocess env injection (AUTH-03/04)
Backend half of the Wave 2 Settings → API Keys UI plus the AUTH-04
subprocess env-injection invariant.
backend/api/routers/settings.py:
- POST /api/settings/hf-token — body {token: str} → save_app_token
- DELETE /api/settings/hf-token — also_clear_hf_cli query → clear_app_token
- GET /api/settings/hf-token/state — same shape as token_resolver.state()
All three are gated by `Depends(require_loopback)` at the router level
(threat T-01-03 mitigation; non-loopback Host → 403).
backend/main.py: router mounted alongside existing API routers.
Subprocess env injection (AUTH-04, threat T-01-04 disposition=accept):
- backend/services/sonitranslate.py already updated in Task 2 to read
via token_resolver.resolve() and inject HF_TOKEN + YOUR_HF_TOKEN into
the SoniTranslate child env block.
- backend/services/gpu_sandbox.py: NOT patched — the GPU sandbox runs
in-process TTS generation that uses the parent's already-loaded HF
state. Adding env injection there is a no-op (parent and child share
state via multiprocessing.Pipe before any HF API call).
- backend/services/model_manager.py:480 (Task 2): resolves in-process,
no subprocess crosses here.
- backend/api/routers/exports.py: subprocess.Popen calls only spawn
`open` / `explorer` / `xdg-open` — file-manager launchers with no
HF needs. Skipped per Task 3 conservative-patching rule.
So the canonical AUTH-04 site for this milestone is sonitranslate.py.
Future SubprocessBackend work in Phase 2 will inherit the same pattern.
Tests (8 new cases, all green):
- tests/backend/test_engine_spawn_token.py
* POST /hf-token loopback → 200 + state.active == "app"
* POST /hf-token non-loopback → 403 ("loopback origin required")
* DELETE /hf-token clears settings_store + state.active == None
* GET /hf-token/state returns 3 source rows in priority order
* GET /hf-token/state non-loopback → 403
* env block contains HF_TOKEN + YOUR_HF_TOKEN when resolver returns one
* env block does NOT contain an injected empty HF_TOKEN when resolver
returns None
* source-level check that backend/services/sonitranslate.py still
reads via token_resolver.resolve() (regression guard against
silent reverts of the AUTH-04 wiring)
Full Wave 1 test suite: 35/35 green. Phase 0 smoke tests still green.
Refs #35.
* docs(01-01): SUMMARY + STATE update for Phase 1 Wave 1 completion
Records execution outcome of the 3-task plan: 10 files created, 9 modified,
35 new test cases, 5 read sites patched, grep gate clean. Documents the
two Rule-3/Rule-2 deviations applied (cryptography dep, env.py URL
override), the subprocess-launcher inventory for Phase 2, and the
known stray edit to the main repo's pyproject.toml that needs a one-
line user action to revert.
Updates STATE.md current-position table, progress bar, and open TODOs to
point at Wave 2 (Plan 01-02) and Wave 3 (Plan 01-03) as the next steps.
Three P0 fixes bundled — foundation cleanup before v0.3.0 phase work. Closes release.yml drift (PR #51's tabs broke v0.3.0 tag releases), production bind exposure (Critic F1), and 9-endpoint LAN gap on /system/* (Critic F2+F3). 5 new tests; 243 full pass.
Production deployment hardening, dub OOM recovery, new SoniTranslate sidecar engine, ASR backend expansion.
- Dub generation OOM recovery: backend/api/routers/dub_generate.py:163-209 adds OOM detection + one retry with reduced nstep
- New SoniTranslate sidecar engine: backend/api/routers/sonitranslate.py + backend/services/sonitranslate.py (subprocess-based dubbing pipeline, opt-in)
- ASR backends expansion: backend/services/asr_backend.py adds NeMo Parakeet TDT, Moonshine, additional Whisper variants; new GET /system/asr-backends endpoint
- Dub UI polish: tighter spacing in DubSegmentRow.css, DubTab.css
Issue #78 (speaker diarization mis-assignment) NOT addressed by this PR — the bundled diarization changes are in the new SoniTranslate sidecar, not the existing pyannote pipeline. Keeping #78 open.
No DB schema changes, no migration. Backward-compatible for existing user data.
Dictation pill widget no longer displays the idle "Ready — hold shortcut to speak" state by default. The widget now appears only when actively used (global shortcut press or tray "Start Dictation" click).
Two surgical edits to frontend/src-tauri/src/lib.rs:
1. Pill-mode setup: removed win.show() + win.set_focus() on the widget. Kept positioning so the first show appears at top-center without animation flicker.
2. Tray "dictate" handler: now positions + shows + focuses widget BEFORE emitting tray-dictate, mirroring the global-shortcut handler. Previously tray-initiated dictation would record silently with no visible UI.
Trade-off accepted: the original auto-show was intended to prevent a "looks-launch-failed" first-run experience for users without Accessibility permission. The tray icon + "OmniVoice Dictation" tooltip provide app-running signal; first-launch onboarding toast can be added later if support requests indicate confusion.
First v0.3.x release on the Phase 0 cross-platform CI baseline.
## Cross-platform bug fixes (375ea4e)
User-reported bugs from a Pinokio/Windows session:
- Docker `compose --profile gpu up` no longer port-conflicts on 3900 — restored `profiles: ["cpu"]` that #49 wrongly reverted on CodeRabbit's advice.
- Argos / pip install from the UI now works inside Docker — added `_in_virtualenv()` runtime check; `run_pip` injects `--system` automatically when on system Python.
- Speaker diarization warning toast — when pyannote silently falls back to the silence-gap heuristic (missing HF_TOKEN, license not accepted, network blocked), `_diarize()` now returns `(segments, warning)`; `useDubWorkflow` renders an 8-second toast.
## Dub editor UX (d5df454)
Six fixes per annotated screenshots:
- Editable segment start times (`m:ss.s` or raw seconds; Esc reverts, Enter commits; rejects overlap with end).
- Click a transcript row → seek the waveform/video (`WaveformTimeline` now forwardRef's `seekTo(time)`).
- Speaker is datalist-backed (pulls from detected speaker clones; free text still allowed).
- Scissors menu splits at cursor — uses live caret, then last caret, then sentence-boundary fallback.
- Mouse-wheel scrolls the waveform; Cmd/Ctrl left alone for browser pinch-zoom.
- Menu popover collision: added `avoidCollisions` + `collisionPadding=8` to Radix Content; removed `position: fixed` from `.ui-menu`.
## VRAM-aware GPU pool (73dbe18)
`_gpu_pool` was hardcoded `ThreadPoolExecutor(max_workers=1)` since introduction — every TTS forward serialized through one thread.
- CUDA / ROCm: `workers = clamp(1, free_GB // 2.5, 4)`. 16 GB card with ~14 GB free → 4 workers → ~4× throughput on multi-segment dubs.
- MPS / CPU / unknown: 1 worker.
- `OMNIVOICE_GPU_WORKERS` env var override (clamped 1..16).
- Module `__getattr__` preserves the public `_gpu_pool` symbol for existing callers.
## Stories tab — wire-up + UX (f6bbc7a)
The 264-line `StoriesEditor` component existed but was mounted nowhere. Now wired into NavRail + lazy-loaded on `mode === 'stories'`. Added Paste & Split panel (sentence-boundary chunking) and per-track `[pause 0.5s]` insertion.
## Stories — pauses + inline voice (edd3a1d)
`frontend/src/utils/storyTokens.js` — tokenizer for `[pause X.Ys]` and `[voice:X]…[voice:default]` markers. Voice switches are stateful (carry forward). 13 new vitest cases (vitest now 24/24).
## Verified
- 214 backend tests pass (3 skipped, 10 xfailed, 3 xpassed)
- 23 router-smoke tests pass
- 24/24 vitest cases pass (13 new)
- All 7 Phase 0 CI checks green (Tauri shell + Smoke on macOS/Windows/Linux + Tests)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Adds `request.client.host` allow-list check (`127.0.0.1` / `::1` / `localhost`) to `POST /system/set-env`. Non-loopback callers receive `403` instead of being able to mutate `os.environ` for HF_TOKEN / TRANSLATE_API_KEY.
Surfaced during security review of PR #66, which widens the pre-existing window by persisting these keys to disk via prefs.json. This fix closes the underlying vulnerability so PR #66's revision lands onto a clean base.
Defensive `request.client is None` branch handles ASGI middleware that strips client info. Three new tests cover non-loopback reject, loopback allow, and allow-list still validated on loopback.
Follow-up: `260518-ivy-deferred-items.md` enumerates 5 sibling POST routes in `system.py` that share the same gap — separate PR.
* docs: initialize OmniVoice stabilization milestone project
* chore: add project config (yolo + balanced)
* docs: domain research for stabilization milestone
* docs: define v1 requirements for stabilization milestone
* docs: add GGUF + singing engine spike requirements (Phase 4 new)
* docs: roadmap revision + CLAUDE.md (7 phases, 62 reqs, +GGUF/SING spikes)
* docs(phase-0): add Gates phase RESEARCH.md
Phase 0 research synthesizes the cross-platform CI matrix, frozen
omnivoice_data fixture, installer post-build smoke, SHA-256 checksum
publishing, and PR-template extension into copy-paste-ready YAML and
Python snippets composed entirely from existing in-repo patterns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(phase-0): add Gates phase CONTEXT, PATTERNS, and PLAN
Phase 0 — Gates is the hard pre-condition for v0.3.x stabilization.
Lays cross-platform CI matrix (macos-14/windows-2022/ubuntu-22.04),
regression fixture (≤200 KB), installer smoke on tag push, SHA-256
checksums in release body + per-OS SHA256SUMS-*.txt assets, PR
template with RC cadence + fixture line, and the open-PR landing
for #51.
Plan covers GATE-01..06; structured into 7 slices (A–G) with explicit
Slice C → Slice G dependency reordering so the new smoke-matrix lands
on main before PR #51 (CONTEXT.md L86 interleave decision).
Plan-checker iteration 2: APPROVED — all 3 BLOCKERs + 3 MAJORs from
iteration 1 resolved (file truncation/Slice-G missing, GATE-06 sibling
PR verification, Slice C ordering, Truth #5 wording, macOS Tauri
WebView avoidance per Pitfall #5, Windows taskkill per Pitfall #2).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(00-gates): seed regression fixture (GATE-01)
- scripts/seed-test-fixture.py — deterministic builder for tests/fixtures/omnivoice_data/
- wipes + rebuilds; fixed created_at=1700000000.0; all-zero PCM for byte-deterministic diffs
- calls backend.core.db.init_db() directly (alembic versions/ is empty — see CONTEXT.md)
- checkpoints WAL → DELETE on close so no -shm/-wal sidecars pollute git status
- exits non-zero if fixture > 200 KB
- tests/fixtures/omnivoice_data/{omnivoice.db, README.md} — 8-table empty DB + 1 voice_profiles row
- tests/fixtures/omnivoice_data/voices/test-voice/{profile.json, sample.wav} — 1-sec 24 kHz mono silence
- .gitignore — explicit allow-list (!tests/fixtures/omnivoice_data/**) so the existing
omnivoice_data/, *.db, *.wav patterns don't hide the fixture from git
Verifies: du = 144 KB on disk; sqlite_master lists 8 init_db tables + sqlite_sequence;
voice_profiles has exactly 1 row id='test-voice'; 0 rows in generation_history.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(00-gates): add tests/smoke/test_boot_smoke.py (GATE-01)
- tests/smoke/__init__.py — package marker so pytest treats tests/smoke/ as a module
- tests/smoke/test_boot_smoke.py — 4 in-process FastAPI TestClient smoke tests:
* test_health_returns_ok — /health returns 200 + {status:ok, device:...}
* test_profiles_endpoint_lists_fixture_voice — /profiles surfaces the seeded
test-voice row (validates OMNIVOICE_DATA_DIR wiring → DB_PATH → init_db schema)
* test_system_info_includes_data_dir — /system/info resolves data_dir
* test_history_endpoint_empty — /history reaches DB and returns []
Test isolation env vars (OMNIVOICE_MODEL=test, OMNIVOICE_DISABLE_FILE_LOG=1)
set at module top BEFORE any backend import — pattern from tests/test_router_smoke.py.
Fixture is copied to a per-session temp dir so the test never mutates the
checked-in artifact (SQLite file-change counter + runtime subdirs like dub_jobs/
would otherwise dirty `git status` after every run).
Failure mode: if tests/fixtures/omnivoice_data/ is missing, pytest.fail at
import time with the regenerate command.
- .gitignore — tighten the GATE-01 allow-list to ONLY the seed-produced files
(README.md, omnivoice.db, voices/test-voice/profile.json, sample.wav).
Prevents future runtime subdirs the backend may create under the fixture
from being accidentally committed.
Verifies: `uv run pytest tests/smoke/ -q --tb=short` → 4 passed in 1.31 s
(target was < 30 s). `git status` clean after a test run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(triage): record post-planning GitHub state — PR #62, new issues, OOS deferrals
- GATE-06: mark #53 + #61 merged (2026-05-16); add #62 (Wave 1 quick wins) to gate set
- INST-01: note PR #62 implements setuptools pin (closes#58)
- INST-04: note PR #62 lands README docs for #56 workaround
- INST-12: new requirement for #65 Windows Triton/torch.compile OOM (filed post-planning)
- Out of Scope: defer #67/PR #68 (audio effects), #64 (custom model dir),
PR #66 zh-CN (i18n milestone), #63 (empty-template bug)
PR #62 is the user's own Wave 1 work landed as a separate PR while
GSD planning ran in parallel. Merging it eliminates duplicate work
in Phase 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(00-gates): add cross-platform smoke matrix (GATE-02)
- New smoke-matrix job on macos-14, windows-2022, ubuntu-22.04
- needs: test, fail-fast: false, timeout-minutes: 10
- Pinned actions: checkout@v4, setup-python@v5, setup-uv@v3 (cache enabled)
- Per-OS ffmpeg + libsndfile install (brew/choco/apt via awalsh128 cache)
- UV_HTTP_TIMEOUT=120, UV_HTTP_RETRIES=5 for restricted-network resilience
- Narrow scope: uv run pytest tests/smoke/ -q --tb=short
- Existing `test` and `tauri-cross-platform` jobs untouched
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci: add workflow_dispatch to ci.yml so smoke-matrix can run on feature branches
* feat(00-gates): add --health-check CLI flag to backend entrypoint (GATE-03)
- argparse on __main__ block; --health-check boots uvicorn in a daemon
thread and polls http://127.0.0.1:3900/health every 5s for up to 60s.
- Prints 'OK — /health responded 200 after Ns' and exits 0 on first 200.
- Prints 'FAIL — /health did not respond 200 within 60s' to stderr and
exits 1 on timeout. Default invocation behavior unchanged.
- No new deps (stdlib argparse/threading/time/urllib.request/sys + uvicorn).
- Consumed by per-OS installer-smoke step in .github/workflows/release.yml.
Verified locally: exits 0 in 5s against tests/fixtures/omnivoice_data/.
* ci(00-gates): add per-OS installer smoke to release.yml (GATE-03)
Adds three matrix-leg-specific steps after 'Build + release (Tauri)',
each gated by runner.os with timeout-minutes: 5:
- macOS (macos-14): hdiutil attach DMG → locate bundled Python backend
inside *.app/Contents (NOT the Tauri WebView shell — RESEARCH Pitfall
#5: WebView hangs on headless runners) → invoke --health-check →
hdiutil detach. Falls back to *.app/Contents/Resources and hard-fails
with a directory listing if no backend binary found.
- Windows (windows-2022): msiexec /quiet install → find backend.exe
under 'C:/Program Files/OmniVoice Studio' → invoke --health-check in
background, wait, then taskkill //F //T //PID to cleanup orphaned
PyInstaller child processes on port 3900 (RESEARCH Pitfall #2).
- Linux (ubuntu-22.04): --appimage-extract (no FUSE on GH runners),
locate binary or AppRun, run under xvfb-run -a.
Bundle-only regressions (PyInstaller missing-module, Tauri sidecar
path mismatch) are invisible to ci.yml's in-process smoke matrix —
this step closes that gap before any release is published.
Verified: YAML parses; all three steps present; gating + timeout
correct; Pitfall #2/#5 mitigations preserved.
* ci(00-gates): publish SHA-256 checksums in release body + as asset (GATE-05)
- Add 'Compute SHA-256 checksums' step writing SHA256SUMS-<label>.txt
per matrix leg using native shasum/sha256sum (Git Bash on Windows).
- Add 'Append checksums to release + attach SHA256SUMS file' step using
softprops/action-gh-release@v2 with append_body: true so the hashes
land in the release body alongside tauri-action's content (not
replacing it) and the file is uploaded as a release asset for
'shasum -c SHA256SUMS-<label>.txt' verification.
- Both steps gated by 'github.event_name == push && refs/tags/v*' so
workflow_dispatch dry-runs do not attempt to attach to a non-existent
release (per CONTEXT.md L70 + RESEARCH Pitfall #7 deferral of any
aggregate cross-leg SHA256SUMS job).
- fail_on_unmatched_files: true to surface path-resolution errors loudly.
* docs(00-gates): document RC cadence + regression-fixture check in PR template (GATE-04)
* docs(setup): add HF token persistence guide for macOS/Windows/Linux (DOCS-05)
Covers two persistent paths:
- Method A — canonical ~/.cache/huggingface/token via huggingface-cli login
- Method B — shell env var (~/.zshrc / ~/.bashrc / Windows User scope)
Documents the v0.2.7 "session only" in-app behavior + notes that
Phase 1 AUTH-03 will make in-app pastes write to the canonical file.
Bundled with Phase 0 PR per user request. Strictly DOCS-05 scope —
zero code changes, no engine touches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* spec(auth): redesign HF token resolution as 3-source cascade with fallback (AUTH-01..06)
Replaces the env_store.py file-based design with a SQLite-backed app
store + cascade resolver that checks app → env var → ~/.cache/huggingface/token
in priority order, with automatic fallback to next source on HTTP 401.
User-explicit design decision:
- App-stored token (SQLite settings table, AES-GCM encrypted) wins
- Env var ($HF_TOKEN) second
- Global huggingface-cli login file third
- All three sources visible in Settings → API Keys with "Active" badge
- Save action populates BOTH app store AND canonical HF file (defense in depth)
New requirement:
- AUTH-06 — on 401, auto-retry next source in cascade before erroring
Also: traceability count corrected (62 → 74 — undercount at planning +
INST-12 + AUTH-06 added post-planning). All 74 v1 reqs mapped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(auth): backend recognizes HF token from canonical file, not just env var
Two call sites were only checking $HF_TOKEN env var, missing the canonical
~/.cache/huggingface/token file written by `huggingface-cli login` (or the
app's future Save action):
- system.py `/system/info` `has_hf_token` flag — UI showed "No HF token"
even when `huggingface-cli login` had populated the file.
- model_manager.get_diarization_pipeline — pyannote diarization silently
returned None when only the canonical file was set. This is the bug
behind issue #35 (speaker diarization setup failure).
Both fixes use the same pattern: env var > huggingface_hub.get_token()
(which reads the canonical file). Adds a local _has_hf_token() helper
to system.py with a comment marking it as prelude to the AUTH-01..06
cascade (Phase 1 token_resolver.py will layer SQLite app-store on top).
Closes#35 sub-issue (canonical token invisible to diarization).
Cross-cuts AUTH-02 + AUTH-06 design for Phase 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(dictation): make pill-widget mode reachable from GUI + scripts (INST-13)
The dictation widget infrastructure shipped in PR #40 but was only reachable
via the undocumented --pill CLI flag. Adds three discovery paths:
1. Tray menu: "Switch to Dictation Widget" (studio mode) — saves
launch_as_widget=true to config, relaunches with --pill, exits current.
Mirrors the existing "Open Studio" path in pill-mode tray.
2. Persistent config: AppConfig.launch_as_widget (bool, default false). Read
at startup via load_config_pre_app() (uses dirs-next, no AppHandle
required). CLI --pill still takes precedence when explicitly passed.
3. Tauri commands: get_launch_as_widget / set_launch_as_widget for the
Phase 2 Settings UI to bind a checkbox to.
4. Scripts: bun desktop-prod:pill / desktop-prod:run:pill — forward --pill
to the bundled app launch. macOS uses `open -n --args` to spawn fresh
instance with the flag.
Closes the GUI half of INST-13. Phase 2 closes the Settings UI half.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(dictation): show widget unconditionally on pill-mode launch + visible Suspense fallback
Before: pill mode set up correctly but the widget window stayed hidden
until ⌘⇧Space was pressed. New users saw absolutely nothing on launch
(no main window, no dock icon, hidden widget) and assumed the app
failed. If global-shortcut Accessibility permission wasn't granted,
they had no path to discover the widget at all.
Two changes:
1. lib.rs: in pill_mode_setup, explicitly show + position + focus the
widget window after hiding main. With per-call error logging so we
can diagnose failures (and a clear error log if widget window
wasn't created at all — points at tauri.conf.json regression).
2. main-app.jsx: Suspense fallback was `null`, which combined with
widget's transparent+decorations:false config made any lazy-import
delay or failure invisible. Now renders a dark pill saying
"Loading dictation…" so even if CaptureWidget lazy-import stalls,
the user sees the window exists.
Studio mode behavior unchanged — widget stays hidden until hotkey
or tray click triggers it (existing show() call in the shortcut/
menu handlers is preserved).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(dictation): create widget window programmatically; Tauri 2 silently dropped config-array creation
Root cause: declaring the widget window in tauri.conf.json's app.windows[]
silently failed in Tauri 2 — get_webview_window("widget") returned None
even though the config was syntactically valid. Probable culprit was the
transparent + decorations:false + visible:false combo, but Tauri offered
no error message either at startup or via webview_windows() enumeration.
Diagnosed by adding webview_windows() enumeration logging at setup start
(only ["main"] ever appeared) and a programmatic WebviewWindowBuilder
fallback that surfaces real Result errors.
Fix:
- tauri.conf.json: widget entry now has `create: false` to make the
config-vs-programmatic handoff explicit.
- lib.rs setup(): call WebviewWindowBuilder::new(app, "widget", ...).build()
with the exact same surface attributes the config used to declare.
- capabilities/default.json: include "widget" in windows array so the new
window inherits the same Tauri permissions as main.
- tauri.conf.json: remove the invalid `"url": "/?window=widget"` field —
WebviewUrl::App takes a path only, query strings aren't supported.
Both windows now load index.html.
- main-app.jsx: replace URL-query-based widget detection with
getCurrentWindow().label === 'widget' via @tauri-apps/api/window. This
is the Tauri 2-recommended pattern for multi-window apps and works
regardless of URL routing.
Closes the immediate UX bug behind the dictation widget being invisible.
Builds cleanly + manually verified: pill widget visible on screen at
top-center after `bun desktop-prod:pill`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Pin setuptools>=75.0 in pyproject.toml to ensure pkg_resources is
available on Python 3.12+ (fixes#58)
- Add Linux white-screen workaround for Fedora 44 / Ubuntu 24.04 (#56)
- Add firewall/Russia install guide for uv venv failures (#60, #57)
Closes#58
Closes#52. Users who already have correct, pre-synced subtitles can now
skip ASR entirely — they upload a video as normal and then hit "Import
.srt" instead of "Upload & Transcribe". The .srt cues populate the dub
segment list directly, so the rest of the pipeline (translate, dub,
export) just works.
Backend
- services/srt_parser.py: lenient SubRip parser. Tolerates BOM, CRLF,
missing index numbers, dot-vs-comma ms separator, and overlap (shifts
the later cue's start to the earlier's end rather than dropping). Skips
cues with non-positive duration or empty bodies; reports counts so the
UI can warn.
- dub_core.py: new POST /dub/import-srt/{job_id} accepts the .srt file,
parses it, clamps cues that run past the source media's duration, and
replaces job["segments"]. Tries UTF-8 with BOM first, falls back to
latin-1 for legacy Windows subs.
Frontend
- api/dub.ts: dubImportSrt helper with a typed response.
- hooks/useDubWorkflow.js: handleDubImportSrt — sets segments, flips
dubStep to 'editing', shows a toast with per-bucket counts (imported /
skipped / overlap-shifted / clamped) so the user sees what happened.
- pages/DubTab.jsx: "Import .srt" button next to "Upload & Transcribe"
once a job exists, plus a smaller "Import .srt instead" affordance in
the transcription-failure banner — the exact recovery path the
reporter asked for.
Tests
- tests/test_srt_parser.py: 12 cases covering well-formed input,
multi-line cues, dot-as-separator, BOM, CRLF, malformed cues, empty
bodies, overlap shift, overlap-becomes-zero-drop, missing indices,
empty input, and segment shape (sequential ids, speaker filler).
pytest is now 226 passed (was 214); vitest unchanged at 11.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: post-refactor cleanup — wire fingerprints, drop dead code, scope pytest
Follow-up to PR #49. Fixes residual issues from the App.jsx hooks split
and tightens repo hygiene so a bare `pytest` doesn't foot-gun.
Real bug
- frontend/src/hooks/useDubWorkflow.js: setLastGenFingerprints lives in
useSegmentEditing, not on the store. The previous code called
useAppStore.getState().setLastGenFingerprints?.(...) — the optional
chain swallowed the missing method, so the "N segments changed" badge
never updated after a fresh generate until a project save+reopen.
Thread setLastGenFingerprints in from App.jsx; useSegmentEditing()
now runs before useDubWorkflow() to make the setter available.
Dead code from the refactor
- frontend/src/App.jsx: drop unused `showAllProjects` useState and
`pushUndo` from the useSegmentEditing destructure.
- frontend/src/hooks/useDubWorkflow.js: drop 5 unused selectors
(preserveBg, defaultTrack, exportTracks, dualSubs, burnSubs) — the
dub-download logic that needs these lives in App.jsx, not the hook.
Repo hygiene
- backend/api/routers/setup.py.bak: delete 38 KB tracked-in-git backup.
The setup/ subpackage replacement has been in place for a while.
- pyproject.toml: add [tool.pytest.ini_options] with testpaths +
norecursedirs. Previously a bare `pytest` would INTERNALERROR walking
into research/ (1.2 GB of vendored upstream projects with their own
test_*.py files that call sys.exit at module level).
- .github/workflows/ci.yml: run backend/tests/ as a second pytest
invocation. The 23 tests there stub core.config in sys.modules to
avoid the heavy main app import chain — that pollutes import state
for other tests, so they need their own session. Previously these
tests existed in the repo but never ran on CI.
Net effect on lint: 60 → 52 problems (-8) from dead-code removal.
Test counts unchanged: pytest 214 + 23, vitest 11.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: log silent catch failures that mask real bugs
CodeRabbit nitpick on #50: empty catch on the incremental-plan fallback
swallows errors. Extending the fix to the catches in this area that have
the same problem (a real failure would be invisible) while leaving the
genuinely non-actionable cleanup catches alone (EventSource.close(),
localStorage.setItem, fire-and-forget UI promises).
Logged:
- useDubWorkflow.js:97 — transcribe SSE message handler
- useDubWorkflow.js:347 — incremental-plan fallback (the CR finding)
- useDubWorkflow.js:352 — dub generate SSE event dispatch
- App.jsx:552 — exportRecord on Tauri save path
- App.jsx:580 — exportRecord on browser download path
Left silent (cleanup / non-actionable):
- useDubWorkflow.js:68, 112 — evt.close() in SSE teardown
- useDubWorkflow.js:102 — SSE error-event payload parse fallback
- App.jsx:124 — localStorage.setItem (quota / privacy mode)
- App.jsx:789, 901 — fire-and-forget UI promise tails
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: eliminate DB connection leaks, race conditions, and deprecated asyncio API
## DB Connection Leaks (P0)
- Convert 38 raw get_db() calls to db_conn() context manager across 14 router files
- Connections are now guaranteed to close even when exceptions are raised
- profiles.py create_profile: clean up orphaned audio file if DB insert fails
- profiles.py lock_profile: consolidate 3 separate conn.close() error paths
## Race Condition (P1)
- Add _dub_jobs_lock (threading.Lock) to protect _dub_jobs dict in dub_pipeline.py
- get_job/put_job now thread-safe for concurrent dub sessions
## asyncio Deprecation (P2)
- Replace 23 asyncio.get_event_loop() calls with asyncio.get_running_loop()
- Prevents DeprecationWarning on Python 3.12+ and future breakage on 3.14
## Quick Fixes
- gallery.py preview_voice: remove filesystem path from error response (P2)
- dub_pipeline.py parse_vtt_segments: remove redundant `import re` inside loop (P3)
- gallery.py _init_gallery_db: use db_conn() context manager (P2)
* refactor: extract hooks, centralize isTauri, add pytest-cov
## Frontend
- Extract useTTS hook (150 LOC) — TTS generation, streaming, audio ingestion
- Extract useProfiles hook (219 LOC) — voice profile CRUD, lock/unlock, preview
- Centralize isTauri detection: dialog.js, VoiceGallery.jsx, Settings.jsx
now import from utils/media.js instead of 4 different detection patterns
## Backend
- Add pytest-cov to dev dependencies
- Baseline coverage: 39% across backend/ (214 tests pass)
- Add .coverage to .gitignore
* feat: add Vitest + checkJs, extract useDubWorkflow + useAppData hooks
## Frontend Testing (new)
- Set up Vitest with jsdom environment + @testing-library/react
- 11 tests: utils (isTauri, formatTime, constants) + Zustand store (mode, text, dubStep, pill)
- Scripts: 'test' (vitest run), 'test:watch' (vitest), 'test:legacy' (node runner)
## App.jsx Decomposition (continued)
- Extract useDubWorkflow hook (387 LOC) — upload, ingest, transcribe SSE,
translate, generate SSE, abort, stop, cleanup
- Extract useAppData hook (181 LOC) — data loading, localStorage persistence,
WebSocket real-time updates, model-status pill management
## TypeScript checkJs
- Enable checkJs: true in tsconfig.json for IDE-level type checking
- 947 existing errors (informational, not blocking builds)
- noImplicitAny remains false to avoid blocking
* ci: add Vitest step, fix useProfiles duplicate state
## CI
- Add 'Run Vitest (frontend)' step — runs 11 unit tests
- Override --checkJs false in CI typecheck to avoid 947 pre-existing errors
- Rename legacy test step for clarity
## Hooks
- Fix useProfiles to accept loadProfiles from parent (useAppData)
instead of managing its own duplicate profiles array
* refactor: wire hooks into App.jsx — 2067 → 1129 LOC (-45%)
App.jsx now delegates to extracted hooks instead of inline logic:
- useAppData: data loading, localStorage, WebSocket, model pill
- useProfiles: voice profile CRUD, lock/unlock, preview
- useTTS: generation, streaming, audio ingestion
- useDubWorkflow: upload, transcribe SSE, translate, generate SSE
988 lines removed. All handler logic lives in focused,
independently testable hooks. Store selectors and render
JSX stay in App.jsx as the shell.
Verified: vite build clean, 11 frontend + 214 backend tests pass.
* feat: show real-time percentage on model loading pill
Backend: register hf_progress listener during _load_model_sync()
so download/weight-loading tqdm events update _loading_detail with
a progress percentage (0-99%). get_model_status() now includes a
'progress' field that the frontend polls.
Frontend: useAppData reads msQuery.data.progress and calls
setPillProgress() — the FloatingPill already renders the percentage
text and progress bar width from this value.
* fix: prevent FileNotFoundError in desktop bundle during model init
transformers >=4.52 calls _can_set_experts_implementation() and
_can_set_attn_implementation() during PreTrainedModel.__init__,
which open the class source file via open(class_file). In a Tauri
desktop bundle, module.__file__ points to a path that doesn't
exist on disk, causing:
FileNotFoundError: .../omnivoice/models/omnivoice.py
Override both classmethods on OmniVoice to return static values
without filesystem access. OmniVoice doesn't use MoE experts
(return False), but does support flex/flash attn (return True).
* fix: sync source dirs on every bootstrap, not just first run
The Tauri bootstrap previously only copied omnivoice/ and backend/
to Application Support on the first run. Subsequent app updates
kept using stale source files, preventing bug fixes from landing.
Now ensure_venv_ready() always syncs both directories from the
bundle resources before returning, even when the venv is healthy.
This fixes the FileNotFoundError crash where the old omnivoice.py
lacked the _can_set_experts_implementation override.
* ui: premium setup wizard polish
- Primary button: solid gradient fill with hover glow + lift + press
- Stepper nav: connected pills with glow ring on active step
- Welcome cards: glassmorphism with stagger-in animations, lucide icons,
left-border accent strip, hover translate
- Preflight panel: colored icon pill backgrounds, stagger-slide entrance
- Step transitions: fade+slide animation via keyed wrapper
- Footnote: shortened paths (~/ notation), Reveal in Finder button
- Recommendation banner: gradient background with accent glow
- Compact spacing throughout for denser, professional layout
* fix: kill zombie backend on clean+retry bootstrap
When clean_and_retry_bootstrap removes the project dir, any old
uvicorn process still running from the deleted paths remains alive
on port 3900. The subsequent retry_bootstrap sees the port is
healthy and attaches to the zombie instead of re-bootstrapping.
Now explicitly kill any process on the backend port after cleaning,
before calling retry_bootstrap.
* feat: integrate speaker clones into dubbing interface, sanitize system environment variables for subprocesses, and improve FFMPEG binary path resolution.
* fix: restore docker compose default + drop dead setSeed call
- deploy/docker-compose.yml: remove profiles: ["cpu"] from the default
service so `docker compose up` matches the comment on line 5. With the
profile present, no service auto-started.
- frontend/src/App.jsx: drop the setSeed call in restoreHistory. The
selector was never reintroduced after the App.jsx hooks split, and
there is no seed state in the store — seeds are generated fresh per
call in useTTS and only read from history items for display.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address CodeRabbit review — async detection, dub stream, bootstrap fail-fast
- backend/services/tts_backend.py: invert async-context detection in
_ensure_loaded. The previous code unconditionally caught its own
diagnostic RuntimeError and then called asyncio.run() inside a
running loop, masking the intended error message.
- frontend/src/hooks/useDubWorkflow.js: require a terminal `done` event
before reporting dub success. Without this, a dropped stream after
partial progress would flip the UI to `done`, refresh history, and
play the completion ping as if generation finished.
- frontend/src/hooks/useDubWorkflow.js: restore the previous step when
tasksCancel() fails. The UI was getting stuck in `stopping` forever
on cancel errors.
- frontend/src-tauri/src/bootstrap.rs: fail-fast when source sync fails
after the existing directory has already been removed. The previous
warn-and-continue path could leave the install with no backend/ or
omnivoice/ sources and defer the failure to backend startup with a
cryptic error.
- backend/api/routers/generation.py: add `from e` to the ValueError →
HTTPException re-raise (Ruff B904).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: preserve % suffix in TTS generation timer
The 100ms timer in useTTS was rewriting generationTime to a plain
elapsed-seconds string, which immediately wiped the "(xx%)" download
suffix written on the next iteration of the response-body loop. The
real-time percentage was flickering on/off as a result.
Read the previous value inside the setter and reattach any existing
percent suffix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New .github/workflows/docker.yml publishes images to ghcr.io on tag push
- README Docker section now leads with 'docker pull' from GHCR
- docker-compose.yml defaults to GHCR image with build-from-source fallback
- Dockerfile: copy README.md for hatchling metadata resolution
* feat: implement frameless OS-level floating dictation widget
- Refactor CaptureButton into standalone CaptureWidget
- Add secondary transparent Tauri window configuration
- Map global hotkey to show/hide widget instead of focusing main app
- Implement auto-hide post-paste
- Add social preview image
* docs: up the game with enhanced README
- Use the high-quality social preview image as the hero image
- Bump download release links to v0.2.7
- Highlight the new Frameless Dictation Widget feature
* docs: complete README overhaul for maximum virality
- Add Highlights section with 2-column feature grid
- Move Quickstart to top with one-command install
- Collapse technical details into expandable sections
- Add 'Up Next' roadmap with concrete upcoming features
- Add star call-to-action banner
- Tighten navigation links and section hierarchy
* docs: add beta warning banner
* docs: add star request to beta banner
* docs: rewrite README with cognitive hooks, remove redundant CTAs
- Remove 2 premature star asks (beta banner + highlights)
- Rewrite highlights with loss-aversion framing
- Keep single earned CTA at the very bottom
- Use action-oriented headings that describe outcomes
* docs: rename section to 'Why OmniVoice Studio?'
* docs: rename 'What you get' to 'Features'
* docs: concise scannable features, remove duplicate section
- Each feature is one punchy emoji-led line
- No verbose paragraphs, no redundant collapsibles
- Removed duplicate Features section from merge
* docs: 3-column feature card grid for visual impact
Replaces flat bullet list with 4x3 HTML table grid.
Each feature gets its own visual cell with emoji header,
bold keywords, and 2-line description. Pops on dark mode.
* docs: fix feature grid vertical alignment
* chore: bump version to 0.2.7, add changelog entry
* fix: apply CodeRabbit auto-fixes
Fixed 1 file(s) based on 1 unresolved review comment.
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
---------
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Replaces flat bullet list with 4x3 HTML table grid.
Each feature gets its own visual cell with emoji header,
bold keywords, and 2-line description. Pops on dark mode.
- Remove 2 premature star asks (beta banner + highlights)
- Rewrite highlights with loss-aversion framing
- Keep single earned CTA at the very bottom
- Use action-oriented headings that describe outcomes
- Add Highlights section with 2-column feature grid
- Move Quickstart to top with one-command install
- Collapse technical details into expandable sections
- Add 'Up Next' roadmap with concrete upcoming features
- Add star call-to-action banner
- Tighten navigation links and section hierarchy
- Use the high-quality social preview image as the hero image
- Bump download release links to v0.2.7
- Highlight the new Frameless Dictation Widget feature
WS dictation pipeline was producing exit-183 from ffmpeg on every
partial because MediaRecorder.start(250) ran before the WebSocket
handshake finished — the first chunk (WebM EBML header) was queued
only into chunksRef and never pushed to the WS, so concatenated
chunks 1..N decoded as malformed WebM. Fix:
- Construct the WebSocket BEFORE starting the recorder so wsRef is
set when the first ondataavailable fires.
- ondataavailable now queues every chunk through wsPendingRef when
the socket isn't OPEN; ws.onopen drains the queue.
- ws.onmessage('error'): fire HTTP fallback immediately instead of
waiting the full fallback-timeout window.
- ws.onclose without prior `final`: same — kick the HTTP path now
if the recorder has already stopped.
Mic permissions:
- New frontend/src-tauri/Info.plist with NSMicrophoneUsageDescription
+ NSCameraUsageDescription. Tauri 2 auto-merges the file at bundle
time (path is the same dir as tauri.conf.json — schema documents
this fallback). Without it, getUserMedia silently fails on macOS
10.14+ TCC.
- Mic-denial toast now includes platform-specific recovery (Settings
paths for macOS/Windows, audio-group check for Linux).
CI / release notes:
- release.yml extracts the matching `## [X.Y.Z]` section from
CHANGELOG.md and feeds it into tauri-action's releaseBody, so
v0.2.6+ tag pushes produce real release notes instead of the
placeholder "Auto-generated release. See commit log for changes."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- LICENSE replaced with the canonical Functional Source License,
Version 1.1, ALv2 Future License (auto-converts to Apache 2.0 two
years after each release).
- Scope clarified: Studio (frontend + backend + tauri shell + scripts)
is FSL. Bundled `omnivoice/` Python TTS model package by Han Zhu
stays Apache-2.0 — not relicensed here.
- README license section + license badge updated to reflect FSL +
future-Apache; replaced "30-day free evaluation" copy with the FSL
Permitted Purposes wording.
- Enterprise page: drop hard-coded pricing tiers (Startup/Business/
Enterprise) since pricing is still being finalized. Replaced with a
"Pricing tiers coming soon — request a quote" panel. FAQ rewritten
around FSL semantics (internal use is permitted, source converts to
Apache 2.0 in 2yr). Drop now-unused TIERS const + TierCard
component + .ent-tier* CSS.
- CHANGELOG entry under 0.2.6 records the relicense.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tray + lifecycle:
- tauri-plugin-single-instance — second launch focuses existing window
instead of racing for port 3900.
- Window close hides instead of destroying; backend shutdown moved to
RunEvent::ExitRequested so only the tray "Quit" item (or Cmd+Q on macOS)
actually exits.
- Tray icon flips to red-dot variant during dictation recording.
Hotkey customization:
- Settings → Capture tab. Records any modifier+key combo, persists to
app config, re-registers on launch.
- set_dictation_shortcut rolls back to the previous binding on register
failure so a bad combo never leaves the user with no shortcut.
Dictation latency / correctness:
- WS-final treated as source of truth; HTTP POST /transcribe runs only as
fallback (WS error / timeout / no-WS path). Audio transcribed once
instead of twice. Server accepts an "EOF" text frame (or empty binary
frame) so the socket stays open for `final` to be delivered before the
client closes.
- MediaRecorder chunks queued during the WS handshake are drained in
ws.onopen — the server's final transcript no longer drops the first
~250 ms of audio.
- Fallback timeout scales with recording length (max(15s, recordedMs+10s))
so long-form dictations don't trip duplicate transcription.
Donate page:
- Drop Patreon, Bitcoin / Ethereum / Solana cards. Drop qrcode.react.
- Move "Commercial License" CTA from page bottom to top-right header bar.
Docker hygiene:
- docker-compose binds 127.0.0.1 by default. README documents the LAN
exposure trade-off + recommends a reverse proxy with auth.
CI:
- New cross-platform `tauri-cross-platform` job runs `cargo check` against
the Tauri shell on macOS / Windows / Linux per PR. Catches platform
cfg-gate regressions without paying the full ~15min/platform bundle
cost (full bundling stays in release.yml on tag push).
Tests:
- tests/test_capture_ws.py (3 cases) covers EOF text-frame, empty-binary
EOF, and legacy disconnect-finalize paths.
Includes the user's previously-staged 0.2.5 polish: cross-platform
desktop-prod.sh, Dockerfile base-image fix, bun.lock churn.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Backend emits 'resolving' heartbeat every 2s during HF metadata
resolution so UI shows 'Resolving repo metadata...' instead of
being stuck on 'Connecting to HuggingFace…' indefinitely
- fmtBytes(0) now returns '0 B' instead of '—'
- desktop-prod.sh only tolerates signing errors, surfaces real
build failures with exit code
- Handle install_retry phase in frontend with attempt number
The --no-bundle flag caused the script to build only the raw binary
while launching the OLD stale .app bundle from a previous build.
Now builds the full bundle (tolerating the signing error which is
non-fatal) and deletes the old bundle first to prevent stale code.
shutil.disk_usage() throws on non-existent paths, causing the
preflight to report 0.0 GB free after a fresh wipe. Now resolves
up to the nearest existing ancestor directory so it probes the
actual volume free space correctly.
- 'Connecting to HuggingFace…' when no file events yet
- 'Resolving N files…' when tqdm init fired but total unknown
- Speed shows immediately from backend tqdm rate (no 2s warmup)
- '0 B / …' instead of '— / ?' for early progress
- 1s tick timer forces re-render so speed/ETA updates smoothly
- ETA shortened to ~3m instead of ~3m left for compactness
The --no-bundle flag caused the script to build only the raw binary
while launching the OLD stale .app bundle from a previous build.
Now builds the full bundle (tolerating the signing error which is
non-fatal) and deletes the old bundle first to prevent stale code.
shutil.disk_usage() throws on non-existent paths, causing the
preflight to report 0.0 GB free after a fresh wipe. Now resolves
up to the nearest existing ancestor directory so it probes the
actual volume free space correctly.
- 'Connecting to HuggingFace…' when no file events yet
- 'Resolving N files…' when tqdm init fired but total unknown
- Speed shows immediately from backend tqdm rate (no 2s warmup)
- '0 B / …' instead of '— / ?' for early progress
- 1s tick timer forces re-render so speed/ETA updates smoothly
- ETA shortened to ~3m instead of ~3m left for compactness
- tqdm hook emits progress every 0.3s with backend rate (bytes/sec)
- Frontend uses backend rate for instant speed display, no 2s warmup
- Shows 'Connecting to HuggingFace…' during connect phase
- Shows 'measuring speed…' before rate is available
- Re-check button moved to top-right header in system preflight
- Retry + Clean & Retry buttons on failed splash screen
- Smart error hints (missing README, network timeout, port in use)
- README.md + omnivoice/ source package copied during bootstrap
- desktop-prod.sh wipes HF cache + all app data for fresh testing
- Persistent config.json in app_data stores region preference
- China region auto-sets HF_ENDPOINT=https://hf-mirror.com
- Segmented toggle on bootstrap splash (🌐 Global / 🇨🇳 China)
- get_region / set_region Tauri commands for frontend access
- System HF_ENDPOINT env var still takes priority over config
Closes#33
Users in China can now set HF_ENDPOINT=https://hf-mirror.com as a
system env var before launching OmniVoice Studio. The Tauri shell
passes it through to the Python backend.
Root cause of 'No log output captured': bootstrap events fire before
the webview loads, so the React listener misses all of them.
Fix:
- Add log buffer (Vec<LogPayload>) to BootstrapState on Rust side
- emit_log() writes to both the event stream AND the buffer
- New 'get_bootstrap_logs' Tauri command returns all buffered lines
- Frontend calls get_bootstrap_logs on mount to backfill missed logs
- Deduplication prevents double-showing lines caught by both paths
- Also pipe backend stdout (not just stderr) to splash panel
- Pipe both stdout AND stderr from backend process to splash logs
(previously stdout went to file/null, so 'No module named X' was
invisible to users)
- All bootstrap stages stream logs to the splash panel
- Log panel always open by default with copy button
Critical Windows fix:
- Verify uvicorn is importable before trusting cached venv
- If venv exists but deps are missing, auto-repair via uv sync
- Fixes: users stuck in 'No module named uvicorn' loop
Splash improvements:
- Show version (v0.2.5) next to title on loading screen
- Logs panel open by default — users see live output immediately
- Copy button inline with toggle for easy bug reporting
- __APP_VERSION__ injected via Vite define from package.json
Logs are now always visible during the splash screen so users
can see what's happening (Python imports, model loading, etc).
Copy button sits inline next to the toggle and line count.
Log panel height increased to 280px for more context.
Root cause: uv.lock wasn't bundled in the Windows MSI, so
'uv sync --frozen' silently produced a venv without uvicorn.
Changes:
- If uv.lock is missing after copy, run 'uv sync' without --frozen
so uv resolves deps from pyproject.toml (slower but always works)
- Log a warning instead of silently ignoring lockfile copy failures
- Auto-expand bootstrap logs on failure so users see full context
- Add '📋 Copy logs' button for easy bug reporting
- user-select: text on log panel so text is selectable
Flush Dropdown:
- Flush button now opens a dropdown showing all loaded models
- Each model shows device, VRAM usage, and individual Unload button
- Backend endpoints: GET /model/loaded, POST /model/unload/{id}
- Bottom actions: Flush caches, Unload all + flush
Credentials Tab:
- New Settings > Credentials tab with HF_TOKEN and TRANSLATE_API_KEY
- Session-scoped via POST /system/set-env (no ElevenLabs — we ARE the alternative)
- Shows 'Set' / 'Not set' badge for HF token
Notification Panel:
- Moved from header dropdown to footer status bar (4th tab: Notifications)
- Bell icon in header dispatches event to open footer tab
- Click notification → navigates to relevant page (e.g., Settings for HF token)
- No inline inputs — notifications are purely informational + navigational
Whisper Model Selector:
- Capture widget now has quality preset picker: tiny → large-v3
- Persisted in localStorage; sent to backend as 'model' form field
- Backend passes chosen model to ASR backend
Reactive Transcriptions:
- Custom window event (omni:transcription-added) bridges CaptureButton → TranscriptionsPage
- Page updates in realtime when new dictation completes
Notification Panel:
- Bell icon in header with badge count (red/amber by severity)
- Polls GET /system/notifications every 30s
- Surfaces: missing HF_TOKEN, missing ffmpeg, low disk, CPU-only mode
- Inline HF_TOKEN input — set token without leaving the app
- Dismiss individual or all notifications (persisted in localStorage)
- Click-outside to close, slide-in animation
Backend:
- GET /system/notifications — returns actionable notifications
- POST /system/set-env — safely set HF_TOKEN, TRANSLATE_API_KEY,
ELEVENLABS_API_KEY at runtime (allowlisted keys only)
Transcriptions Page:
- New nav rail item (Transcripts) with FileText icon
- Searchable list + detail split-pane layout
- Stores all dictation results in localStorage (max 200)
- Copy, delete, export all as .txt
- Shows timestamps, language, duration, and segment breakdown
- CaptureButton auto-saves to Transcriptions on success
Header:
- Added gallery + transcriptions to VIEW_META breadcrumbs
CaptureButton.jsx was using VITE_API_BACKEND_URL (undefined, defaults
to empty string) so /transcribe was a relative path hitting the Vite
dev server or Tauri webview instead of the backend on :3900.
Fix: import API from api/client.ts (same as all other API calls).
Voice Capture (Dictation):
- CaptureButton FAB with ⌘+⇧+Space global shortcut
- Records mic → POST /transcribe → displays text → copy to clipboard
- Backend capture.py: standalone ASR endpoint (no dub job needed)
- Animated waveform bars, glassmorphic panel, pulse recording indicator
Speaker Casting:
- CastingView component — visual speaker-to-voice assignment grid
- Auto-cast from video speaker clones or manually pick saved profiles
- Dropdown picker with personality tags, preview button
- Registered in CastingView.css with premium glassmorphism
Real-time Dub Preview:
- POST /dub/preview-segment/{job_id} — 8-step fast TTS for single segment
- No disk write, no watermark, no mix — just instant audio feedback
- Returns WAV bytes directly for immediate playback
- Add /health endpoint returning {'status':'ok','device':'...'} for
Docker health checks and monitoring
- Rewrite docker-compose.yml: CPU default + GPU via --profile flag so
CPU-only machines don't get nvidia driver errors
- Named volumes, proper health checks with start_period for first-run
model downloads
- Fix README Docker quickstart: wrong port (8000→3900), add GPU
profile instructions
- Onboarding: seed 'OmniVoice Demo' profile on first run (empty DB)
with bundled reference audio so Launchpad isn't empty
- Voice Personalities: 6 built-in presets (Narrator, Casual, News
Anchor, Storyteller, Corporate, Energetic) with instruct text
auto-fill in Voice Design mode
- i18n: react-i18next with English locale, browser language detection,
Launchpad & CloneDesignTab strings extracted to en.json
- DB migration v4: personality TEXT column on voice_profiles
- New API: GET /personalities returns preset list
- CSS: demo callout banner + personality picker strip
Three fixes for the Windows MSI first-launch failure:
1. **backend_log_path() was macOS-only** — used $HOME + Library/Logs
which doesn't exist on Windows. Now uses %LOCALAPPDATA% on Windows,
~/Library/Logs on macOS, and XDG_STATE_HOME on Linux. Without this,
stdout/stderr went to Stdio::null() and all backend crash output was
silently lost.
2. **Add TORCHDYNAMO_DISABLE=1 on Windows** — prevents PyTorch from
trying to download Triton (which has no Windows support), avoiding a
hang during first torch.compile() call (#26 workaround).
3. **Increase health timeout from 180s to 300s** — first-run PyTorch
import on Windows can take 120+ seconds for CUDA kernel JIT, plus
uv sync + torch + model loading. 3 min wasn't enough.
Fixes#30
Tauri v2 replaces `../` with `_up_/` when bundling resources into MSI
and deb installers. The `../../pyproject.toml` config path becomes
`$RESOURCE/_up_/_up_/pyproject.toml` at runtime, but the Rust bootstrap
only checked the flat `$RESOURCE/pyproject.toml` path.
This worked on macOS (.app bundles flatten into Contents/Resources/) but
failed on Windows MSI and Linux deb with:
Missing bootstrap resources (pyproject=..., backend=...)
Fix: try both the flat path and the _up_/_up_ prefixed path, with
diagnostic logging if neither is found.
Fixes#28
- Implement donate page and migrate API fetching to react-query hooks
- Add setup wizard for batch job management and voice clip editing
- Refactor setup router into package (wizard, models, download sub-modules)
- Fix 9 CI test failures from setup router refactor
- Fix cross-device link error in prefs.py atomic writes
- Fix event loop mismatch in export test fixtures
- Modernize README with architecture diagram and 13 app screenshots
- Defer per-segment disk writes in dub_generate for ~6s faster dubs
- Extract 45 inline styles from Launchpad, KeyboardCheatsheet, DubSegmentRow
- Add playwright dev dep and screenshot capture script
v0.2.2 users upgrading from a PyInstaller build saw "Failed to load
engines: Load failed" because the app window opened before the
first-run `uv sync` (5-10 min) could finish populating the venv. The
webview just hung on a blank state.
Wire the setup through properly:
Rust (src-tauri/src/lib.rs):
- New `BootstrapStage` enum: checking → downloading_uv →
creating_venv → installing_deps → starting_backend → ready (or
failed { message }). `#[serde(tag = "stage")]` so it serialises as
a tagged union the frontend can switch on.
- `BootstrapState` exposed via `bootstrap_status` Tauri command so
React can poll progress.
- `setup()` no longer blocks on `ensure_venv_ready`. Instead spawns a
background thread that walks the bootstrap, writes stage updates to
the mutex, then waits up to 60 s for the backend port to answer and
flips stage to `ready`.
- `ensure_venv_ready` + `spawn_backend` take the progress mutex
(Option<&Arc<Mutex<…>>>) and set the right stage at each step.
They now take AppHandle<R> instead of &App<R> so the background
thread can hold them.
React (frontend/src/components/BootstrapSplash.{jsx,css}):
- Self-contained splash component + `useBootstrapStage()` hook that
polls the Rust command every 1 s, short-circuits to 'ready' in the
Vite dev server / non-Tauri contexts.
- Renders a progress bar + step list keyed to BootstrapStage. On
Failed, shows the Rust-side message.
App.jsx:
- Calls `useBootstrapStage()`, blocks the main UI render until stage
=== 'ready'.
Also bumps pyproject/package/cargo/tauri.conf versions 0.2.2 → 0.2.3.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>