Commit Graph
100 Commits
Author SHA1 Message Date
Palash DebnathandClaude Opus 4.8 621c263354 fix(version): surface running version in web/Docker UI + hide desktop-only updater (#249) (#258)
The Docker web build has no Tauri runtime, so Settings → About → Version read
`getVersion()` (Tauri-only) and rendered a dash — leaving Docker users unable
to tell which version they were running (issue #249). The update-channel toggle
was also shown there even though the auto-updater is desktop-only.

- Backend: expose the single-source `APP_VERSION` over HTTP — add it to
  `/system/info` (`app_version`) and `/health` (`version`). Both are model-free
  and the latter is zero-auth.
- Frontend: Settings → About → Version falls back to `info.app_version` when
  no Tauri `getVersion()` is available, so Docker shows the real 0.3.x version.
- Frontend: hide the update-channel toggle, update-endpoint row, and the
  "Check for updates" button outside Tauri — the Docker image updates by
  pulling a new tag, not via the in-app updater.
- Docs: fix the wrong package name in the version-check command
  (`omnivoice-studio` → `omnivoice`) and document the new `/health` version
  field + the in-UI version row.

Tests: assert `/system/info.app_version` and `/health.version` equal
APP_VERSION (test_router_smoke.py). The stale `:latest` tag itself was already
fixed in #252; cutting a v0.3.x release repopulates it.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 02:15:11 +05:30
Palash DebnathandClaude Opus 4.8 e2a33e8c40 fix(frontend): browser/Docker fallback for file export (closes #256) (#257)
The history-item export button (and the dub/audio export path) called the
Tauri `save` dialog unconditionally. In the Docker web-server build there is
no Tauri shell, so the plugin's internal invoke() dereferences an undefined
__TAURI_INTERNALS__ and crashes with:

    TypeError: Cannot read properties of undefined (reading 'invoke')

…which is exactly what users hit when downloading a freshly cloned voice from
the browser/Docker UI.

Fix: extract a shared `browserDownload` helper (utils/download.js) that does a
plain HTTP-blob download via a temporary <a download>, and guard
`handleNativeExport` on `isTauri` — falling back to that helper (streaming the
file already served at /audio/<path>) when no Tauri runtime is present.
`triggerDownload`'s browser branch now reuses the same helper instead of
duplicating the blob-download logic.

Adds utils/download.test.js covering the Content-Disposition parser and the
no-Tauri download path (regression guard for #256).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 02:07:18 +05:30
Palash DebnathandClaude Opus 4.8 f555fe0720 fix(bootstrap): surface setuptools-repair failures + verify pkg_resources (follow-up to #253) (#254)
* fix(bootstrap): surface setuptools-repair failures + verify pkg_resources (follow-up to #253)

Three gaps flagged by review bots on PR #253 are addressed:

1. **Layer-2 repair result captured** (`bootstrap.rs` ~line 481): the
   `let _ = run_streaming(...)` that silently discarded network/permission
   failures from the targeted `uv pip install setuptools>=75,<80` is replaced
   with a `match` block that logs `log::info!` on success and `log::error!`
   on failure (consistent with the Layer-3 path).

2. **Post-repair re-verification added** (`bootstrap.rs`): after the targeted
   install in Layer 2, a second `import pkg_resources` check is run. If
   pkg_resources is *still* absent, a `log::error!` with an actionable
   remediation message is emitted before returning. This closes the gap where
   bootstrap handed back a known-bad venv that caused the dubbing crash (#248)
   with no clear signal in the log.

3. **Test strengthened** (`bootstrap.rs` `setuptools_repair_uses_correct_specifier`):
   the test now mirrors the exact `&[&str]` slice used in both repair branches
   and asserts `repair_args[2] == "setuptools>=75,<80"` as a single positional
   argument. This catches the split-arg regression the review bot identified
   (e.g. `["setuptools>=75", ",<80"]`) which would silently install the latest
   setuptools and leave pkg_resources absent.

4. **Smoke-test INST-01/02 hardened** (`scripts/smoke-test.sh`): exports
   `UV_PYTHON_PREFERENCE=only-system`, `UV_HTTP_TIMEOUT=120`, and
   `UV_HTTP_RETRIES=5` before the `uv run` import checks so that failures
   reflect real bootstrap regressions, not harness-network timeouts.

`cargo test bootstrap` → 5 passed, 0 failed.

Closes review findings on #253. Related: #248.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bootstrap): fail clearly when pkg_resources repair fails (PR #254 review)

- ensure_venv_ready now returns None (via fail()) when pkg_resources is still
  missing after the targeted setuptools repair, instead of returning a venv that
  crashes on the first ASR/dub call. The 'pkg_resources' message routes to the
  PKG_RESOURCES_MISSING failure mapping for a clear, doc-linked remediation.
- smoke-test.sh: correct the comment (timeout+retry vars, not a non-existent index var).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:50:37 +05:30
Palash DebnathandClaude Opus 4.8 ef98aae4db test(probe): whole-app coverage — dubbing, i18n, engines, security, migration, dictation, design (#247)
* test(probe): expand coverage — dubbing, i18n, engines, security, migration, dictation, design, coverage-critic

Broadens the probe harness from one happy-path spec per layer to whole-app
feature coverage (web, backend, dictation, clone, design), keeping the
Actor/Judge split and offline-by-default + enable-on-demand for heavy paths.

New specs + judges (one subprocess boot shared across backend-touching specs):
- dubbing (L4): segment duration-ratio, SRT/VTT well-formed, export-archive
  contents, output language-ID (advisory)
- i18n: locale files valid JSON (gate); orphan-keys + coverage (advisory).
  NOTE: surfaced a real bug — all 20 non-en locales carry gallery.cat_*/
  bootstrap.lines keys absent from the en reference (reported, not gated).
- engine matrix: active engine available + every unavailable engine explains
  why (11 TTS / 7 ASR backends via /engines/*)
- loopback security: system routes reject non-loopback origins (403)
- DB migration: alembic UPGRADE on the seeded omnivoice_data fixture
- Coverage Critic: every declared layer still has a spec (gate) + API inventory
- dictation: streaming-ASR WebSocket /ws/transcribe registered + handshake
- voice design: reuses the audio-correctness ladder
- real ASR round-trip: enable-on-demand (PROBE_E2E=1)

Enriched _boot_runner.py to capture engines/asr/loopback/openapi/ws in ONE
isolated boot (conftest boot_capture session fixture); added env.seeded_data_dir.

13 specs total. probe suite 74 passed / 5 skipped; full repo 687 passed, 0 failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(probe): address all 15 unresolved review findings on #247

- coverage.py:22 — use `with open(...)` context to close spec files after
  yaml.safe_load (file handle leak)
- _boot_runner.py:80 — store only `type(exc).__name__` for WS errors; drop
  raw str(exc) that could leak home paths / secrets into capture JSON
- _boot_runner.py:99 — snapshot DB files before boot; set db_created=True
  only when boot creates NEW files (not when fixture already had one)
- dubbing.py:46 — FAIL segments_duration_ratio when validated==0 (guards
  against empty/corrupt segment list passing vacuously)
- i18n.py:49 — FAIL locale_valid_json when locales_dir is empty/missing
- i18n.py:7 — fix docstring: locale_no_orphan_keys is advisory, not blocking
- test_probe_i18n.py:59 — assert r.passed is False, not just r.advisory
- coverage_critic.probe.yaml:15 — add "meta" to required layers list
- dub_export.probe.yaml:17 — capture dub_audio in steps before advisory reads it
- migration.probe.yaml:13 — add path_exists(db_path) data-integrity check
- test_probe_asr_e2e.py:33 — os.path.exists → os.path.isfile for PROBE_ASR_SAMPLE
- test_probe_migration.py:24 — assert context["db_path"] (presence) not
  db_created (new creation), aligning with the boot_runner fix

Two findings intentionally skipped with reasons (see review thread replies):
  test_probe_design.py:36 — offline pattern is intentional; actor step is
    bypassed by design throughout the probe suite for CI compatibility
  test_probe_engines.py:22 — whisperx pin is intentional; it verifies the
    shipped default ASR engine is available out-of-the-box

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(probe): ASCII x in dubbing detail (ruff) + run migration judges inside seeded dir

Two regressions from the hardening pass:
- dubbing.py: replace non-ASCII '×' with 'x' (Ruff ambiguous-unicode → Tests lint fail)
- test_probe_migration: move run_judges inside the seeded_data_dir with-block so the
  new path_exists check sees the DB before the temp dir is torn down (was always failing)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:41:21 +05:30
Palash DebnathandClaude Opus 4.8 ab492c0e8e fix(docker): fix stale :latest tag + add push-to-main trigger (#252)
* fix(docker): fix stale :latest tag and add push-to-main trigger (closes #251, addresses #249)

Two bugs caused the Docker image to be stale (showing v0.2.7 inside a
:latest/:0.3.x-tagged image):

1. **`:latest` was never set on tag pushes.** The metadata-action rule
   `type=raw,value=latest,enable={{is_default_branch}}` evaluates
   `is_default_branch` as false on tag-triggered runs (which run in a
   detached-HEAD context, not on the default branch). The tag rule was
   replaced with `enable=${{ github.ref_type == 'tag' }}` so `:latest`
   is updated on every `v*` tag push.

2. **No trigger for main-branch pushes.** There was no way to keep an
   up-to-date `:main` edge image between releases. Added
   `push: branches: [main]` which produces a `:main` rolling tag.

Also added a note in the workflow and docs clarifying that the
update-channel toggle (Settings → About) is a Tauri desktop feature and
does not apply to the Docker image (headless web-server build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(docker): gate mutable tags to push events + guard :latest against prereleases (PR #252 review)

- semver / :latest / :main now require github.event_name == 'push' so a manual
  workflow_dispatch only ever emits a throwaway :sha- tag (no mutable-tag rollback)
- :latest excludes prerelease tags (ref contains '-') so an rc/beta can't clobber it
- header + tag-strategy comments corrected (:sha- emits on every trigger)

Addresses greptile + coderabbit review on #252.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:23:56 +05:30
Palash DebnathandClaude Opus 4.8 c98b0aaf6f fix(bootstrap): guarantee pkg_resources in backend venv (closes #248) (#253)
Root cause: the existing-venv fast-path in ensure_venv_ready() only
checks `import uvicorn` before returning — it never verified that
`pkg_resources` (dropped by setuptools≥80, issue #224) was present.
Users who installed before commit 675cc20 (Jun 1) had setuptools 82.x
in their venv; the app launched fine but any dubbing/transcription run
immediately crashed with `ModuleNotFoundError: No module named
'pkg_resources'` via the whisperx → ctranslate2 → import chain.
Reinstalling didn't help because the app re-detected uvicorn and skipped
the repair sync entirely.

Fix (three-layer defence):

1. **Existing-venv health check**: if uvicorn imports but
   `import pkg_resources` fails, fall through to the repair-sync path
   instead of returning the broken venv. Logs a clear message
   distinguishing "uvicorn missing" from "pkg_resources missing (#248)".

2. **Post-repair verification**: after the repair sync succeeds, run
   `import pkg_resources` again; if still absent, run a targeted
   `uv pip install "setuptools>=75,<80"` to install it directly without
   re-resolving the full dependency tree.

3. **Post-fresh-sync verification** (belt-and-suspenders): same
   pkg_resources check + targeted pip-install added after every
   fresh-install `uv sync`, catching the edge case where the bundled
   uv.lock is absent and uv resolves setuptools≥80 from scratch.

All three paths use the same scrub_python_env + apply_uv_http_env
guards already applied elsewhere; safe on macOS/Linux/Windows.

Also adds `setuptools_repair_uses_correct_specifier` unit test and
updates the INST-01 smoke-test comment to reference #248.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:22:28 +05:30
Palash DebnathandClaude Opus 4.8 24a00bea64 test(probe): spec-driven AI-agent test harness (L1–L5 + HTML report + triage) (#245)
* test(probe): add spec-driven AI-agent test harness (L1/L2/L4/L5 + report + triage)

Introduces `tests/probe/`, a portable, mostly-deterministic test harness built
on the Actor/Judge split: AI agents may drive and self-heal, but verdicts are
always deterministic code + metrics — no LLM on the verdict path.

Layers:
- L1 API: Schemathesis property-fuzz over in-process ASGI (enable-on-demand).
- L2 web: Playwright Driver + deterministic self-heal (id→test-id→text, loosened
  CSS) → pluggable Healer; LLMHealer/anthropic_healer for genuine agentic heal.
  Judges + self-heal logic unit-tested offline via FakePage; live browser skips.
- L4 media: audio correctness — exists/decode/duration/not-silent/clipping/NaN,
  round-trip ASR WER (pure-python, faster-whisper backend), speaker similarity.
  No golden-WAV (device-stable metrics only); naturalness is advisory-only.
- L5 env/first-run: fresh-data-dir backend boot in a SUBPROCESS (no session
  contamination), asserts health + DB init + endpoint reachability. Docker gated.

Plus: hybrid YAML spec engine + JudgeResult/registry; self-contained HTML report
that auto-opens (suppressed in CI/headless/PROBE_NO_OPEN); Triager that clusters
failures and drafts a prefilled GitHub issue URL (sanitized, no auto-submit) with
a one-click button in the report.

Dependency-light: runs in the base venv; schemathesis/resemblyzer/playwright/
anthropic are enable-on-demand and skip cleanly. Generated reports gitignored.
Full suite green (657 passed); no contamination of existing tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(probe): add L3 desktop layer (Tauri config-integrity + guarded launch)

Per the architecture decision, desktop E2E is substituted by backend-over-HTTP
(L5) + browser (L2) since Tauri has no official macOS WebDriver. L3 guards the
packaging/shell contract a browser test can't see, against the real
tauri.conf.json (with platform-override merge), running on any platform with no
Tauri toolchain:

- version parity between tauri.conf.json and pyproject (release integrity)
- dev/build wiring (devUrl matches the Vite frontend, frontendDist, before* cmds)
- bundled binaries first-run depends on (uv / ffmpeg / ffprobe in externalBin)
- CSP actually permits the local backend origins (desktop-only failure mode:
  packaged app can't reach :3900 while the browser build works)

Adds desktop.py (config load + platform deep-merge + bundle discovery + launch
guard), judges/desktop.py (config_present/config_eq/config_contains/csp_allows),
desktop_smoke.probe.yaml, and tests covering integrity, platform-merge replace
semantics, and a live bundle launch that skips without a built bundle/display.

Full suite green (662 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:00:19 +05:30
Palash DebnathandClaude Opus 4.8 208719a555 fix: dub OOM fallback, watermark on /generate, Settings responsiveness (#241 follow-up) (#244)
* fix(dub): whisperx CUDA OOM → CPU fallback instead of a bare 500

Found while exercising the dub pipeline on an 8 GB RTX 4070 Laptop GPU: with
the TTS model + GPU worker pool resident, whisperx's CTranslate2 load of
large-v3 dies with "CUDA failed with error out of memory", and
POST /dub/transcribe surfaced it as an unhandled 500 with no guidance.

WhisperXBackend now catches a CUDA OOM at load and retries on CPU (int8, same
model + accuracy, just slower) after clearing the CUDA cache. Dubbing keeps
working on small/laptop GPUs instead of dead-ending. Only triggers on a CUDA
OOM, so the MPS/CPU paths are untouched (cross-platform parity).

Verified: /dub/transcribe on the prepped job went 500 → 200 with correct
segments. Added a deterministic unit test (forces the OOM, asserts the device
switches cuda→cpu; a non-OOM RuntimeError still propagates).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(watermark): embed invisible watermark on /generate output, not just dubs

embed_watermark was wired only into the dub pipeline (dub_generate.py), so
plain TTS from /generate came out unmarked even with invisible watermarking
enabled — i.e. the setting silently did nothing for the main generate path.
Embed it on the final audio in the generate handler too. embed_watermark
self-gates on the setting + AudioSeal availability and passes audio through
unchanged on failure, so it's a no-op when off and never breaks generation.

Verified: detector on a fresh /generate clip went is_watermarked:false →
true, confidence 1.0, message OMNI ("OM"), is_omnivoice:true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(settings): wrap the settings sub-nav so tabs don't clip out of view

The settings sub-nav has 10 tabs (General…Privacy) but the shared .ui-tabs
primitive is a non-wrapping inline-flex row, so on a narrow Settings pane the
later tabs (Credentials/Logs/About/Privacy) overflowed the right edge and were
unreachable. Scope flex-wrap to `.ui-tabs.settings-tabs-ui` only — the bar now
grows to 2–3 rows instead of running off-screen. The shared primitive (used by
the models role tabs, log-source tabs, etc.) is unchanged.

Verified at 900px (2 rows) and 700px (3 rows): all 10 tabs visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(settings): connect active tab to content via accent + tighten spacing

Make the active settings tab read as connected to the panel below: each tab
carries its own semantic accent (already in TAB_DEFS — Models pink, Engines
purple, …) instead of a uniform pink, and that accent is threaded down as
--settings-accent to paint a matching hairline along the top of the content
panel. The shared colour ties tab→content subtly and wrap-proof (no fragile
positional connector). Content wrapped in .settings-content with deliberate
margin/padding so it breathes under the bar; the bar's own bottom margin is
dropped so the bridge owns that gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(engines): make the compatibility matrix responsive (scroll, don't overlap)

On a narrow Settings pane the matrix's fixed-width columns (status/gpu/
isolation/actions ≈ 630px) plus the flexible name column couldn't fit, so the
cells collapsed and OVERLAPPED — name text rendered under the AVAILABLE/ACTIVE
badges and GPU chips. Give the table a horizontal-scroll container with a
shared header/body min-width (840px) and stop the fixed cells from shrinking,
so columns keep their shape and stay legible at any width (scroll for the
overflow) — the same data-table treatment used elsewhere. Fills normally on
wide panes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(settings): border-connect the active tab to its content panel

Refine the tab→content connection from a single accent hairline to a
"border-connect": the pill bar opens at its bottom (flat corners, no bottom
border) into a 3-sided panel (.settings-content) framed in the active tab's
accent, with a 2px full-accent top edge at the seam. The bar + panel read as
one outlined container, and the active tab's colour visibly feeds into the
panel it opens. Accent is threaded per-tab via --settings-accent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 10:19:54 +05:30
Palash DebnathandClaude Opus 4.8 a94fc435a5 fix(asr): repoint two 404 ASR model repo IDs in catalog (closes #239) (#242)
* fix(asr): repoint two 404 ASR model repo IDs in catalog (closes #239)

Model install failed with HTTP 404 for two ASR entries whose Hugging Face
repos don't exist:
- UsefulSensors/moonshine-small -> UsefulSensors/moonshine-tiny (Moonshine
  ships tiny/base; there is no 300M 'small')
- Systran/faster-whisper-large-v3-turbo -> deepdml/faster-whisper-large-v3-turbo-ct2
  (Systran publishes no turbo repo; deepdml is a valid CTranslate2 build)

Audited all 25 catalog repo_ids — every one resolves 200 on HF after the swap.
Adds a static (no-network, CI-safe) regression test asserting repo_ids are
well-formed and the known-404 IDs can't reappear.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test/docs(asr): safer repo_id access + flag turbo repo as community build (PR #242 review)

- test_known_404_repo_ids_absent: m.get('repo_id','') so a missing field gives a
  clean assertion instead of KeyError regardless of test order.
- models.yaml: note the turbo entry is a community CTranslate2 conversion to
  re-verify on future audits (greptile).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:17:29 +05:30
Palash DebnathandClaude Opus 4.8 5ad160a2e9 fix(license): declare actual FSL-1.1-ALv2 in pyproject (was Apache-2.0) (#236)
pyproject declared license = "Apache-2.0", but the repo's LICENSE is
FSL-1.1-ALv2 (Functional Source License; each release converts to Apache-2.0
two years after publication). The Apache-2.0 declaration was inaccurate for
the current grant. Declared as a PEP 639 LicenseRef since FSL isn't an
OSI/SPDX-listed identifier.

Validated: hatchling accepts the expression and builds the project cleanly
(uv build OK), so uv sync / packaging in CI is unaffected.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:12:10 +05:30
Palash DebnathandClaude Opus 4.8 8690833137 feat(update): move update pill into status bar + Updates panel (changelog/channel/history) (#240)
* docs(spec): updates-in-status-bar design (move pill to LogsFooter + Updates panel)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(plan): updates-in-status-bar implementation plan (11 tasks, TDD)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(plan): pin i18n task to scripts/translate_all.py backfill

* feat(update): pure chip + release presentation helpers

* feat(update): listReleases + fetchAppVersion wrappers

* feat(update): transient releasesSlice composed into store

* feat(update): app version + channel in updaterSlice

* feat(update): list_releases Tauri command (GitHub releases)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(update): UpdateStatusChip bar indicator

* feat(update): UpdatesPanel (live row + channel + releases list)

* feat(update): mount chip+panel in LogsFooter, retire floating UpdateBadge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(update): Settings channel switcher shares store value (auto-sync)

* i18n(update): add updates.* keys across 21 locales

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(deps): lock reqwest for list_releases command

* polish(update): a11y radiogroup on channel switch, safer release key, drop dead test seam

Addresses final-review nits (non-blocking): role=radiogroup/radio + aria-checked
on the channel Segmented; key={r.name||r.version} to avoid collisions; remove the
unused vi import + __loader seam in releasesSlice.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(update): i18n the channel-set error + correct flagged updates.* translations (PR #240 review)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(update): add 10s timeout to list_releases HTTP client (PR #240 review)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(update): guard chip Restart against in-flight dub job (greptile P1, PR #240)

The always-visible status chip's one-click Restart (ready state) called
installUpdate→relaunch without the dub-busy guard the panel uses, so a user
with a dub/transcription job running could lose in-flight work. Mirror the
panel's gate: toast update.busy and bail when dubStep === 'generating'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(update): surface channel-switch failures in the Updates panel (greptile, PR #240)

Mirror Settings' error handling: the panel's stable/preview switch now catches a
failed set_update_channel and toasts settings.channel_set_failed instead of an
unhandled rejection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 08:53:36 +05:30
Palash DebnathandClaude Opus 4.8 c857be816a fix(gallery): OmniVoice Gallery rename, dark dropdowns, and noisy/stale archetype previews (#241)
* fix(gallery): rename to "OmniVoice Gallery" + fix dark-theme dropdown colors

The gallery heading now reads "OmniVoice Gallery" (gallery.title, all 21
locales — brand prefix on each localized word).

The facet filter <select>s (Gender/Age/Pitch/Accent/Language) rendered with
the OS-default light control surface on the dark theme: .facet-select set
background/border from --bg-tertiary / --border-color, which are defined
nowhere. An undefined var() reads as transparent on the sibling <div> filters
(fine over the dark page) but falls back to the native light background on a
form control. Switch to the defined dark-chrome tokens and add
color-scheme: dark + an explicit dark option list so the popup matches across
WebKit / WebView2 / WebKitGTK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(gallery): archetype previews render a noise buzz instead of voice

The Hype Host, The Podcaster and The Vlogger previews played a loud tonal
buzz, not speech. The preview renderer pinned num_step=16 and seed=42; the
"social" sample script at that exact point lands on a degenerate diffusion
trajectory and collapses to a near-pure tone. The blank-audio guard missed it
because the buzz is loud (peaks near -2 dBFS), not silent — so the garbage was
cached and served. The cache key is (instruct, language) only, so it never
self-corrected.

- Bump preview num_step 16 -> 32: reliably converges to speech across the
  gallery's instruct/script space (one-time, cached render cost).
- Add a spectral-flatness floor (_is_unusable_audio) so a degenerate tonal
  render is rejected like a blank one, reusing the existing retry-on-new-seed
  path. Whisper/breathy voices are broadband (high flatness) so they're safe.

Verified: flatness Hype Host 0.001->0.050, Podcaster 0.0002->0.083,
Vlogger 0.004->0.039; whisper control (Calm Guide) 0.239, not flagged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(gallery): stop preview playback replaying stale cached audio

Preview audio is re-rendered server-side when an archetype is fixed, but the
URL is stable and the response carried no Cache-Control — so the WebView's
HTTP cache replayed the first clip it ever fetched (e.g. the old buzz)
indefinitely, even after the server file was corrected.

- Frontend: fetch previews with { cache: 'no-store' } so playback always
  pulls current bytes.
- Backend: send Cache-Control: no-cache on the preview response so any client
  revalidates against the ETag instead of serving a stale clip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(e2e): add Playwright UI smoke + gallery specs and preview-quality unit test

UI testing system to catch regressions like "Use design → Importing a module
script failed" (a dead Vite/module server) and the noisy-preview bug.

- Playwright (frontend/e2e): drives the system chromium (no browser download)
  against the Vite dev server. ui-smoke mounts all 12 routable views and fails
  on any code-split/import failure, uncaught exception, or ErrorBoundary
  fallback. gallery.spec asserts the "OmniVoice Gallery" heading, the dark
  facet dropdowns (computed bg = rgba(255,255,255,0.04), not the OS-default
  light surface), and that opening an archetype in the Designer mounts the
  lazy CloneDesignTab. `bun run e2e`.
- backend/tests/test_archetype_preview_quality.py: unit-tests the
  _spectral_flatness / _is_unusable_audio guard with synthetic signals
  (tone < threshold < speech < noise; loud tone + silence are unusable) and
  pins the render constants. CI-safe — no model/GPU.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 08:34:46 +05:30
Palash DebnathandClaude Opus 4.8 d58e5a9e38 feat(launchpad): Transcripts card + recent OmniDrive files strip (#235)
- New Transcripts action card (lime accent, FileText) → opens the
  Transcriptions view, alongside Clone/Design/Dub/Stories/Gallery.
- Below the cards, a "Recent files" strip shows the last few exports from
  OmniDrive (GET /export/history, already loaded on mount) with a "View all
  files →" link that jumps to the full OmniDrive browser (Projects page).
  Hidden when there are no exports yet.

App.jsx passes exportHistory to Launchpad; reuses existing lp-project-card
chrome, adds a small files-head + view-all + grid in index.css. 5 new
launchpad.* keys, backfilled across 21 locales.

Verified: tsc clean, build OK, vitest 167/167, CJK guard passes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:32:32 +05:30
Palash DebnathandClaude Opus 4.8 07479be04f feat(support): unify Donate + Commercial License behind one toggle (#234)
* feat(support): unify Donate + Commercial License behind one toggle

The donate page and the commercial-license (enterprise) page were two separate
full-screen modes reached from different places. Merge them into a single
SupportPage with a charming segmented toggle:

- Segmented "💛 Support ⇄ 🏢 Commercial License" control with a sliding active
  pill that carries each panel's accent hue (pink for Support, teal for
  License) and an icon that pops on selection.
- Switching cross-fades the panel (key remount replays the hero/card entry
  animations) over the shared Launchpad aurora + a single Back button.
- Both legacy modes still work: 'donate' opens the Support tab, 'enterprise'
  opens the Commercial License tab — so the footer heart and the dub/export
  "commercial license" links land on the right tab unchanged.

Reuses the existing donate/enterprise chrome (DonatePage.css + EnterprisePage.css
kept and imported); SupportPage.css only owns the toggle + transitions. Both
views share one 640px container width so the frame doesn't jump on toggle.
DonatePage.jsx + EnterprisePage.jsx removed (content folded in).

3 new support.* i18n keys, backfilled across 21 locales.
Verified: tsc clean, build OK (SupportPage chunk replaces the two old ones),
vitest 167/167, CJK guard passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(support): add 'Other ways to help' chips + FSL hero_note fix + i18n backfill

- SupportPage: add Star GitHub / Join Discord chips below donation methods
- SupportPage CSS: vertically center short Support panel, single-column
  donation grid, ghost-pill chip styles
- Fix FSL hero_note wording across all 21 locales to accurately reflect
  the license (internal use at any scale is free; only competing
  product/service triggers commercial license)
- New i18n keys: support.other_ways, support.star_github, support.join_discord

* feat(support): polish Support panel + correct Commercial License wording

Support panel (from screenshot feedback):
- Donation methods now stack in a single clean column — no orphaned PayPal
  card floating in a half-empty second row.
- Short Support panel is vertically centered so it no longer clings to the top
  of an empty page (License stays top-aligned; it's tall enough to fill).
- New "Other ways to help" row: Star on GitHub + Join Discord ghost chips, so
  people who can't donate still have a real way to support — and it balances
  the layout.

Commercial License wording:
- Fixed enterprise.hero_note: it implied "deploying at scale (pay-per-use API)"
  triggers a commercial license. Per the actual FSL-1.1-ALv2, scale does NOT
  trigger licensing — internal use is free at any scale; the trigger is
  offering OmniVoice to others as a competing product/service. Reworded to say
  exactly that, and re-translated across all 21 locales.

3 new support.* keys. Verified: tsc clean, build OK, vitest 167/167, CJK guard
passes, 21 locales at parity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: drop stray stories-editor plan doc that slipped into the branch

This planning artifact (with CJK i18n examples) was accidentally swept into
an earlier commit on this branch; it isn't part of the Support-page feature
and isn't on main. Removing it so the CJK guard passes — the committed tree
no longer carries hardcoded CJK outside the translation layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:32:22 +05:30
Palash DebnathandClaude Opus 4.8 dc453cf9da fix(i18n): close coverage gap the translation PR missed (#230 follow-up) (#232)
The "full i18n coverage" PR (#230) was based on a stale snapshot, so strings
added to main after that point — the updater/channel dialog, dictation
shortcut, and a batch of export/save/project toasts — were still hardcoded
English. This extracts the remaining user-facing imperative strings (toasts +
the update ask() dialog) plus the adjacent JSX labels in DubFailureNotice:

- App.jsx (17): export/save/download/project/flush toasts → i18n.t('app.toast_*')
  (App already imports the configured i18n instance; reused it rather than
  plumbing a hook through 17 handlers).
- Settings.jsx (14): save/clear failures, engine-switch, channel, updater
  download/install + the "Update available" ask() dialog, dictation-shortcut
  set/register/reset → t('settings.*').
- DubTab.jsx (DubFailureNotice): added the useTranslation hook; "Diagnostic
  copied"/"Copy failed" toasts + "Open docs"/"Copy diagnostic" labels.

38 new keys added to en.json, backfilled across all 21 locales. No regressions:
the only shared-key value change from the #230 merge was the intentional
engines.unavailable casing fix.

Verified: 21 locales at parity, tsc clean, build OK, vitest 167/167, CJK guard.

NOTE: this covers imperative strings (toast/ask) in these 3 files. A full
codebase audit of all JSX text/placeholders is a larger separate sweep.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:24:06 +05:30
Palash DebnathandClaude Opus 4.8 dec67dc619 feat(gallery): calmer, more elegant archetype cards (#231)
The "Use voice" buttons were solid, fully-saturated per-category color fills —
16 loud, differently-hued blocks on screen drew the eye to the buttons instead
of the voice names. Polish pass:

- Use-voice button: tonal by default (13% accent wash + accent-colored text +
  hairline accent border), going solid only on hover/focus. Keeps the
  per-category hue as identity but lets the resting grid stay calm; the CTA
  lights up on the card you're pointing at.
- Chip row: always rendered with a reserved min-height so cards without an
  accent/whisper chip (e.g. Captain Crusty) no longer leave a ragged void —
  action rows now line up across the grid.
- Designer (wand) button: quiet at 0.5 opacity at rest, full on card
  hover/focus — it's tertiary, so it no longer competes on every tile.
- Card hover border softened a touch.

color-mix() is already used in 17 frontend files (proven on all WebView
targets). Verified: tsc clean, build OK, vitest 167/167.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:39:22 +05:30
Palash DebnathandClaude Opus 4.8 40f96db1d7 feat(l10n): extract remaining 531 hardcoded strings — full i18n coverage (#230)
* feat(l10n): complete translations for all 21 languages (837 keys each)

Translate all UI keys across every component for 20 non-English locales:
ar, de, es, fr, hi, id, it, ja, ko, nl, pl, pt, ru, sv, th, tr, uk, vi, zh-CN, zh-TW

- 837 flattened keys per language (100% coverage)
- Covers settings, splash, main UI, dialogs, tooltips, errors
- Placeholders ({{var}}) and HTML tags (<1>) preserved
- Add translate_all.py batch script for future re-translations

* feat(l10n): extract remaining 531 hardcoded strings and translate all UI

Scan found ~250 hardcoded user-facing strings across ~30 component files.
Extracted all into en.json (837 → 1368 keys, 49 namespaces) and updated
every component to use t() / i18next.t().

Components updated (35 files):
- Zero-i18n: AudioTrimmer, CaptureWidget, CastingView, CheckpointBanner,
  CompareModal, DirectionDialog, EngineCompatibilityMatrix, ErrorBoundary,
  FloatingPill, KeyboardCheatsheet, NetworkToggle, ReadinessChecklist,
  SupertonicLicenseDialog, VoicePreview, BatchAddDialog
- Partial-i18n: BootstrapSplash, DubSegmentRow, Header, LogsFooter,
  DictationDemo, DubbingDemo, NavRail, MultiLangPicker, SearchableSelect,
  Sidebar, Settings, SetupWizard, EnterprisePage, VoiceGallery,
  SharingPanel, ReportBugButton, StoriesEditor, App.jsx
- Hooks: useDubWorkflow.js, useTTS.js (using i18next.t directly)

New namespaces: trimmer, casting, checkpoint, compare, direction, errors,
keyboard, header, sidebar, network, readiness, license, voicePreview,
models, enterprise_faq, gallery_extra, dub_workflow, tts_errors, sharing,
reportBug, app

All 20 non-English locales translated to 100% (1368 keys each).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:31:09 +05:30
Palash DebnathandClaude Opus 4.8 f290575593 fix(logs): import useAppStore in LogsFooter (donate + notification nav crashed) (#229)
LogsFooter.jsx called useAppStore.getState().setMode(...) in four handlers
(donate button + notification action targets, lines ~405/447/880/922) but
never imported useAppStore — clicking any of them threw
'ReferenceError: Can't find variable: useAppStore' and the handler died.

Add the canonical 'import { useAppStore } from "../store"'. tsc + build clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:00:42 +05:30
Palash DebnathandClaude Opus 4.8 c2ce92cf69 feat(launchpad): surface Stories + Voice Gallery; fix gallery key-spread warning (#228)
- Launchpad showed only Clone/Design/Dub. Add ActionCards for the two newer
  modes: Stories (multi-voice audiobooks → setMode('stories')) and Voice Gallery
  (browse designed-voice archetypes → setMode('gallery')). i18n in en + zh-CN,
  backfilled across all 21 locales.
- VoiceGallery: stop spreading a 'key' prop into <ArchetypeCard {...cardProps}>
  (React dev warning + ignored). cardProps no longer carries key; pass key={a.id}
  directly at the two render sites.

Verified: tsc clean, build OK, vitest green, CJK guard passes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 13:56:47 +05:30
Palash DebnathandClaude Opus 4.8 94ed7e6347 feat(stories): redesign — chapter section bars, grouped toolbar, readable column (#227)
Full design polish pass (behavior unchanged; JSX structure + CSS only):

- Chapters render as distinct section bars (heading title + grip + delete) with
  an accent left-border — no speaker/voice/tune/preview controls. Detection
  (isChapterText) is lenient so clearing the title doesn't flip the bar back to
  a voiced line mid-edit; unified with the chapter auto-numberer.
- Toolbar split into three labelled clusters with thin dividers — Project
  (Projects · Cast) · Content (Import · Paste&Split · +Line · +Chapter) ·
  Output (Stems · format · Generate) — and wraps instead of cramming one row.
- Editor centered at a 1040px reading column so lines no longer stretch
  edge-to-edge on wide windows.
- A line's secondary actions (inline-voice / tune / pause / preview / delete)
  are quieted to 0.5 opacity and revealed on row hover/active, cutting visual
  noise. Drag handlers factored into a shared dragProps (reused by both bar
  and line) so reordering still works across chapters + lines.

Verified: tsc clean, build OK, vitest 167/167.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 13:40:40 +05:30
Palash DebnathandClaude Opus 4.8 94f2363fa2 feat(release): version preview builds (0.3.0-preview.N) + rollback spec (#226)
Phase A: stamp each preview build with a unique monotonic semver prerelease
(<base>-preview.<run_number>) via an ephemeral tauri.conf.json rewrite on the
preview path. Today every preview reported the static 0.3.0, so the updater
never saw a newer version and never delivered preview updates. The prerelease
ordering makes each new preview offer-able and converges to stable when <base>
ships. (Windows MSI ProductVersion strips the prerelease — caveat noted to
verify; mac/linux unaffected.)

Phase B (rollback) is captured as a design spec for review, not implemented:
per-version preview releases + retention, an in-app Preview-builds picker, an
allow_downgrades install path, and the alembic-head data-safety boundary.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 13:25:42 +05:30
Palash DebnathandClaude Opus 4.8 675cc203ee fix(asr): cap setuptools <80 so pkg_resources stays present (#224) (#225)
Windows v0.3 users hit two transcription failures with models installed:
- WhisperX: "Transcription produced no segments. No module named 'pkg_resources'"
- Whisper PyTorch fallback: "No ASR backend is ready. … set OMNIVOICE_PRELOAD_TTS_ASR=1"

Shared root cause: whisperx / faster-whisper import `pkg_resources` at runtime,
and setuptools 80+ DROPPED the bundled pkg_resources. The existing pin
`setuptools>=75` therefore resolved to 82.0.1 — which has no pkg_resources — so
`import whisperx` fails. That both breaks WhisperX transcription and makes its
is_available() return false, which is why every backend reports "not ready" and
the engine asks for the PyTorch fallback (the user's PowerShell env var never
reached the GUI-launched app, a separate red herring).

Fix: pin `setuptools>=75,<80`. Verified: <80 resolves to 79.0.1 which ships
pkg_resources; 82 does not. `uv lock` changed only setuptools (82.0.1→79.0.1).
This fixes BOTH errors — WhisperX imports again, so it's available and the
fallback is no longer needed.

Adds tests/test_pkg_resources_available.py to guard the pin from regressing.
Full suite 602 passed (incl. the new test), 0 failures.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:46:32 +05:30
Palash DebnathandClaude Opus 4.8 6a86d2151f refactor(settings): move Performance toggles to the General tab (#223)
The Performance panel (Disable torch.compile / Show live system metrics in
header) was nested under the Credentials tab — an odd home. Render it in the
General tab instead, where users look for app-level toggles. Pure relocation:
PerformancePanel is unchanged; removed its render from CredentialsTab and added
it after GeneralTab in the general view.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:30:43 +05:30
Palash Debnath 5320b57de5 fix(release): macOS smoke mount path preserves space in volume name (#221) 2026-06-01 10:16:10 +05:30
Palash Debnath fa9cda3381 fix(release): macOS signing env must be ABSENT not empty (unblocks mac build) (#220) 2026-06-01 10:01:26 +05:30
Palash DebnathandClaude Opus 4.8 3fd326b845 feat(update): dismiss button for the failed-update pill (#219)
#216 stopped the 6h periodic re-check from clobbering a failed-install error
badge — correct, but it left no way to clear that badge except retrying, so a
transient install failure pins a red "Update failed · Retry" pill until the
user retries or restarts the app. Add a × to dismiss it (mirrors FloatingPill),
returning the updater surface to idle.

- updaterSlice: dismissUpdate() → idle + clears error/progress
- UpdateBadge: × dismiss button on the error state (i18n: update.dismiss)
- updaterSlice.test: dismiss returns to idle and clears the error
- en.json: update.dismiss ("Dismiss"); other locales fall back to en

Verified: vitest 10/10 (updaterSlice + updater guard), typecheck:ci clean, build OK.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:42:42 +05:30
Palash Debnath 6aa581c5f1 fix(release): resolve AppImage path before cd in Linux smoke (exit 127) (#218) 2026-06-01 09:38:46 +05:30
Palash DebnathandClaude Opus 4.8 1ece49a080 fix(release): make macOS signing opt-in so a bad cert can't break builds (#217)
The APPLE_CERTIFICATE secret is currently set-but-invalid, so tauri-action's
'security import' fails and kills the whole macOS build — on stable v* releases
too, not just preview. Make Developer-ID signing OPT-IN: pass the Apple creds
only on a v* tag push AND when the repo variable MACOS_SIGNING_ENABLED == 'true'.
Otherwise pass empty -> the build stays unsigned and succeeds (users clear
quarantine via xattr -cr, as documented). Preview is always unsigned.

To re-enable signed stable releases: fix the signing secrets, then set
MACOS_SIGNING_ENABLED=true (Settings -> Secrets and variables -> Actions ->
Variables). No code change needed to flip it.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:24:01 +05:30
Palash DebnathandClaude Opus 4.8 679b2b5e4f fix(update): keep error/retry pill across periodic re-check (#214 follow-up) (#216)
* fix(update): keep error/retry pill across periodic re-check (#214 follow-up)

PR #214's 6h periodic re-check guard skipped only downloading/ready, not
error. setUpdateChecking() clears updateError and the badge renders null
for 'checking', so a tick while 'Update failed · Retry' was showing
silently erased the prompt the user still needed to act on — defeating
the PR's own error-surfacing goal (greptile P1, unresolved).

- updater.js: also short-circuit the re-check on 'error'. Retry is
  user-initiated (installUpdate → downloading), so auto re-check is
  unnecessary in that state.
- updater.test.js: new regression test — guard no-ops on
  error/downloading/ready, proceeds from idle.
- UpdateBadge.jsx: add aria-controls + panel id to the 'What's new'
  disclosure (greptile P2 a11y).
- UpdateBadge.css: word-break:break-word → overflow-wrap:break-word
  (CodeRabbit; the deprecated value).

Verified: vitest 166/166, typecheck:ci clean, bun run build OK, CJK guard pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(update): keep notes panel mounted so aria-controls always resolves

greptile P2: aria-controls pointed at a conditionally-rendered panel, so
the IDREF dangled while collapsed. Render the panel whenever notes exist
and toggle with the hidden attribute (canonical disclosure pattern) — the
reference now always resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:02:38 +05:30
Palash DebnathandClaude Opus 4.8 891e819b4e fix(release): unblock all-platform preview/release builds + auto-generated notes (#215)
The first preview build surfaced four real release-pipeline issues (all of which
also affect a stable v* release):

- macOS: build died at codesign — `security import: failed to import keychain
  certificate` (the APPLE_CERTIFICATE secret is set but invalid). Preview now
  force-skips Apple signing (passes empty creds) so it can't fail on a bad/absent
  cert; stable v* tags still receive the secrets, so signing engages once the
  cert is fixed.
- Linux: .deb bundling fails with "Failed to create control scripts: No such
  file or directory" (no custom deb config of ours). Drop .deb, ship AppImage
  only — the universal Linux format and the Linux auto-update target.
- Installer smoke (all 3 OSes): the steps hunted for a frozen backend binary to
  boot with --health-check, but the thin uv-venv installer ships no such binary
  (the venv builds on first launch). Rewrite to structural verification —
  assert the bundle carries the shell binary + bundled uv sidecar + backend
  source resources (pyproject.toml + backend/main.py).

Also: a new preview-notes job regenerates the rolling preview release body with
GitHub's auto-generated notes (What's Changed by PR + New Contributors + Full
Changelog) plus a Contributors avatar strip built from the PR authors — instead
of the bare "Auto-generated release for main…" fallback. Runs once after the
matrix, preview-only; stable keeps its CHANGELOG section + appended checksums.

Stable v* tag-push behavior is otherwise unchanged. YAML validated.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 08:53:28 +05:30
Palash DebnathandClaude Opus 4.8 8cf3dad2f4 feat(gallery): multilingual designed-voice archetypes (#213)
* feat(gallery): multilingual designed-voice archetypes

Ship curated designed voices in 9 more languages (Spanish, French, German,
Italian, Portuguese, Russian, Hindi, Japanese, Korean) so the gallery offers
more than English + Chinese out of the box -- 27 new featured archetypes
across three reusable roles (Narrator / Explainer / Companion).

Voice-design timbre (gender/age/pitch) is language-independent, and a designed
voice's spoken language is driven by the preview text, not the instruct. So
these reuse a neutral instruct + a localized sample script + a `language` value
matching frontend/src/languages.json -- byte-for-byte the same
model.generate(text, language, instruct) call the Generate tab already makes.
They carry no accent/dialect token (accents are English-only, dialects
Chinese-only; an invented "spanish accent" would crash synthesis, the issue-#89
mode), so every instruct stays inside the validator vocabulary.

- backend/core/archetypes.py: _ML_SAMPLES + _ML_ROLES + _make_multilingual()
- frontend VoiceGallery: extend the language facet filter
- tests: assert the 9 languages are present, neutral-timbre, valid-token
- test_no_hardcoded_cjk: note JA/KO sample text in the existing allowlist entry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gallery): surface featured-only languages through the filter

Address review feedback (greptile/coderabbit) on #213:

- VoiceGallery: the Browse query hard-coded `featured: false` while the
  Featured strip is hidden whenever a filter is active. The 9 new languages
  have *only* featured archetypes, so selecting Spanish/French/etc. produced
  an empty Browse AND a hidden Featured strip -> "No voices match these
  filters" despite 3 archetypes existing per language. Now Browse includes
  featured exactly when the Featured strip is hidden (i.e. when filtering),
  with no duplication when nothing is filtered.
- archetypes.py: module docstring said the Featured tier was "~24"; it is now
  ~51 (24 English + 27 multilingual). Added a docstring to _make_multilingual().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 08:37:29 +05:30
Palash DebnathandClaude Opus 4.8 edbf0af43f feat(update): release notes in badge + periodic re-check + error surfacing (#214)
Closes the three highest-value gaps from the auto-update audit vs best-in-class:

1. Release notes — the available pill gains a 'What's new' expander showing the
   release body (already captured as updateNotes) so users see what changed
   before installing.
2. Periodic re-check — App.jsx re-checks every 6h, not only on boot, so
   long-running sessions get notified. checkForUpdate now no-ops while a
   download/restart is in flight, so the interval can't interrupt an install.
3. Error surfacing — the badge no longer returns null on 'error'; it shows a
   'Update failed - Retry' pill (with the error as tooltip) that re-attempts
   the install, instead of silently vanishing.

i18n: update.whats_new/failed/retry added to en + zh-CN and backfilled across
all 21 locales (placeholders intact).

Verified: tsc clean, vitest 162/162, build OK, CJK guard passes, 21 locales
valid + key-complete.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 08:29:06 +05:30
Palash DebnathandClaude Opus 4.8 c4d66a8cf5 docs(readme): add ASR Engines table (surfaces SenseVoice/FunASR) (#212)
* docs(readme): add ASR Engines table (surfaces SenseVoice/FunASR)

The README documents the multi-engine TTS backend but never listed the
ASR backends, so users filed requests (#206, #208) for engines that
already ship. Add an ASR Engines section mirroring the TTS table,
grounded in backend/services/asr_backend.py engine ids + display names,
plus a nav anchor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(readme): correct Parakeet language scope + MLX framework name

Address review feedback on PR #212:
- Parakeet TDT: NeMoASRBackend docstring documents 25+ European
  languages w/ auto language detection (not English-only); note GPU req.
- MLX Whisper: the engine uses Apple's MLX (Metal-backed) framework,
  not the CoreML inference stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 08:28:23 +05:30
Palash DebnathandClaude Opus 4.8 cf816b54f7 fix(ci): skip supertonic license/cpu tests when the optional dep is absent (#211)
release.yml installs deps with plain 'uv sync' (no optional engines), so
test_cpu_only_honest and test_license_gate failed there — is_available()
short-circuits with 'supertonic package not installed' before reaching the
license check those tests assert on. This blocked EVERY release (preview and
stable) at the test gate, not just the preview build that surfaced it.

Skip the two when 'supertonic' isn't importable (optional opt-in engine). They
still run fully under ci.yml's 'uv sync --all-extras'; the absent-package path
is covered independently by test_optional_dep_missing. Also fixes the same two
failing on a local '.venv' without the extra.

Verified: tests/test_supertonic3.py now 8 passed, 5 skipped, 0 failed without
supertonic installed.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 08:12:55 +05:30
Palash DebnathandClaude Opus 4.8 47729057bd chore(lint): remove unused imports + variables (ruff F401/F841) (#210)
Autofixes the genuine lint behind the CodeQL py/unused-import and
py/unused-local-variable note-level alerts — actually removing the dead
code rather than dismissing it. 68 safe fixes via 'ruff check --select
F401,F841 --fix' across 29 backend files (dead stdlib/symbol imports like
io/sys/json/torch/typing.Optional and unused locals). Only ruff's safe
fixes applied — the 9 'unsafe' fixes and the audio_dsp numpy availability
import were left untouched.

Not touched: empty-except (needs per-site judgement, not autofixable);
frontend js/unused-local-variable (eslint no-unused-vars has no autofix);
the loopback-low-risk path/log/stack-trace alerts (real, left visible).

Verified: full tests/ suite unchanged at 601 passed (the 2 test_supertonic3
failures are pre-existing on main, local .venv state, green in CI).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 07:53:06 +05:30
Palash DebnathandClaude Opus 4.8 253122325c feat(gallery): lucide/flag icon redesign + community marketplace (omnivoice-gallery) (#207)
* feat(gallery): lucide icons + country flags + card redesign (replace emoji)

- backend archetypes emit lucide-react icon *names* (cross-platform; emoji
  render inconsistently across OSes) for use-cases and the 24 featured voices.
- new frontend/src/utils/archetypeIcons.jsx: name→lucide map, accent→country
  flag (country-flag-icons, tree-shaken to ~11), per-category color scale,
  color-coded avatar tile, and a CSS-animated now-playing equalizer
  (prefers-reduced-motion aware).
- card redesign: real elevated surfaces (cards were invisible on the dark bg),
  avatar + name + facet sub-line, accent/flag chips, and a footer with
  Preview / category-colored "Use voice" / Open-in-Designer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(marketplace): community voice gallery via omnivoice-gallery submodule

Offloads curated + community gallery content to the standalone
debpalash/omnivoice-gallery repo — added here as a submodule for authoring,
loaded at runtime via the jsDelivr CDN so the binary stays small.

Content repo (seeded + pushed separately): manifest.json (24-voice starter
pack generated from the featured archetypes), a JSON schema, CONTRIBUTING,
and GitHub submission templates carrying consent / no-impersonation guardrails.

Backend (api/routers/community.py): configurable sources (env var > file >
default), CDN fetch with offline disk cache, strict validation (invalid
presets and non-allow-listed audio URLs are dropped, so a bad community entry
can neither crash synthesis nor fetch from an arbitrary host), filtering, the
prefilled submit URL, and "use" (preset → archetype render path; voice →
sha256-verified download). 11 tests.

Frontend: a third gallery zone, "Community", reusing the redesigned card, plus
"Submit a preset / voice" buttons opening the prefilled GitHub forms.

Local-first preserved: network only on open/refresh; everything cached; the
built-in generated archetypes need no network, so the gallery is never empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(marketplace): address review feedback on the community gallery

- community_use: run the blocking manifest read + voice download in a thread
  (asyncio.to_thread) so they don't stall the event loop (greptile P2).
- community_submit_url: validate the `source` override against an owner/repo
  pattern, falling back to the configured default (greptile P1 hardening).
- rename the `type` query param to `item_type` (alias="type") so it no longer
  shadows the Python builtin (coderabbit).
- frontend submit buttons use the canonical openExternal() (Tauri-aware) instead
  of window.open, which doesn't open the system browser in the desktop app.
- lowercase the `currentcolor` CSS keyword (stylelint).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(marketplace): rename useCommunityItem -> addCommunityItem (not a hook)

Avoids the use-prefix on a plain API function (rules-of-hooks smell).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:24:31 +05:30
Palash DebnathandClaude Opus 4.8 9e04d5c683 i18n: backfill gallery + archetypes keys across 20 locales (#205)
The de-celebrified Voice Gallery (#203) added gallery.* (45) + archetypes.*
(15) keys to en.json only, so all 20 other locales fell back to English for the
new gallery UI. Backfill all 60 keys into every locale via the project's own
scripts/translate_all.py (Google Translate, placeholder-masked, incremental) —
the same tool/path fixed in the earlier update-channel backfill.

Also removes 8 stale gallery.cat_* keys per locale (cat_celebs, cat_marvel,
cat_disney, cat_politicians, cat_anime, cat_books, cat_gaming, cat_news) — the
dead celebrity categories #203 removed from en.json but left behind in the
other locales. Finishes the de-celebrification across the whole i18n layer.

en.json / source untouched. Verified: all 21 locales valid JSON and key-aligned
to en for gallery + archetypes (0 missing); {{version}}/{{pct}}/{{channel}}-style
placeholders intact (0 losses); tsc clean; build OK; CJK guard passes (locales
are the allowlisted translation layer).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:40:08 +05:30
Palash DebnathandClaude Opus 4.8 fd6f213517 fix(audio): stop near-silent renders becoming blank noise + guard archetype renders (#204)
* fix(audio): stop near-silent renders becoming "blank noise" + guard archetypes

Root cause of the blank/hiss voices: normalize_audio peak-normalized to -2 dBFS
whenever max(|audio|) > 0. When the model emits a near-silent clip (peak at the
noise floor, e.g. 1e-4), that applies thousands of × of gain and lifts the
noise floor to full scale — silence turned into loud hiss. This affected every
generation path (clone/dub/design/archetypes), which is why "some voices" came
out as blank noise.

- services/audio_dsp.py: normalize_audio gains a -50 dBFS silence floor. At or
  below it the audio is left untouched (stays inaudible) instead of being
  amplified. Real speech — even a whisper — peaks well above the floor, so
  normal output is unchanged.
- api/routers/archetypes.py: after rendering, _is_blank_audio() detects a dead
  clip (empty / non-finite / peak < 0.02 — a real normalized clip peaks ~0.79).
  The render retries once with a different seed, then fails loudly (503 via the
  existing handlers) so a blank preview or voice profile is never cached/saved.
  Also extracts the script with a non-empty fallback.
- core/archetypes.py: _build never falls back to an empty script (empty text
  synthesizes to silence).

Tests (tests/, runs in CI): normalize_audio doesn't amplify silence but still
normalizes real audio to target; _is_blank_audio flags dead renders and passes
real audio; every archetype carries a non-empty sample script.

Verified: full tests/ suite 601 passed incl. 8 new (the 2 test_supertonic3
failures are pre-existing on main — local .venv engine/license state, green in
CI — and unrelated to this diff).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gallery): static log message in blank-render retry (clears py/clear-text-logging)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:27:30 +05:30
Palash DebnathandClaude Opus 4.8 e1850b4bd9 feat(gallery): designed-voice archetype gallery + neutral importer (#203)
* feat(gallery): designed-voice archetype gallery + neutral importer

Adds a browsable library of ~1,100 designed voice archetypes (no real
people), generated from OmniVoice's own voice-design taxonomy and
organized ElevenLabs-style: 24 curated Featured voices plus a
facet-filtered "Browse all" explorer (595 English + 504 Chinese-dialect).
Every generated instruct is built from the validator's own vocabulary, so
none can trigger the issue-#89 synthesis crash.

Backend:
- core/archetypes.py: catalog engine (featured + generated, implausible
  combos pruned, stable hashed ids); loads the taxonomy by file path to
  stay torch-free in tests.
- api/routers/archetypes.py: categories / list+filter+paginate / get /
  preview (render-on-demand + disk cache) / use (materialize a voice
  profile). Preview/use reuse generation.py's proven inference path.
- gallery.py: drop the celebrity/character catalog; the importer is now a
  neutral, user-driven "My Imports" (paste a URL you have the rights to).
  No project-shipped directory of named real people.

Frontend:
- Gallery UI rewrite: Archetypes zone (featured grid + facet filters +
  favorites) and My Imports zone; per-card Use voice / Open in Designer.
- api/archetypes.ts, useArchetypes/useArchetypeCategories hooks (v5
  placeholderData:keepPreviousData), gallerySlice, en.json keys.

Tests: 27 new (engine contract + API), full backend suite green (72);
CJK guard allowlists the one functional Chinese preview sample.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gallery): clear new bandit alerts (sha1 + SQL false-positive)

The PR's code-scanning "Bandit" check fires on NEW alerts vs main's baseline.
The archetype work introduced three:

- archetypes.py / core/archetypes.py: hashlib.sha1 used to derive a
  deterministic preview-cache key and archetype id (not a security digest) —
  flagged B324 (HIGH). Add usedforsecurity=False; the digest is unchanged.
- gallery.py: the UPDATE query interpolates only static, code-controlled column
  fragments ("is_favorite = ?", "description = ?"); every user value is bound
  via a ? placeholder — flagged B608 (false positive). Annotate `# nosec B608`
  with the justification.

Behavior-preserving. Net new bandit alerts after this: zero (verified with
bandit -ll -ii; only main's pre-existing baseline remains).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gallery): resolve PR #203 CI (SHA-256 ids, log sanitization, CJK allowlist)

All failures stemmed from the initial commit:

- Bandit + CodeQL (2 high): SHA-1 weak-hash on the archetype id and the
  preview cache key. These are deterministic identifiers, never security
  digests — switched to SHA-256, which the SAST scanners accept.
- CodeQL (log injection): the render-failure logs echoed the raw
  user-supplied archetype_id; log the catalog's canonical a["id"] instead
  (untainted — it comes from the trusted in-memory catalog, not the request).
- CodeQL (superfluous argument): declare createGallerySlice's StateCreator
  store param so its arity matches the 3-arg call site.
- Tests (test_no_hardcoded_cjk): the committed design spec's Chinese-dialect
  reference table tripped the guard; allowlist it under documentation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gallery): clear CodeQL clear-text-logging on archetype render errors

CodeQL's sensitive-data heuristic flags any request-derived value
interpolated into a log call (it persisted even after switching the raw
id to the catalog's canonical a["id"]). Log a static message with
exc_info=True instead: the full traceback still reaches the backend log
for debugging, but no data expression remains for the query to flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 12:49:51 +05:30
Palash DebnathandClaude Opus 4.8 87e4df95cd i18n: backfill update-channel + auto-update keys across 18 languages (#202)
PR #200 added 18 new locale files, but they predated #199 (auto-update badge +
Stable/Preview channel toggle), so they were missing the `update.*` namespace
(6 keys) and `about.channel_*` (5 keys) — those strings fell back to English in
ar/de/es/fr/hi/id/it/ja/ko/nl/pl/pt/ru/sv/th/tr/uk/vi/zh-TW.

Backfill all 11 keys in every one of those languages so the updater UI is fully
localized. en.json / zh-CN.json already had them and are untouched. Placeholders
({{version}}, {{pct}}, {{channel}}) preserved verbatim; files re-emitted in the
exact format scripts/translate_all.py writes (ensure_ascii=False, indent=2) so
the diff is additions only (+13 lines/file, 0 deletions).

Also fix scripts/translate_all.py: LOCALES_DIR was hardcoded to a contributor's
absolute path (/Users/.../orca/...) — make it repo-relative so the generator
actually runs for anyone.

Verified: all 21 locales valid JSON + key-complete, placeholders intact;
tsc clean; vitest 162/162; build OK; CJK guard passes (locales are the
allowlisted translation layer).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 11:21:00 +05:30
Palash Debnath 52d7321287 feat(l10n): complete translations for all 21 languages (837 keys each) (#200)
Translate all UI keys across every component for 20 non-English locales:
ar, de, es, fr, hi, id, it, ja, ko, nl, pl, pt, ru, sv, th, tr, uk, vi, zh-CN, zh-TW

- 837 flattened keys per language (100% coverage)
- Covers settings, splash, main UI, dialogs, tooltips, errors
- Placeholders ({{var}}) and HTML tags (<1>) preserved
- Add translate_all.py batch script for future re-translations
2026-05-31 11:08:36 +05:30
Palash DebnathandClaude Opus 4.8 4ec5b4cb7d chore(security): scope CodeQL to shipped product code (#201)
CodeQL flagged 459 alerts on main, but triage showed the bulk are in code
that never ships in the installer's runtime path: file-not-closed in the
omnivoice/eval harnesses, unused-global "FPs" in alembic migration boilerplate
(revision/down_revision), bind-all in tests, and path sinks in the legacy
Gradio research UI. They drowned out the handful of real findings.

Add a CodeQL config (inline, supported because build-mode is `none`/interpreted)
that scopes analysis to product code via paths-ignore: omnivoice/eval, research,
tests, backend/migrations, and *.test.* files. Queries move into the inline
config so security-and-quality stays the single source of truth alongside
paths-ignore.

Net effect on the next scan: the non-shipped-code alerts auto-resolve, leaving
the security tab focused on shipped backend + frontend. No product code changes.

Deliberately NOT touched (assessed, left as-is):
- Stack-trace-exposure (detail=str(e) in routers) — these are intentional,
  helpful one-line diagnostics (the error-transparency work in b64f53b).
  Genericizing them on a loopback/single-user app would regress a product value
  for ~zero real benefit.
- "Critical" command-injection in exports.py and the high path-injections in
  settings.py / system.py — design-correct false positives: list-form argv (no
  shell), and the destination/model-dir/ffmpeg paths are arbitrary user-chosen
  paths by design (containment guards would break the features). Best handled by
  dismiss-with-justification, not code surgery.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 11:02:08 +05:30
Palash DebnathandClaude Opus 4.8 672f106f05 feat(update): Stable/Preview update channels with opt-in toggle (#199)
Adds a user-selectable updater release channel (Settings -> About -> Update
channel). Stable (default, every install + launch) tracks tagged vX.Y.Z
releases; Preview tracks the latest main build via a rolling "preview"
prerelease, falling back to stable if a stable release is ahead.

Why Rust: tauri-plugin-updater reads its endpoints from tauri.conf.json and
neither the JS check() nor the plugin's registration Builder can change them at
runtime (verified against the 2.10.1 source). The only runtime-endpoint API is
UpdaterExt::endpoints, so check+install move into two Rust commands that mirror
the plugin's own check/download_and_install -- the Stable path behaves
identically to the JS flow it replaces; only which manifest is consulted
changes. Switching is instant (channel is read per check), no restart.

backend (Rust):
- config.rs: update_channel field (default "stable", VALID_CHANNELS) +
  get/set_update_channel commands.
- updater_channel.rs: channel_endpoints() (preview -> [preview, stable]) +
  check_update / install_update commands; install emits update://progress.

frontend:
- utils/updateChannel.js (+test): single source of truth, normalizeChannel.
- utils/updater.js: routes the badge flow (#198) through the Rust commands via
  the same store contract -- UpdateBadge/App.jsx unchanged.
- Settings About: Stable/Preview segmented toggle, channel-aware endpoint row +
  diagnostics; Check-for-updates honors the live channel.
- i18n en + zh-CN.

release.yml: additive, workflow_dispatch-guarded preview publish to a rolling
"preview" prerelease. The v* tag-push stable path evaluates to its exact prior
values (verified) and is never affected. Preview builds are manual -- no
scheduled CI spend, nothing auto-published.

docs/update-channels.md.

Verified: cargo check (compiles clean), tsc, vitest 162/162, build, CJK guard,
release.yml YAML parses.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 07:49:49 +05:30
debpalash 588177c3ad Merge branch 'debpalash/translation' 2026-05-31 07:39:44 +05:30
debpalash 73c771fe87 feat(l10n): add 15 more major UI languages and update auto-detector 2026-05-31 07:39:38 +05:30
debpalash fa047166d3 Merge branch 'debpalash/translation' 2026-05-31 07:35:42 +05:30
debpalash 8cb2f54dee feat(l10n): add Spanish, French, German, Japanese UI locales, auto-detection, and splash page switching 2026-05-31 07:33:50 +05:30
Palash DebnathandClaude Opus 4.8 dfc86da9d7 feat(update): non-blocking auto-update with progress + states + busy-gating (#198)
Replaces the blocking ask() dialog with a state-driven, progress-visible flow
that never interrupts in-flight work — and preserves existing work by design
(user data lives outside the bundle; alembic migrates on next backend start).

- updaterSlice: idle→checking→available→downloading(pct)→ready/error state
  machine (transient, not persisted). 5 reducer tests.
- utils/updater: checkForUpdate() (launch, non-blocking → store) +
  installUpdate() (downloadAndInstall with a Started/Progress/Finished →
  progress callback, then relaunch). No-ops outside packaged Tauri.
- UpdateBadge: a non-intrusive pill — 'Update vX available · Install & Restart'
  → progress bar → 'Restart to update'. Install is gated while a dub job is
  generating (toast 'finish your dub first') so a relaunch can't lose work.
- App.jsx: launch check now just surfaces availability into the store + mounts
  the badge (no blocking dialog, no silent auto-install).
- i18n (en + zh-CN).

Builds on the existing tauri-plugin-updater (signed, GH-release latest.json).
Preview/main channel (a release.yml latest-preview.json + channel toggle) is a
follow-up; this is the stable-channel core.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 05:50:12 +05:30
Palash DebnathandClaude Opus 4.8 cd155f4384 feat(dub): FunASR cam++ inline diarization → dub speakers (#182 Phase 2) (#197)
When FunASR is the active ASR backend, use its cam++ per-segment speaker IDs
directly and skip pyannote — the 'all-in-one' diarization the issue asked for.

- asr_backend: FunASRBackend loads spk_model='cam++' (ASR_FUNASR_SPK, set '' to
  disable); transcribe()/_normalize_funasr already surface per-segment speakers.
- segmentation.assign_speakers_from_turns(segments, turns): generalised
  overlap-weighted speaker assignment from {start,end,speaker} turns (mirrors
  assign_speakers_from_diarization without a pyannote object; falls back to the
  silence-gap heuristic when no turns). Pure + tested.
- dub_core: _transcribe_chunk collects offset-shifted speaker turns; they
  accumulate across chunks; _diarize() uses them and skips pyannote when present.
  Default (WhisperX) flow unchanged — no turns → existing pyannote/heuristic path.

4 new tests (overlap winner, containing turn, malformed-turn filtering, empty→
heuristic). Router smoke confirms boot.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 05:43:33 +05:30
Palash DebnathandClaude Opus 4.8 7b346d95f7 feat(ui): density follow-up — tighten studio-panel padding (14/16 → 10/12) (#196)
Reduces the panel chrome padding across the studio (left + right columns),
reclaiming vertical + horizontal space uniformly. Safe global compaction; the
remaining bar-merge + borders→tints are a visual-iteration follow-up.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 05:29:45 +05:30
Palash DebnathandClaude Opus 4.8 e690ab79a7 feat(dub): density pass — tighten segment-row rhythm (min-height 30→27, padding 4→3) (#194)
Safe slice of the compactness pass: shave per-row vertical space without
clipping the two-line (translated + ORIG) content. The higher-impact wins
(collapsing the stacked TRANSCRIPT/GLOSSARY/translations-ready bars, borders→
tints) are a follow-up best tuned visually.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 04:35:34 +05:30
Palash DebnathandClaude Opus 4.8 b4b829095c fix(ui): compact the top bar height (per request — keep it, don't move) (#193)
Reduce .header-area vertical padding 6px → 3px and tighten the title
(line-height 1.15, 1.05rem) so the top bar is shorter, reclaiming vertical
space for content without relocating the bar.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 04:35:30 +05:30
Palash DebnathandClaude Opus 4.8 c70dd4c9bc fix(ui): larger Projects/History/Exports icons when the sidebar is collapsed (#192)
The collapsed sidebar rail showed the tab icons at size 13 — too small to read
as the only affordance. Bump to 18 when collapsed; the expanded tab bar keeps 13.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 04:35:26 +05:30
Palash DebnathandClaude Opus 4.8 e9bb4516b0 feat(asr): add FunASR (SenseVoice) as an opt-in alternative backend (#182) (#191)
FunASR is an all-in-one multilingual ASR (50+ languages, punctuation, optional
cam++ speaker diarization). ASR is already pluggable, so this is a new
ASRBackend:

- FunASRBackend (id 'funasr'): deferred funasr import in is_available() (reports
  an install hint when absent — opt-in, NOT a hard dep); _ensure_model loads
  AutoModel(SenseVoiceSmall + fsmn-vad); transcribe() normalises output.
- _normalize_funasr(): pure, defensive normaliser → OmniVoice's
  {chunks, segments, language} shape (handles VAD sentence_info with ms
  timestamps + optional speaker, single-utterance fallback, strips SenseVoice
  rich tokens). Unit-tested without funasr installed.
- Registered in _REGISTRY → auto-appears in /system/asr-backends → the Settings
  ASR picker, with availability/install hint. WhisperX stays the default.

Phase 2 (future): wire FunASR's cam++ speaker ids into dub diarization.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 04:35:22 +05:30
Palash DebnathandClaude Opus 4.8 7d0420066c feat(dub): move Output Options + Timing to the top of the right (transcript) section (#190)
Relocates the OUTPUT OPTIONS (Mix BG / Dual subs / Burn subs / Default Track)
and Timing-strategy rows from the full-width footer panel to the top of the
right transcript column, where the export-relevant settings sit next to the
segments they affect. Footer keeps only the done/error banners + export-track
toggles. typecheck/build green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 04:03:35 +05:30
Palash DebnathandClaude Opus 4.8 8040863f32 feat(dub): move Generate Dub + Export to the header bar (with Save/Reset) (#189)
Relocate the stateful primary-action cluster (Generate Dub / Stop / Stopping /
Regen-changed + Export) from the bulky footer button bar up to the header bar
next to Save/Reset, rendered as compact sm FooterBtns behind a thin divider.
Frees the footer to hold just output settings — tighter, less scrolling, and the
primary CTA sits where the file/Save/Reset context already is.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 03:50:27 +05:30
Palash DebnathandClaude Opus 4.8 8098e7537e fix(win): SoniTranslate venv paths cross-platform — Scripts/ on Windows (#186) (#188)
sonitranslate.py hardcoded SONI_VENV/'bin'/{pip,python} (POSIX). On Windows,
python -m venv creates Scripts\ with .exe, so is_venv_ready() was always False
(install looped) and start() fell back to the wrong interpreter -> 30s timeout.

- Add _venv_bin(name): Scripts/{name}.exe on win32 else bin/{name} (mirrors
  engines/indextts/bootstrap.py). Use it for the is_venv_ready/install/start
  pip+python paths.
- stop(): _proc.terminate() instead of send_signal(SIGTERM) (cross-platform);
  drop the now-unused signal import.
- test: _venv_bin returns Scripts/pip.exe on win32, bin/python on posix.

Closes #186.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 03:38:54 +05:30
Palash DebnathandClaude Opus 4.8 7d9338bd2d fix(dub): key per-segment WAVs by stable id, not list index (#185) (#187)
* fix(dub): key per-segment WAVs by stable id, not list index (#185)

Partial regeneration ('regenerate only changed segments') reloaded/wrote
seg_{i}.wav by LIST INDEX while the regen allow-list + fingerprints were keyed
by STABLE id. After a delete/merge/split (ids preserved, positions shifted),
unchanged segments reused a different segment's audio → silently corrupted dub
output on the default in-UI incremental path.

- core.config.dub_seg_path(job_id, seg_id): per-segment path keyed by stable
  id, sanitized to a bare filename (defends against path traversal via crafted
  ids). A numeric index sanitizes to the legacy seg_{i}.wav, so old jobs resolve
  through the same helper.
- dub_generate: write/reload per-segment WAVs by stable seg_id (deferred write,
  RVC write, regen reload) with a legacy seg_{i}.wav fallback; persist a
  job['seg_order'] manifest (index -> stable id) for index-keyed readers.
- dub_export: preview + stems-zip resolve the file via seg_order (-> stable id)
  with legacy fallback, so they keep finding the right audio.
- test: dub_seg_path id-naming, legacy-index equivalence, traversal sanitization.

Back-compatible with in-flight jobs (legacy index files still resolve). Closes #185.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dub): harden dub_seg_path with realpath containment; route all seg paths through it (CodeQL path-injection)

Both job_id and seg_id are request-derived. Sanitise both and verify the
resolved path stays inside DUB_DIR (realpath + startswith) — raises on escape.
Route the legacy index fallbacks in dub_generate/dub_export through dub_seg_path
so no raw os.path.join(DUB_DIR, job_id, ...) remains and a bare '..' component
can't traverse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dub): assert path containment at export sinks (recognized CodeQL barrier)

dub_seg_path already validates realpath containment, but CodeQL doesn't
propagate the barrier across the call. Re-assert at the FileResponse / zf.write
sinks (realpath + startswith on the value used) so the guard is recognized
in-function — clears the py/path-injection false positives.

* fix(dub): realpath+containment guard before any path sink in export/preview

CodeQL flags os.path.exists/FileResponse/zf.write as path sinks and won't
propagate dub_seg_path's internal guard. Resolve each candidate, realpath it,
and containment-check (startswith DUB_DIR) BEFORE any filesystem access — the
guard now dominates every sink in-function, clearing py/path-injection.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 03:31:03 +05:30
Palash DebnathandClaude Opus 4.8 f28f5fc749 fix(dub): ExportModal crash — t-shadowing in .map callbacks (#183) (#184)
Opening the Export modal on a dub job threw 'TypeError: e is not a function'
inside an Array.map during render, tripping DubTab's ErrorBoundary. Cause: the
zh-CN i18n sweep (#157) added t('…') translation calls INSIDE .map(t => …) /
.filter(t => …) callbacks where 't' was the loop variable (a track object / a
lang-code string), shadowing the useTranslation 't'. Calling the shadowed 't'
as a function threw (minified to 'e is not a function').

Rename the loop vars (t -> track / code) at the three <option>/<label> render
sites so they no longer shadow the translation fn; strings still go through t().
The harmless non-rendering shadows (useMemo/handlers that never call t()) are
left as-is.

Regression test renders ExportModal with a dub track equal to dubLangCode (the
exact crashing branch) and asserts it doesn't throw. Full suite 154/154,
typecheck/build/legacy ✓.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 02:37:31 +05:30
Palash DebnathandClaude Opus 4.8 a07da5f302 feat(stories): MP3 export — backend ffmpeg encode + format selector (#181)
Completes the pro-output story: an Export-format selector (WAV / MP3) on the
Stories toolbar. WAV stays fully client-side; MP3 routes the client-stitched
WAV through a new backend POST /stories/encode (ffmpeg via the Windows-reload-
safe spawn_subprocess, #175), with a strict format whitelist (mp3/m4b/ogg) and
bitrate validation so the uploaded format can't inject ffmpeg args. Both the
audiobook and per-character stems honor the selected format; MP3 falls back to
WAV with a toast if ffmpeg is unavailable.

- backend/api/routers/stories.py + main.py registration; temp-file cleanup.
- frontend/src/api/stories.ts encodeAudio() (apiFetch: same-origin + PIN).
- tests: format-whitelist 400, ffmpeg-missing 501, real mp3 encode (skips if
  ffmpeg absent). Backend 26 incl. router smoke; frontend 152/152, typecheck/
  build/CJK ✓.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:09:43 +05:30
Palash DebnathandClaude Opus 4.8 004a87e149 feat(stories): Phase 4 — pro output (stems + chapters) + named projects (#180)
Final phase of the pro-studio Stories Editor.

- Named projects: storiesSlice gains storyProjects[] + currentProjectId with
  save/load/new/delete/rename (transient fields stripped on snapshot); a
  Tauri-safe Projects panel (name input + Save + New + load/delete list).
  Persisted to localStorage.
- Per-character stems: 'Stems' export renders one WAV per cast voice
  (exportStems → exportStoryAudio per character group) and downloads each.
- Chapter markers: lines starting with '# ' are chapter headings (not spoken);
  Generate emits the audiobook WAV + a story-chapters.txt cue sheet with
  HH:MM:SS timecodes. 'Add Chapter' inserts a heading line.
- Pure + tested: isChapterLine/chapterTitle/formatTimecode/tracksByCharacter/
  buildCueSheet + project reducers. exportStoryAudio now returns
  {blob, chapters, durationSec}.
- i18n (en + zh-CN); 9 new unit tests. Full suite 152/152, typecheck/build/CJK/
  legacy ✓.

Client-side, no new deps, no DB. (MP3/M4B encode — a backend ffmpeg step — is
the one remaining optional add; WAV output is universal.)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:58:49 +05:30
Palash DebnathandClaude Opus 4.8 e07d7d92eb feat(stories): Phase 3 — per-line studio drawer (tone tags + speed) (#179)
Click the tune button on any line to reveal a drawer:
- Tone chips insert OmniVoice's native inline emotion/sound tags ([laughter],
  [sigh], [question-en], [surprise-wa], [confirmation-en], [dissatisfaction-hnn])
  at the cursor — the model-native way to direct tone (not the instruct param,
  which only whitelists gender/age/pitch/style/accent and rejects free emotion).
- Per-line speed slider (0.5–2.0x) → threaded into /generate for both preview
  and the audiobook export; reset-to-default.

- insertToken extracted to storyTokens (pure + tested); insertPauseInto + tone
  chips share it. exportStoryAudio now resolves per-track {profileId, speed}.
- i18n (en + zh-CN); 4 new unit tests. Full suite 143/143, typecheck/build/CJK ✓.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:43:04 +05:30
Palash DebnathandClaude Opus 4.8 7eb8186df7 feat(stories): Pro Studio Phase 2 — auto-cast from text + import (.txt/.srt) (#178)
* feat(stories): Phase 2 — auto-cast from text + import (.txt/.srt)

The no-brain ingestion path. Paste or import a story, click Auto-cast , and
the editor detects who's speaking and builds the cast + lines for you.

- parseScript(text): screenplay 'NAME: dialogue' + prose quote attribution
  ('said the fox' / 'the fox asked', straight + curly quotes); narration →
  Narrator. Pure + tested (speaker normalization, URL guard, fallbacks).
- importStory: parseSrt strips indices/timestamps → cue text; importToText
  routes .srt vs .txt. File import button (accept .txt/.srt) fills the panel.
- Auto-cast wiring: distinct speakers → cast members (round-robin voice assign
  from installed profiles), lines appended; existing cast/work preserved.
- i18n (en + zh-CN); 14 new unit tests. Full suite 139/139, typecheck/build/CJK ✓.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(stories): use substring check for SRT arrow (avoid CodeQL js/bad-tag-filter false positive)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:29:59 +05:30
Palash DebnathandClaude Opus 4.8 06560e5555 feat(stories): Pro Studio Phase 1 — real audiobook output, cast, persistence, reorder, i18n (#177)
* docs(spec): Stories Editor pro-studio design (line cards, auto-cast, pro output, projects)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(stories): Phase 1 — real audiobook output, cast, persistence, reorder, i18n

First phase of the pro-studio Stories Editor (spec:
docs/superpowers/specs/2026-05-30-stories-editor-studio-design.md). Makes the
editor actually produce audiobooks and remember your work:

- Persistence: storiesSlice (tracks + cast) via zustand persist -> localStorage;
  transient fields (generating/audioUrl) stripped on persist; id counter reseeds
  from persisted tracks. Dropped the hardcoded sample seed -> clean empty state.
- Cast: editable CastMember[] (name, color, voice) with a Cast panel; each line
  picks a character and inherits its voice (per-line override still available).
- Real Generate: exportStoryAudio() stitches every line + [pause] gaps into one
  WAV via the Web Audio API (job-less /generate per chunk) with a % progress
  indicator and download. Per-line preview already shipped (#176).
- Reorder: native HTML5 drag-and-drop (pure reorder() helper).
- i18n: all Stories strings via t('stories.*') (en + zh-CN).
- Tests: storiesSlice reducers, storyCast resolution, storyExport WAV/concat/
  silence, storyReorder. 18 new unit tests.

No DB/alembic; localStorage only. Same-origin + PIN-safe synth (apiFetch). No
new deps. Cross-platform-identical default behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:00:31 +05:30
Palash DebnathandClaude Opus 4.8 762f5bfdc5 fix(stories): preview via job-less /generate (was 404 on /api/dub/preview-segment/__stories__) (#176)
The Stories Editor preview POSTed to a relative /api/dub/preview-segment/__stories__
URL: that dub route requires a real dub job ("__stories__" -> 404) and the bare
relative path skipped the API base + PIN header entirely. Route through the
standalone /generate endpoint via the shared api client (generateSpeech), which
is same-origin and PIN-aware. Per-line and marker-chained preview now work.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:27:12 +05:30
Palash DebnathandClaude Opus 4.8 e69dcbb6b1 fix(win): subprocess spawns work under bun run dev (--reload) on Windows (#122) (#175)
issue #122 'Extract: Unknown Error' on Windows: 'bun run dev' fails, running
backend+frontend separately works. Root cause: dev:api launches uvicorn with
--reload, so use_subprocess=True, and uvicorn 0.42's asyncio_loop_factory
EXPLICITLY forces the SelectorEventLoop on Windows in that case (passed as
loop_factory to asyncio_run, overriding any policy). The SelectorEventLoop has
no subprocess support -> asyncio.create_subprocess_exec raises
NotImplementedError. 'python backend/main.py' (no reload) uses ProactorEventLoop
-> works. So an event-loop-policy fix is futile; the thread fallback is the fix.

The ffmpeg extract path already routed through _spawn_async's thread fallback
(landed in #157), but several other spawn sites used raw create_subprocess_exec
and stayed broken on the dev loop:
- add public spawn_subprocess() (drop-in for create_subprocess_exec) that routes
  through _spawn_with_retry -> _spawn_async (NotImplementedError -> thread
  fallback + EAGAIN retry); native asyncio path unchanged on supported loops.
- fix _spawn_thread_fallback to forward cwd/env/etc. to subprocess.Popen (was
  silently dropping them -- breaks sonitranslate's cwd= pip install).
- convert raw spawns: dub_generate atempo, tools ffprobe, gallery yt-dlp (x2),
  sonitranslate install (x4). translation_engines already had its own fallback.
- tests: NotImplementedError -> thread fallback; cwd forwarding; stdin input
  (atempo); native path unchanged.

No behavior change off the broken loop (macOS/Linux/Windows-prod): the native
asyncio subprocess is still used; the fallback only triggers on NotImplementedError.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:15:09 +05:30
Palash DebnathandClaude Opus 4.8 1b08c03da9 fix(docker): runtime API-base override so served deployments reach the backend (#174)
Docker users reported Settings -> Engines failing with 'Failed to load engines:
Failed to fetch'. Two problems: (1) the two API-base resolvers diverged
(client.ts honored VITE_API_URL; apiBase.ts honored VITE_OMNIVOICE_API), and
the docs documented VITE_OMNIVOICE_API -- which the Engines request path
ignored; (2) VITE_* is inlined at BUILD time, so a prebuilt ghcr.io image has
no working runtime override at all for reverse-proxy / split-origin deploys.

- backend: when OMNIVOICE_PUBLIC_API_BASE is set, inject it into index.html as
  window.__OMNIVOICE_API_BASE__ (core/spa_inject.py; validated to a plain
  http(s) URL so it can't break out of the <script>). Unset (default) ->
  StaticFiles serves index.html untouched (same-origin, zero overhead).
- frontend: both resolvers (client.ts _resolveApiBase + utils/apiBase.ts) now
  read the runtime global FIRST, then VITE_OMNIVOICE_API/VITE_API_URL, then
  fall through to same-origin. client.ts also strips trailing slashes and
  recognises __TAURI_INTERNALS__ (parity with apiBase.ts/external.ts).
- docs: docker.md + troubleshooting.md document OMNIVOICE_PUBLIC_API_BASE as the
  runtime override that works on the prebuilt image (the old VITE_OMNIVOICE_API
  docker run -e example never worked -- build-time inlining).
- tests: spa_inject helpers (inject + URL validation/breakout); resolver
  precedence for the runtime global + VITE_OMNIVOICE_API in both test files.

Default same-origin behavior is unchanged on every platform; override is opt-in.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:54:49 +05:30
Palash DebnathandClaude Opus 4.8 5a723c0408 fix(model): bound first-run model load/download so it never hangs forever (#173)
Windows users reported 'create demo voice runs indefinitely, no audio, no
error'. Root cause: the first /generate triggers OmniVoice.from_pretrained()
which downloads multi-GB weights via the legacy LFS path (HF_HUB_DISABLE_XET=1),
with NO timeout anywhere. A stalled socket (proxy/firewall/AV) blocks the GPU-
pool worker forever inside get_model() -- before the try/except that would
surface an error -- and the frontend /generate fetch had no abort, so the
spinner spun forever with no toast.

- backend/main.py: set HF_HUB_ETAG_TIMEOUT=15 + HF_HUB_DOWNLOAD_TIMEOUT=30
  (per-read timeout: resets on each chunk, so slow-but-progressing downloads
  are never punished; only a dead socket trips it). Set before hf import.
- model_manager: get_model()/preload_model() now load via _load_model_with_timeout(),
  an asyncio.wait_for backstop (OMNIVOICE_MODEL_LOAD_TIMEOUT, default 1200s) that
  drops the poisoned GPU pool and raises a clear, actionable RuntimeError so a
  retry gets a fresh worker instead of queueing behind the wedged one.
- useTTS.js: AbortController backstop on /generate so the UI never spins forever
  even if the backend is unreachable; friendly timeout toast.
- tests: watchdog raises + resets pool + releases lock; env/floor parsing.

Cross-platform (no OS-specific behavior); backward-compatible with installed
models; local-first preserved.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:50:21 +05:30
Palash DebnathandClaude Opus 4.8 40cf9bf0e5 chore: gitignore Spec Kit/GSD local tooling; track network-sharing plan (#172)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 17:47:32 +05:30
Palash DebnathandClaude Opus 4.8 0ec6357760 fix(network): remote LAN-share UI actually works (same-origin API + safe clipboard) (#171)
* fix(network): remote share UI must use same-origin API, not hardcoded :3900

When a device opens the LAN-share URL, the SPA is served by the share listener
on :5050 but client.ts hardcoded the API to :3900 — cross-origin (CORS-blocked)
AND loopback-only/unreachable from another machine, so every fetch failed. The
share listener serves the same app+API, so the remote SPA must hit its OWN
origin. _resolveApiBase: Tauri→127.0.0.1; vite-dev→:3900 (CORS-allowed); else
(share listener / docker / prod build)→window.location.origin. +6 unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ui): safe clipboard copy over plain HTTP (LAN-share remote devices)

navigator.clipboard is secure-context-only (https/localhost); on a LAN-shared
instance at http://<ip>:<port> it's undefined, so unguarded
navigator.clipboard.writeText(...) threw 'Cannot read properties of undefined
(reading writeText)' — crashing copy buttons on remote devices. Add a copyText
util (clipboard API when available, hidden-textarea + execCommand fallback
otherwise) and route all 9 call sites through it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 17:43:24 +05:30
Palash DebnathandClaude Opus 4.8 1a0798f7f1 feat(settings): dedicated Appearance tab + global font selection (#170)
* feat(settings): global font preference applied app-wide

Add a persisted, system-safe global font selection. A new `font` pref in
the prefs slice overrides the root `--font-sans` CSS variable (the whole
UI uses `font-family: var(--font-sans)`), so the choice applies app-wide;
`default` removes the override and falls back to the :root Inter stack.

- prefsSlice: FontId type, FONT_OPTIONS/FONT_STACKS tables, font + setFont
- store/index: persist `font` via partialize; re-export font tables/type
- App.jsx: re-apply persisted font on launch in the rehydrate effect
- AppearancePanel: Font row (Select) next to UI-scale and color-theme
- AppearancePanel.test: covers render, selection, and default reset

All stacks are system fonts (no web-font downloads) so behavior is
identical offline across macOS/Windows/Linux.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(settings): move Appearance into its own Settings tab

Promote the Appearance panel out of the Credentials tab into a dedicated
top-level tab (with the Palette icon), placed before Credentials. Add the
English "appearance" label so the tab renders via t(`settings.${id}`).
Remove the AppearancePanel render (and its stale comment) from
CredentialsTab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 17:23:17 +05:30
Palash DebnathandClaude Opus 4.8 3901a3cf4a fix(network): footer Local toggle dead in Tauri (window.confirm no-op) (#169)
The footer Local/Network pill called window.confirm() before enabling, but
window.confirm is a no-op in the Tauri webview (returns false), so the enable
action was silently swallowed — the button appeared to do nothing. Replace it
with a reliable in-app confirm popover (Cancel/Enable). The backend endpoint
was always working (verified: enable opens a real listener on the share port).
Adds a regression test for the Local -> confirm -> Enable -> POST flow.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 17:11:47 +05:30
Palash DebnathandClaude Opus 4.8 c993e14072 fix(diarization): use_auth_token->token shim for pyannote on HF Hub 1.x (#167) (#168)
* fix(diarization): shim use_auth_token->token for pyannote on HF Hub 1.x (#167)

pyannote-audio 3.x (pipeline.py:102) calls hf_hub_download(use_auth_token=...),
which huggingface_hub 1.x removed (only 'token' now) -> 'unexpected keyword
argument use_auth_token', breaking speaker diarization. Wrap hf_hub_download/
snapshot_download to translate the dead kwarg, applied before pyannote's
'from huggingface_hub import hf_hub_download' binds it (+ patch already-loaded
pyannote modules). Verified the real pyannote reference binds the shim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(diarization): use pytest.importorskip (fixes CodeQL uninitialized-local)

CodeQL doesn't model pytest.skip() as no-return, so it flagged _pp as a
possibly-uninitialized local (py/uninitialized-local-variable, error). Switch
to pytest.importorskip — cleaner and CodeQL-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 17:11:44 +05:30
Palash DebnathandClaude Opus 4.8 e3d7815df0 fix(scripts): Windows (Git Bash/MSYS) support across install/run/smoke-test (#164) (#166)
Follow-up to #165 — the same uname Darwin/Linux-only pattern in the other
dev scripts:
- smoke-test.sh: add MINGW*/MSYS*/CYGWIN* detection + Windows data paths
  (%APPDATA%/%LOCALAPPDATA%, per backend/core/config.py) + .exe binary suffix.
- run.sh: Windows detection + %LOCALAPPDATA% log dir; backend launch already
  uses 'uv run' which is cross-platform.
- install.sh: detect Windows and print a clear 'use the .msi / WSL' message
  (the brew/apt system-dep installer can't run on native Windows) instead of
  silently treating it as Linux.
build-omnivoice-tts.sh already handled windows-x86_64; record-reference.sh
already guards macOS-only with a clear message.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:35:25 +05:30
Palash DebnathandClaude Opus 4.8 41176f6ef1 fix(scripts): support Windows (Git Bash/MSYS) in desktop-prod.sh (#164) (#165)
uname -s returns MINGW64_NT/MSYS_NT/CYGWIN_NT under Git Bash, which hit the
catch-all 'Unsupported platform' error. Add a windows case + a Windows data-
path branch (%APPDATA%\OmniVoice backend data + %LOCALAPPDATA% Tauri/HF dirs,
per backend/core/config.py) + launch the debug .exe. The cross-platform
'tauri build' step is unchanged; every rm stays [ -d ]-guarded so an off path
is a no-op, never a wrong delete.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:03:53 +05:30
Palash DebnathandClaude Opus 4.8 c2869e73d5 feat(network): user-configurable backend / LAN-share / UI ports (#163)
* feat(ports): make backend/share/UI ports configurable via env vars

Single-source the backend port from OMNIVOICE_PORT and derive the LAN-share
base from it (OMNIVOICE_SHARE_PORT override). Previously network_share.py
hardcoded BACKEND_PORT=3900, so a user running the backend on a custom port
got LAN-share/Tailscale pointed at the wrong port.

- network_share: replace BACKEND_PORT constant with backend_port() /
  share_port_base() helpers (env-driven, never-throw fallback to defaults);
  enable() probes from share_port_base().
- tailscale: serve_enable(port=None) defaults to network_share.backend_port().
- main.py: direct-run + --health-check ports and HEALTH_URL read OMNIVOICE_PORT;
  CORS default origins use OMNIVOICE_UI_PORT (default 3901).
- /system/info + SystemInfoResponse: expose backend_port, share_port_base,
  ui_port (both success and never-throw except branches).
- set-env: persist OMNIVOICE_PORT/SHARE_PORT/UI_PORT; validate numeric and
  1024-65535, reject otherwise with 400.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ports): pin child OMNIVOICE_PORT in backend spawn

Push OMNIVOICE_PORT=backend_port() onto the spawned Python child's env so
network_share.backend_port() always agrees with the uvicorn --port Rust
passes. Without this, a user-set OMNIVOICE_PORT would move the LAN-share /
Tailscale target while the listener stayed on the Rust-resolved port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ports): Ports subsection in Sharing settings + env-driven Vite port

- vite.config.js: dev-server port reads OMNIVOICE_UI_PORT (default 3901).
- SharingPanel: new Ports subsection reads /system/info and displays
  backend_port / ui_port (with their env-var names + restart-to-apply note)
  and makes the LAN-share port editable — Save POSTs OMNIVOICE_SHARE_PORT to
  /system/set-env (persisted), "applies next time you enable sharing".
- Tests: extend SharingPanel.test.jsx for the ports subsection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:52:51 +05:30
Palash DebnathandClaude Opus 4.8 0fbc65f29d test: scrub brand name from whisper segmentation fixture (#162)
The whisper_screenshot transcription fixture + its segmentation test
referenced a real product/brand name. Swap it for the neutral placeholder
'Acme' (fixture text + chunks + the expected-segment assertions), keeping
the test's consolidation behavior identical.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:46:42 +05:30
Palash DebnathandClaude Opus 4.8 4af5d69111 fix(tailscale): HTTP serve fallback when tailnet lacks HTTPS certs + parallel dev launch (#161)
* fix(tailscale): serve over HTTP when tailnet has no HTTPS certs

Real-world failure: 'tailscale serve --https=443' on a tailnet without the
HTTPS Certificates feature (CertDomains: None) fails with 'error enabling
https feature: 404'. Detect cert availability from status --json and use
--https only when certs exist; otherwise serve over --http (the WireGuard
tunnel encrypts transport anyway). Also surface a clear note/error instead
of the raw 404, and a 'run tailscale up' hint when not running. Verified the
--http path live on a real tailnet. SharingPanel now shows the returned note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(dev): launch app in parallel with backend (drop wait:api gate)

dev/desktop no longer block the Tauri/vite launch on the API being HTTP-ready
— the window appears immediately and the frontend's setup-status check
already retries (30x1s) until the API answers. Matches prod, where the window
shows BootstrapSplash while the sidecar boots. Dev-only; no shipped change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:25:38 +05:30
Palash DebnathandClaude Opus 4.8 f7cfd33994 polish(network): outermost PIN gate + non-buffering ASGI middleware + listener test (#160)
* fix(network-share): mount RemoteAuthGate at outermost provider

Move the <RemoteAuthGate> wrap from App.jsx's main-studio return up to
main-app.jsx, inside QueryClientProvider and wrapping the entire app tree
(both the dictation widget and <App />). Previously the gate only wrapped
the studio return, so a remote device opening a bare URL (no ?pin=) during
first-run states — the /setup/status check, SetupWizard, or BootstrapSplash
early returns — would 401 with no gate rendered to collect the PIN. The QR
path was fine (PIN captured pre-fetch in client.ts); only bare-URL was broken.

Remove the App.jsx wrap to avoid double-gating (two PIN dialogs). No behavior
change for loopback or QR users.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(network-share): make NetworkAccessMiddleware non-buffering ASGI

Rewrite NetworkAccessMiddleware from a starlette BaseHTTPMiddleware into a
pure ASGI middleware (class with __init__(app) and __call__(scope, receive,
send)). BaseHTTPMiddleware buffers StreamingResponse/SSE bodies before
forwarding them, so PIN'd LAN clients on streaming endpoints (dictation SSE,
tts streaming, /system/logs/stream) got buffered/laggy responses. Loopback was
unaffected (bypasses early), but remote-share streaming was degraded.

The ASGI form forwards send untouched on every pass-through path, and only
wraps send to inject Set-Cookie on the http.response.start message for the
first valid-PIN request — the body keeps streaming chunk-by-chunk. request.app
resolves in ASGI scope (Starlette sets scope["app"]), so the inert/loopback/
shell/PIN logic is identical to before. Registered after CORS (unchanged) so
CORS stays outermost.

All 5 existing behavior tests pass unchanged. Adds three tests: a guard that
the middleware is not a BaseHTTPMiddleware subclass, a StreamingResponse
pass-through (401 without PIN, full chunked stream with PIN, no buffered
Content-Length), and a Set-Cookie-via-ASGI assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(network-share): integration test for real listener lifecycle

Add tests/test_network_share_lifecycle.py exercising the real second uvicorn
listener: await network_share.enable(app) on a minimal FastAPI app, assert
get_state().enabled is True with a share_port set and a live TCP listener on
that port (real socket connect), then await disable(app) and assert the state
resets and the port stops accepting connections.

Uses the returned share_port (never a hardcoded port) and tolerates teardown
timing by polling for socket close. Wrapped in asyncio.run inside a sync test
so it does not depend on a pytest-asyncio event-loop mode; skips gracefully if
binding 0.0.0.0 is not permitted in the sandbox. Defensive cleanup resets the
module-level state on any failure path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:45:54 +05:30
Palash DebnathandClaude Opus 4.8 fa1503c4eb feat: network sharing (PIN-gated LAN + QR) & Tailscale remote access (#125) (#159)
* docs(spec): network sharing + Tailscale remote access design

Same-state LAN sharing via a second in-process uvicorn listener on a
dedicated share port (no restart, model/jobs preserved), PIN-gated for
non-loopback clients, with QR + all-LAN-addresses panel. Tailscale serve
for private remote access. Supersedes the raw 0.0.0.0 default-flip in #125.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(spec): control endpoints reuse existing require_loopback gate

Security review of #157 confirmed the /system router is already loopback-gated
via Depends(require_loopback) (non-spoofable request.client.host). The network
control endpoints inherit it and /system/set-env is auto-protected from the
LAN listener — no new guard needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(network): share-listener module — LAN enumeration + PIN + lifecycle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(network): loopback-only control endpoints + /system/info sharing fields

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cjk): scan git-tracked files only, not untracked vendored dirs

The no-hardcoded-CJK guard walked the filesystem, so local untracked
vendored experiments (research/voice-pro etc. with JP issue templates)
caused false local failures while CI (committed files) passed. Scan via
git ls-files so local-only and CI behavior match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(network): PIN middleware — gate non-loopback API access when sharing on

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(network): inject X-OmniVoice-Pin globally + capture ?pin= from QR URL

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(network): remote PIN gate on 401

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(network): add qrcode dep for share QR

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(network): footer Local/Network toggle with LAN addresses, QR, copy/open

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(tailscale): CLI status + serve enable/disable + endpoints

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(network): Settings → Sharing & Remote Access panel (LAN + Tailscale)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(network): sharing & remote access guide (LAN PIN/QR + Tailscale)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(network): enable() tears down and raises if the share listener never binds

Defensive guard (spec §7): if the second uvicorn server doesn't reach
'started' (e.g. the share port was taken in the race after the free-port
probe), cancel the task, reset state, and raise — so the API surfaces the
failure and the UI stays Local rather than reporting a dead 'Network' state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(network): use globalThis (not Node global) in client.test.ts for tsc

CI runs 'tsc --noEmit --checkJs false', which type-checks .ts files; Node's
'global' isn't typed there (TS2304). vitest (esbuild) tolerated it locally.
Use globalThis (standard, typed) + cast the mock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(network): apiFetch leaves opts untouched when no PIN set

The unconditional headers merge changed the request shape for callers with
no headers (e.g. FormData posts), breaking the legacy 'apiPost passes
FormData without Content-Type override' node test. Only spread opts +
inject X-OmniVoice-Pin when a PIN is actually present; otherwise pass opts
through unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:26:16 +05:30
Palash DebnathandClaude Opus 4.8 b4f238aeb1 fix(bootstrap): scrub PYTHONHOME/PYTHONPATH before uv so AppImage venv build succeeds (#144, #127) (#158)
On the Linux AppImage, the bundled runtime exports PYTHONHOME / PYTHONPATH
(and sometimes LD_LIBRARY_PATH) pointing at the AppImage's *own* bundled
Python. When first-run bootstrap shells out to `uv` to create/sync the venv,
that build subprocess inherits those vars, so the freshly-built managed
interpreter resolves its stdlib against the wrong (AppImage) Python and dies
with `ModuleNotFoundError: No module named 'encodings'` while compiling a
transitive dep (dora-search/demucs). This surfaces downstream as
"Backend process exited (never started) — no error output captured" (#144).

The backend spawn in backend.rs already scrubs these vars before launching
uvicorn; the uv/venv/pip subprocesses in bootstrap.rs were scrubbing them too
but via five inline copies, which is drift-prone. Factor the scrub into a
single `scrub_python_env(cmd)` helper documenting the #144 root cause, and
apply it at every uv/venv/pip call site (uvicorn import check, repair sync,
venv create, sync, ROCm reinstall). Add a unit test asserting the helper
queues removals for all three vars.

Safe cross-platform: those vars are normally unset on macOS/Windows and
`env_remove` on an unset var is a no-op, so default behavior is unchanged
everywhere. Compile-validated (cargo check + cargo test pass); no AppImage in
CI to reproduce the original failure end-to-end.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:36:53 +05:30
8fa54ccae4 l10n(zh-CN): full Chinese localization + Windows/settings fixes (absorbed from #66) (#157)
* @
fix: skip torch.compile on Windows where Triton is unavailable

torch.compile with mode="reduce-overhead" depends on Triton, which has no
official Windows support. On Windows the compile call succeeds but generates
code paths that crash at inference time with an OOM-like error
("Cannot find a working triton installation").

Check for Triton availability before compiling so TTS gracefully falls back
to eager mode on platforms without Triton.

Closes #65
SummerSec
@

* feat: comprehensive Chinese (zh-CN) localization for Settings and navigation

Add full Chinese (zh-CN) translation support across the frontend:
- NavRail, Launchpad, Clone/Design tabs, Settings (all tabs)
- Sidebar navigation labels, hero text, action cards, section headings
- Fix: Settings missing General tab in TABS array
- Fix: i18n locale not persisted after page reload (useEffect deps)
- Fix: NavRail key prop spreading into JSX elements

Co-authored-by: SummerSec

* fix: translate production override parameter labels (Speed, t_shift, etc.)

* fix: translate voice design category labels (Gender, Age, Pitch, etc.)

* feat: translate Transcriptions and Voice Gallery pages

* fix: translate gallery category names (Disney, Anime, etc.)

* feat: translate DubTab, personality presets, and voice design presets

* fix: remove duplicated emoji in personality name translations

* fix: correct preset translation keys to match actual preset IDs

* fix: filter natural language from personality instruct to prevent validation error

* fix: handle edge case where instruct has no valid tags

* chore: remove debug logging from personality instruct filter

* fix: address CodeRabbit review — importlib.util, English comment, grammar, theme, placeholder

* fix: localize selected category label in VoiceGallery header

* feat: translate remaining DubTab UI text (CAST, Generate Dub, Translate All, etc.)

* fix: improve ffmpeg detection on Windows, error messages, and yt-dlp download timeout

* feat: add proxy setting in Settings → General for downloading via proxy

* fix: improve ffmpeg detection on Windows, error messages, and yt-dlp download timeout

* feat: allow HTTP_PROXY/HTTPS_PROXY env vars via /system/set-env

* fix: support SOCKS5 proxy, also set ALL_PROXY env var

* fix: increase yt-dlp extractor retries for subtitle 429 errors

* feat: translate prep overlay stage labels (download, extract, demucs, scene)

* feat: translate BatchQueue, VoiceProfile, ToolsPage, Projects pages

* feat: translate SetupWizard, DonatePage, EnterprisePage + fix NotImplementedError handling

* feat: add ffmpeg status + manual path setting in Settings → General

* fix: validate ffmpeg path exists when user sets it manually

* fix: fall back to thread-based subprocess when asyncio raises NotImplementedError on Windows

* fix: pin setuptools<70 — ctranslate2 requires pkg_resources removed in 70+

* fix: translate transcribing overlay text

* fix: complete DubTab zh-CN localization + argostranslate preflight check

* fix: add cmn-Hans language code mapping for Google Translate

* fix: fall back to thread-based pip install on Windows when asyncio subprocess raises NotImplementedError

* feat: add DeepL/Microsoft/LLM credential fields to Settings

* feat(i18n): localize GlossaryPanel, DubSegmentTable, DubSegmentRow

* fix: Windows-safe log rotation handler avoids PermissionError on rename

* feat: persist proxy/FFmpeg/LLM/translation credentials, separate DeepL/Microsoft keys, add glossary max-height scroll and collapse

- /system/set-env writes env.* to prefs.json via prefs.set_()/delete()
- Backend startup reads env.* from prefs.json into os.environ (.setdefault)
- PERSISTENT_KEYS covers proxy, FFmpeg, LLM, DeepL/Microsoft keys
- DeepL uses DEEPL_API_KEY, Microsoft uses MICROSOFT_API_KEY (fallback TRANSLATE_API_KEY)
- DeepL/Microsoft _build_translator reads DEEPL_BASE_URL/MICROSOFT_BASE_URL
- Google/MyMemory/Microsoft translators bypass Windows registry proxy
- Frontend CREDENTIAL_GROUPS splits into 4 groups with password/text fields
- Glossary panel body max-height: 35vh + overflow-y: auto
- Glossary panel can be collapsed via ChevronDown button
- queryClient.invalidateQueries after save for immediate refresh
- SystemInfoResponse adds proxy_url, ffmpeg_ok, ffmpeg_path

* feat(i18n): localize ExportModal with zh-CN support

- Add useTranslation + replace ~50 hardcoded strings with t() calls
- Add exportModal namespace to en.json and zh-CN.json
- Cover presets, tracks, tabs, video/audio/subs/package tabs, license notice, and output footer

* fix: address PR #66 security review feedback

- Regenerate uv.lock against pypi.org (remove TUNA mirror URLs)
- Route HF_TOKEN through huggingface_hub.login() instead of prefs.json
- Add os.chmod(prefs_path, 0o600) for restricted file permissions
- Add warning logs to _WindowsSafeRotatingFileHandler bare except blocks

* docs: add Chinese translation README_CN.md

* docs: add link to Simplified Chinese translation in README.md

* docs: add English/Simplified Chinese cross-links between READMEs

* fix(l10n): restore clickable Discord/email footer links in EnterprisePage

The i18n extraction replaced main's clickable <button onClick=openExternal>
footer links with bare {t()} labels, dropping both the clickable behavior
and the literal Discord URL — which broke test_discord_link_updated
(EnterprisePage missing discord.gg/bzQavDfVV9). Restore both as clickable
links wrapping the translated label, with the hardcoded URL/mailto (URLs are
not translated). Keeps i18n, restores functional parity with main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(l10n): no-hardcoded-CJK rule + enforce; clean dead LLM block; harden set-env

- Add 'Localization (hard rule)' to CLAUDE.md: no hardcoded non-English UI
  text outside frontend/src/i18n/; functional CJK allowed via allowlist.
- New tests/test_no_hardcoded_cjk.py enforces it (allowlists text-processing
  regexes, model/engine vocab & IDs, error matching, demo/eval data, fixtures).
- Settings.jsx: remove dead saveLlm block (Chinese toasts + unused llm* state,
  flagged by CodeQL js/unused-local-variable); render language-picker native
  names from new LANGUAGES export in i18n/index.ts instead of hardcoding.
- main.py: drop unused 'import shutil' (CodeQL py/unused-import).
- system.py: harden FFMPEG_PATH/FFPROBE_PATH set-env (reject control chars;
  defense-in-depth for the py/path-injection finding). Endpoint stays
  loopback-only — network sharing must never expose /system/set-env.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: drop unused ui import (Panel) + fix implicit str-concat in cjk test

Clears the two CodeQL notes introduced/attributed to this PR:
- Settings.jsx: remove unused 'Panel' from the '../ui' import (js/unused-local-variable).
- test_no_hardcoded_cjk.py: collapse multi-line message strings to single lines
  (py/implicit-string-concatenation-in-list).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: SummerSec <summersec@qq.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 09:48:21 +05:30
028d7b0151 fix(windows): port-conflict kill + Triton/torch.compile disable (salvaged from #85) (#156)
* fix(windows): port-conflict kill + Triton/torch.compile disable (salvaged from #85)

Salvages the two safe, valuable Windows fixes from community PR #85
without the changes that would regress all users.

backend.rs — implement the Windows branch of `kill_orphan_on_port`,
which was a no-op (`pub fn kill_orphan_on_port(_port: u16) {}`). It now
parses `netstat -ano -p TCP` for the LISTENING socket on exactly `port`
(suffix-matched on ":PORT" to avoid e.g. :3900 matching 39000) and kills
the owning PID via `taskkill /PID <pid> /F`. Behind `#[cfg(not(unix))]`;
the unix branch is untouched. Signature now matches the unix branch.

main.py — on Windows (sys.platform == "win32"), default
TORCH_COMPILE_DISABLE / TORCHDYNAMO_DISABLE / TORCHINDUCTOR_DISABLE to
"1" before torch is imported. Triton has no Windows wheel, so these
prevent TritonMissing/dynamo errors. Uses os.environ.setdefault (never
overrides an explicit user value) and is win32-guarded, so cross-platform
default behavior is unchanged.

Intentionally NOT salvaged from #85:
- The unconditional `os.environ["HF_HUB_OFFLINE"] = "1"` in main.py and
  the `local_files_only=True` / HF_HUB_OFFLINE save-restore in
  model_manager.py. This breaks first-run model downloads for every user
  (downloading models on first use is the core value prop). Offline mode
  must stay opt-in — only when the user sets HF_HUB_OFFLINE themselves.
- The model_manager.py torch.compile/_get_gpu_pool changes: already
  present on main in a superior form (`should_torch_compile()` gating
  from plan-02/#65 and the existing `_get_gpu_pool()` lazy pool), so
  applying #85's cruder `TORCH_COMPILE_DISABLE` env check would regress.
- The 512-line README rewrite, the ~50 frontend .jsx formatting-only
  diffs, and the personalities.py attrs additions (out of scope).

Refs #85.

Co-Authored-By: caaaaaleb <caaaaaleb@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: move win32 torch-disable block below sys.path preamble

test_main_py_bootstrap_adds_backend_dir asserts the first 15 lines of
main.py contain sys.path.insert + _backend_dir. The win32 block was placed
above the preamble, pushing them out of range. The torch env vars only need
to precede torch's (lazy) import, so moving the block below the sys.path
bootstrap keeps behavior identical and restores the test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: caaaaaleb <caaaaaleb@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 08:35:40 +05:30
Palash DebnathandClaude Opus 4.8 f757b77a59 docs(install): clarify macOS Gatekeeper "damaged" workaround (#134) (#155)
Reword the macOS install doc's Gatekeeper section so it's findable by the
exact symptom ("App is 'damaged' / can't be opened"), and spell out both the
GUI path (right-click -> Open, or System Settings -> Privacy & Security ->
Open Anyway) and the Terminal path (xattr -dr com.apple.quarantine ...).

Explains WHY macOS shows "damaged" (the build isn't notarised yet, so it gets
quarantined) and why the workaround is safe (downloaded from the official
repo/Releases). Proper fix remains Apple Developer signing + notarisation,
already wired in release.yml behind the documented secrets.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 08:12:35 +05:30
Palash DebnathandClaude Opus 4.8 54004fcced feat(bootstrap): opt-in AMD ROCm torch install (#124) (#154)
Detection already routes ROCm through torch.cuda (get_best_device +
_configure_rocm_if_needed), but the default install ships the CUDA torch
build, so AMD-only machines fall back to CPU.

Add an opt-in post-sync step: when OMNIVOICE_TORCH_VARIANT=rocm is set, the
bootstrap reinstalls torch/torchaudio from the ROCm wheel index
(default https://download.pytorch.org/whl/rocm6.2, overridable via
OMNIVOICE_TORCH_INDEX). Strictly gated — default (unset) leaves the
CUDA/CPU path untouched — and non-fatal: a failed ROCm reinstall keeps the
working default build and points the user at docs/install/linux.md.

rocm_opt_in() + rocm_torch_reinstall_args() are pure and unit-tested
(gating + index override + arg shape). cargo test green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 07:43:16 +05:30
Palash DebnathandClaude Opus 4.8 034d1c4811 fix(onboarding): hide DictationDemo when sample assets are absent (#119 follow-up) (#153)
DubbingDemo and DemoPresetGrid already degrade gracefully (hide) when their
assets / is_demo profiles are missing, but DictationDemo always rendered its
three hardcoded cards — which fail on click without the bundled sample WAVs
(rendered by scripts/build_demos.sh; absent in a plain source checkout).

Add a mount-time HEAD probe of the first sample; if it's not present, hide
the whole demo (mirrors DubbingDemo's missing-manifest behavior). When assets
are present, behavior is unchanged.

Test: HEAD 404 → demo renders nothing.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 23:14:14 +05:30
Palash DebnathandClaude Opus 4.8 993e6cf6a5 fix(dub): async-ify _pitch_preserving_stretch (#133 Greptile P1) (#152)
_pitch_preserving_stretch ran a blocking subprocess.run() inside the
`_stream` async generator (on the event loop). Each ffmpeg atempo call is
~50-100 ms, so on a multi-segment time_stretch dub job it froze health
checks, status SSE, and every other concurrent request for seconds.

Convert to asyncio.create_subprocess_exec + await communicate() (same
pattern as run_proc_streaming_stderr); await the call site in _stream.
Drop the now-unused `import subprocess`.

Tests: async coroutine + target-length + no-op cases (real ffmpeg).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 23:13:32 +05:30
Palash DebnathandClaude Opus 4.8 79473c4c01 docs(#124): document AMD GPU (ROCm) install path (#151)
Detection already works (get_best_device + HSA_OVERRIDE_GFX_VERSION); the
gap was that the default install ships CUDA torch, so AMD users fell back
to CPU with no guidance. Document the opt-in ROCm wheel swap (rocm6.2),
the device-verify one-liner, and the HSA override for unsupported GFX.

Linux-only, opt-in — default cross-platform behavior unchanged. An
installer-integrated env-var-driven wheel selection is a tracked follow-up.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:20:23 +05:30
Palash DebnathandClaude Opus 4.8 87eb5ad078 feat(dub): audio-only dubbing mode (#119) (#150)
* feat(dub): audio-only dubbing mode (#119)

Add an audio→audio dubbing path: upload an audio file, get dubbed audio
out, with no video processing. The transcribe → translate → TTS core is
unchanged; only the video-coupled stages are skipped.

Backend:
- dub_core /dub/upload: new `input_type` form field ("video"|"audio").
  Audio mode validates the upload is a known audio container (else 400)
  and threads input_type into the ingest source dict.
- dub_pipeline ingest: for audio input, skip scene detection + thumbnail
  ffmpeg passes (still emits scene_done count=0 so the prep SSE contract
  the frontend waits on is unchanged); stores input_type on the job.
- dub_export /dub/download: for audio jobs, branch to an audio-only export
  (_build_audio_export_cmd) — no video input/map/codec/subtitle pass.
  Outputs dubbed_audio_{lang}_{stamp}.{wav|m4a|mp3|flac} via `out_format`
  (default m4a), optionally mixed with the separated background. Unknown
  formats fall back to AAC.

Frontend:
- dubSlice: dubInputType state + setter (default 'video').
- DubTab: auto-select audio-only mode when an audio file is dropped/picked.
- dub.ts/useDubWorkflow: pass input_type on upload.

Tests (11): _build_audio_export_cmd format/mix matrix; end-to-end audio-only
export produces an audio file (no video mux); unknown-format fallback;
upload rejects a video extension in audio mode.

Closes #119.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* harden(#119): allowlist-sanitize lang_code in audio export path

The track id is already constrained to an existing track key, but
allowlist-sanitize it before it reaches the output path (same pattern as
the existing safe_name) so a path component can never carry separators —
clears the CodeQL path-injection flag on the new audio-export branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* polish(#119): address Greptile P2s on audio-only dubbing

- dub_pipeline: emit scene_start before scene_done(count=0) for audio so
  the prep SSE stage sequence is symmetric with the video path.
- useDubWorkflow: 'Preparing audio…' pill for audio jobs (was always
  'Preparing video…').
- DubTab: widen the drop-accept regex + file-input accept to the full
  supported audio set (aac/opus/wma) so it matches the input-type
  detection and the backend allowlist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#119): drop unused dubInputType read in DubTab (CodeQL)

Only setDubInputType is used; the value read was dead. Clears the
CodeQL unused-variable alert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:07:38 +05:30
Palash DebnathandClaude Opus 4.8 8b00dc1f4f feat: onboarding demos, opt-in bug reporting, error-docs deeplinks + issue triage (#133)
* feat: onboarding demos, opt-in bug reporting, error-docs deeplinks + issue triage

Working-tree snapshot bundling several in-flight workstreams (v0.3.0):

- Onboarding/demo system: DemoPresetGrid, DictationDemo, DubbingDemo components
  + tests, render scripts (render_demos_omnivoice.py, build_demos.sh,
  build_dub_demo.sh), personalities preview URLs, alembic 0002 voice-profile
  demo fields.
- Opt-in bug reporting: ReportBugButton (prefilled GitHub-issue URL path).
- Error transparency UX: errorDocsMap deeplinks + BootstrapSplash/error wiring.
- Dub workspace: DubSegmentRow/Table, WaveformTimeline, dubSlice tweaks.
- Issue triage: .planning/issue-clusters/ (plan-01..05 root-cause masters,
  GH #128-#132).
- CLAUDE.md: hard rule — everything ships on v0.3.0, no version bumps.

KNOWN GAP (why this is a draft): the generated demo audio assets are NOT in
this tree, and backend/assets/samples/demo_voice.wav is deleted. onboarding.py
guards the missing file (skips seeding the demo profile with a warning), so no
crash — but first-run Launchpad will be empty and /demo_audio/ preview URLs
404 until assets are regenerated via scripts/build_demos.sh. Do not merge
before regenerating + committing the demo assets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(dub): timing strategies — kill audio compression, add Concise + Stretch Video

Replaces the current audio time-compression default (atempo squeeze to fit
slot) that produced chipmunk/alien output on high-density target languages
like Bengali. Two new user-selectable modes; legacy behaviour kept behind
an explicit "Strict slot" choice.

New `DubRequest.timing_strategy` enum (default "concise"):
  - "concise"        Translator trims text to fit at natural rate; if it
                     still overflows, hard-trim at slot with a fade so we
                     never overlap the next speaker. Surface overflow_s
                     per segment so the user can shorten the text.
  - "stretch_video"  Audio plays at natural 1.0× rate. Backend computes a
                     per-segment new timeline; persists a video_stretch_plan
                     on the job. Mux step (dub_export) builds an ffmpeg
                     trim+setpts+concat filter graph that stretches each
                     segment's video portion to match the natural-rate dub
                     audio. Gaps/pre-roll/tail pass through at 1.0×.
                     Sub burn under stretch_video is skipped in one pass
                     (cues would drift).
  - "strict_slot"    Legacy atempo squeeze. Retained for back-compat.

Director rate-bias side-effect (seg_speed *= bias) now gated on strict_slot
only, so "urgent"/"slow" direction tokens keep their instruct effect in
the new modes without chipmunking.

Per-segment fit_status emitted in the SSE done event:
  {status: "fits" | "overflows" | "video_stretched", overflow_s?, stretch_ratio?}
DubSegmentRow's "Sync: 100%" badge (which was lying — sync_ratio was always
~1.0 because the TTS loop pre-trimmed to slot) is replaced with a truthful
"Fits / Overflows +Ns / Video 1.18×" label.

Frontend:
  - prefsSlice.timingStrategy (persisted, store v3→v4 with safe migrate).
  - DubTab footer Segmented control: "Concise · Stretch Video · Strict slot".
  - useDubWorkflow passes timing_strategy on /dub/generate; consumes fit_status.

Tests: tests/test_dub_timing_strategy.py — 13 cases covering schema
defaults/validation, _build_video_stretch_filter_graph (pre-roll, gap,
tail, empty-plan early return, post-subtitle chain-in), and
_video_stretch_plan_for guards. 30/30 existing dub tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(waveform): surface missing source as "Source media missing" instead of code-4 black box

When a project's underlying media file is gone (moved or deleted between
save and reload) the <video> element fires MediaError code 4 and the
companion audio fetch returns HTTP 404 — both were silently warned to
the console while the user stared at an unresponsive black panel and an
empty waveform.

- WaveformTimeline now flips loadError when the video element rejects
  code 3 (decode) or 4 (src not supported), and tracks `sourceMissing`
  separately so the error UI can name the actual problem.
- The audio decode fallback chain catches HTTP 404 specifically and
  treats it as source-missing instead of loading silent empty peaks —
  an empty waveform on a deleted source is more confusing than a clear
  "Re-upload the video to continue" message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tray): "Show OmniVoice" reloads when the webview is blank

When the dev Vite server restarts (or the main window is created before
the backend is ready), the webview load fails and the window is left
with `<body></body>` plus a "Could not connect to the server" console
error. Clicking "Show OmniVoice" from the tray menu just re-showed the
broken window — there was no recovery path short of quit+relaunch.

Now the show handler runs a tiny eval after `show()`/`set_focus()` that
calls `location.reload()` only when `document.body.childElementCount === 0`.
A healthy window doesn't blink (body is non-empty); a blank one
self-recovers as soon as the user clicks Show.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#133): bug-report diagnostics field mapping + drop unused imports

Address PR #133 review:
- ReportBugButton: /system/info exposes `platform` + `device`, not
  `os`/`torch_device`/`gpu` — those reads silently dropped OS/GPU from every
  bug report. Map to the real fields (CodeRabbit). Also remove the dead
  `home` local in stripHome (CodeQL unused-variable).
- DictationDemo: drop unused `Loader` import (CodeQL unused-import).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 17:24:25 +05:30
Palash DebnathandClaude Opus 4.8 1cfda2f44e feat(settings): configurable models directory (#64) (#149)
* feat(settings): configurable models directory (#64)

Let users pick where model weights download (the HuggingFace / Torch
cache) instead of being pinned to ~/.cache/huggingface — useful when the
system drive is small or slow.

Backend:
- core/user_env.py: durable per-user env file (~/.config/omnivoice/env)
  helper with upsert/unset that preserves other keys and writes 0600.
  main.py already loads this at startup before importing torch/HF, so the
  value takes effect on the next launch. Path resolves at call time via an
  OMNIVOICE_ENV_FILE override so it's robust to module re-import in tests.
- settings.py: GET/PUT /api/settings/storage/models-dir — validates the
  dir is writable (mkdir + write-probe → 400 if not), persists the choice,
  and writes OMNIVOICE_CACHE_DIR to the durable env. Empty path clears →
  reverts to default. Returns restart_required since an in-use cache can't
  be safely moved mid-process. Loopback-gated like the other settings.

Frontend:
- StoragePanel: Models tab panel to view/set/reset the directory, shows
  effective vs configured vs default + a restart note.

Cross-platform default parity preserved (default cache path is the HF
default on every OS); local-first (no network); backward-compatible
(absent setting → existing behavior). No version bump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#64): harden models-dir input + clear CodeQL hygiene flags

- settings.py: reject control/NUL chars in the path with a 400 before any
  filesystem call (an embedded NUL otherwise raised ValueError → 500). Also
  serves as the explicit input-validation barrier for the user-chosen path
  (loopback-gated same-user local file picker — no cross-privilege boundary).
- test_user_env.py: use `with open(...)` so the file is closed and the assert
  has no side effects.
- user_env.py: comment the best-effort chmod except clause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(#64): single source of truth for models dir + review fixes

Address CodeRabbit + Greptile review on PR #149:

- P1 (both bots): the settings_store copy of the models dir was only ever
  read by this GET endpoint, so it was a redundant cache that could diverge
  from the durable env file (the value main.py actually reads). Drop it —
  the per-user env file (OMNIVOICE_CACHE_DIR) is now the single source of
  truth: PUT writes it, GET reads it back. No divergence possible.
- XDG-aware default (CodeRabbit): _default_models_dir now honors
  XDG_CACHE_HOME, matching huggingface_hub's real default on Linux.
- Atomic 0600 write (Greptile, security): user_env writes via an os.open
  opener that creates the file 0600 from the start — no world-readable
  window before chmod for a file that can hold HF_TOKEN.
- _read_lines only swallows FileNotFoundError; other OSErrors propagate so
  an upsert can't silently drop existing keys on a transient read failure.
- Guard makedirs("") when the env path is a bare filename (no parent).
- Best-effort write-probe cleanup in a finally; raise ... from e.
- a11y: label the models-dir input via aria-labelledby/aria-describedby.
- OS-neutral unwritable-dir test (mock makedirs) instead of Unix-only
  /dev/null path semantics.

12 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 16:52:50 +05:30
Palash DebnathandClaude Opus 4.8 15958d3860 fix(bootstrap): surface why the backend "never started" (refs #144, #127) (#148)
* fix(bootstrap): surface why the backend "never started" (#144, #127)

AppImage users hit "Backend process exited (never started) — no error output
captured" with nothing to act on. Root gap: when `Command::spawn()` of the
venv Python fails (the common Linux/AppImage case — interpreter can't exec,
missing system lib, stale venv), spawn_backend logged the OS error but returned
None silently, so the bootstrap reported "no error output captured".

Now the spawn failure writes a diagnostic (the interpreter path, whether it
exists on disk, the OS error, and an actionable "Clean & Retry / run from a
terminal" hint) to backend_err.log, which the bootstrap's read_error_log_tail
already surfaces. The "no output" dead-end becomes the real launch error.

This makes #144/#127 diagnosable (the underlying AppImage cause then routes from
the now-visible error). Pure message builder is unit-tested; cargo test +
cargo check clean.

Refs #144, #127.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bootstrap): platform-specific spawn-failure hint (Greptile #148)

The diagnostic tail said "run the AppImage from a terminal… dynamic-loader
error" — meaningless on macOS/Windows (spawn can fail on any OS). Pick the hint
by build-target OS via cfg!: AppImage/loader wording on Linux, venv/quarantine
on macOS, missing-Python/AV-block on Windows. "Clean & Retry" stays universal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:25:28 +05:30
Palash DebnathandClaude Opus 4.8 ee671300be ci: gate omnivoice-tts build to pin changes; drop hanging Intel-Mac leg (#147)
* ci: gate omnivoice-tts build to pin changes; drop hanging Intel-Mac leg

The omnivoice-tts C++ runtime is pinned to a commit SHA in quant_map.json, so
it only needs rebuilding when that pin (or the build script) changes — not on
every PR/push. Running it per-push left the heavily-contended hosted macOS
runners (esp. Intel macos-13) sitting in "Waiting for a runner…" for hours as
a perpetual queued check (the UNSTABLE state on every PR).

- Moved the build out of ci.yml into its own workflow,
  .github/workflows/build-omnivoice-tts.yml, gated to:
  paths [quant_map.json, scripts/build-omnivoice-tts.sh, the workflow] +
  workflow_dispatch. Normal PRs no longer trigger (or hang on) it.
- Dropped the Intel darwin-x86_64 (macos-13) matrix leg: that hosted pool is
  unusably contended and Apple's momentum is on arm64; Intel-Mac users get the
  in-process OmniVoiceBackend fallback (already the documented behavior).
  Kept linux-x86_64, windows-x86_64, darwin-arm64. Re-add macos-13 here if
  first-class Intel binaries are ever needed.

Both workflows YAML-validated. Matches ci.yml's stated philosophy of keeping
heavy platform builds off the per-PR path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: timeout-minutes + injection-harden the omnivoice-tts build (bot review)

- Greptile: add `timeout-minutes: 45` so a hung leg (esp. experimental
  darwin-arm64 Metal) can't run to GitHub's 6h ceiling — same resource-drain
  class this PR addresses.
- CodeRabbit: stop interpolating the pinned SHA / platform directly into the
  run block. Validate the SHA is a git hash in the pin step, then pass it +
  platform via quoted env vars (no shell-injection surface from quant_map.json).

Declined: SHA-pinning actions@v4 — matches the repo's floating-tag convention
(ci.yml/release.yml); belongs in a repo-wide hardening pass + Dependabot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:07:38 +05:30
Palash DebnathandClaude Opus 4.8 adf486ee03 chore: set version to 0.3.0 across all sources (+ drop v0.4 references) (#145)
* chore: drop stray v0.4 references — everything ships on the v0.3.0 line

Per the project's versioning rule (no v0.4, no unprompted version chatter):

- backend/main.py + marketplace.py: the app reported version "0.4.0" (ahead of
  even pyproject's 0.2.7 and referencing a forbidden version). Aligned to
  "0.2.7" to match pyproject.toml / tauri.conf.json — a consistency fix, not a
  bump.
- errorDocsMap.ts / indextts/bootstrap.py / _secret_key.py: reworded "v0.4"
  deferral comments to version-agnostic "deferred / later hardening pass".
- docs/install/troubleshooting.md: the "tracked for v0.4" notarization line now
  matches macos.md (signing is wired; activates on the Apple cert secrets).

Note: historical planning records under .planning/ still contain "defer to v0.4"
notes; left as-is (a record of superseded decisions) — CLAUDE.md + the
constitution are the live source of truth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: set version to 0.3.0 across all sources (current dev line)

The current/upcoming version is v0.3.0 (0.2.7 is the prior stable). Bump every
version source so the codebase consistently reports 0.3.0 — the in-code dev
version; the git *tag* still happens later per the release cadence.

- pyproject.toml, frontend/src-tauri/Cargo.toml, tauri.conf.json,
  frontend/package.json: 0.2.7 → 0.3.0
- backend/main.py (FastAPI) + marketplace.py export metadata → 0.3.0
  (these had drifted to a phantom "0.4.0")
- CHANGELOG.md: "[0.2.7] — Unreleased" → "[0.3.0] — Unreleased"
- uv.lock + Cargo.lock reconciled (1-line each) so `--frozen` installs hold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(version): read app version from package metadata (no more drift)

Greptile (#145): the FastAPI version + marketplace bundle metadata were bare
string literals — they'd go stale-wrong again at the next bump (the exact class
of bug this PR fixes; that's how "0.4.0" happened). Read once from
importlib.metadata.version("omnivoice") via core.version.APP_VERSION, with a
"0.3.0" fallback only for a non-installed source checkout. pyproject.toml is now
the single source of truth for the runtime version.

Tests: tests/test_app_version.py (semver + equals installed metadata). 2 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:08:56 +05:30
Palash DebnathandClaude Opus 4.8 50954f7f43 feat(macos): wire Developer-ID signing + notarization; fix "app is damaged" docs (#134, #72) (#143)
The unsigned DMG triggers macOS Gatekeeper's misleading "app is damaged" block
(#134, #72). Two parts:

- release.yml: pass APPLE_CERTIFICATE / _PASSWORD / APPLE_SIGNING_IDENTITY /
  APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID to tauri-action. It signs +
  notarizes the macOS bundle when these repo secrets are set, and is a no-op
  (today's unsigned build) when they're absent — so this is safe to merge now
  and "activates" the moment the maintainer adds an Apple Developer cert.
- docs/install/macos.md: explain the "damaged" message is Gatekeeper (not
  corruption), give the `xattr -cr` + right-click→Open workarounds, and add a
  "For maintainers" table of the required secrets. Removed the stale "tracked
  for v0.4" line (versioning rule: everything's on v0.3.0).

The in-app error→docs deeplink (GATEKEEPER_QUARANTINE) already targets the
#gatekeeper-quarantine anchor.

Refs #134, #72.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:40:27 +05:30
Palash DebnathandClaude Opus 4.8 285e3d8d6e fix(bootstrap): always try only-system fallback; drop the too-strict gate (#142)
Verification of #140 (driving real uv) found system_python_ge_311() was
stricter than uv's own interpreter discovery: it probed only `python3`/`python`,
so on a machine where `python3` is the macOS 3.9 but a Homebrew 3.14 exists, the
gate returned false and the only-system fallback was skipped — even though
`UV_PYTHON_PREFERENCE=only-system uv venv` resolves 3.14 fine.

Fix: drop the pre-gate (and the now-unused parse_py_version/system_python_ge_311
helpers + the parse test) and always add the system-python attempt as the last
resort. uv's discovery is the authority; with `requires-python = ">=3.11"` it
resolves any compatible system interpreter or fails fast → remediation.

Verified live: `only-system uv venv` created a venv from system CPython 3.14.5
on this host (no 3.11.x present). cargo test + cargo check clean.

Refs #130.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:40:22 +05:30
Palash DebnathandClaude Opus 4.8 c37a932784 fix(voice-design): validator-safe instruct builder (plan-05, closes #114 #115) (#141)
* fix(voice-design): build validator-safe instruct on the frontend (#132)

plan-05 (option A — frontend guard). The engine validator is whitelist-strict
by design; the #114/#115 failures came from useTTS.js merging the free-text
instruct field with the category dropdowns, producing unsupported items (#115)
or two items in one category (#114).

- voiceInstruct.js buildDesignInstruct(vdStates, freeText): dropdowns win their
  category; free-text accepted only as a known tag in an open category;
  unknown/duplicate items are dropped and returned so the UI can warn. Derives
  TAG_TO_CATEGORY from CATEGORIES (single source of truth).
- useTTS.js design mode uses it instead of the raw merge; toasts dropped items.

Engine validator (_resolve_instruct) untouched — whitelist contract preserved,
no vendored-engine change.

Tests (TDD, vitest): voiceInstruct.test.js (6). Full frontend suite 72 passed;
typecheck + build green.

Closes #114, #115. Addresses #132.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(voice-design): split unsupported vs duplicate instruct; warn on dropdown drift (Greptile #141)

- buildDesignInstruct now returns { instruct, unsupported, duplicates }:
  `unsupported` = free-text prose (not a known tag, #115); `duplicates` = a
  valid tag whose category was already set (e.g. dropdown low pitch outranks a
  typed high pitch, #114). useTTS shows an accurate toast per bucket instead of
  calling a valid-but-outranked tag "unsupported".
- console.warn when a *dropdown* value isn't in CATEGORIES (option-list ↔
  whitelist drift) instead of silently dropping it.

Tests updated + 1 added (7/7); typecheck + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:12:36 +05:30
Palash DebnathandClaude Opus 4.8 c34bc002a3 fix(bootstrap): mirror cascade + system-Python fallback for blocked networks (plan-03, closes #60) (#140)
* fix(bootstrap): mirror cascade + system-Python fallback for blocked networks (#130)

plan-03. First-run bootstrap downloaded managed Python from GitHub with no
mirror and a short retry budget, so a GitHub-blocked/unresolvable network
killed the install dead-on-arrival (#60).

bootstrap.rs (Rust/Tauri):
- apply_uv_http_env(): UV_HTTP_TIMEOUT=120 / CONNECT_TIMEOUT=30 / RETRIES=5 on
  both `uv venv` and `uv sync`.
- `uv venv` cascade: default GitHub → gh-proxy mirror (UV_PYTHON_INSTALL_MIRROR)
  → system Python (UV_PYTHON_PREFERENCE=only-system, only if a system Python
  >=3.11 is detected). First success wins.
- Actionable failure messages (install python.org Python / set a mirror / Clean
  & Retry) instead of a raw uv exit code.

Frontend: BootstrapSplash hint for the GitHub-blocked / can't-download-Python
case. Docs: troubleshooting.md restricted-network section (mirror env vars,
China PyPI index, honest VPN note) — referenced by the remediation text.

Tests: Rust #[cfg(test)] for parse_py_version + apply_uv_http_env (cargo test:
2 passed, crate compiles); docs-drift validator + frontend build green.

NOTE: the restricted-network E2E paths (mirror install, only-system fallback)
need MANUAL verification on a real GitHub-blocked network — not reproducible in
the dev/CI harness. cargo + the unit tests cover compile + the pure helpers only.

Closes #60. Addresses #130, #57, #127.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bootstrap): drop --python 3.11 pin on system-Python fallback (Greptile #140)

system_python_ge_311() accepts 3.12/3.13, but the fallback passed `--python
3.11`, forcing uv to find a 3.11.x interpreter exactly — so a machine with only
3.12/3.13 failed the fallback and wrongly hit the remediation. Drop the pin;
`only-system` + the project's `requires-python = ">=3.11"` lets uv resolve any
compatible system interpreter. cargo test: 2 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 10:44:41 +05:30
Palash DebnathandClaude Opus 4.8 898f41a57d fix(windows): gate torch.compile on Triton + ASR critical-path smoke (plan-02, closes #65) (#138)
* fix(windows): gate torch.compile on Triton availability (#129, closes #65)

plan-02. torch.compile(mode="reduce-overhead") needs Triton at runtime;
Triton has no Windows wheel, so the old `device=="cuda"`-only guard in
model_manager.py failed on Windows+CUDA and surfaced as a confusing "OOM"
(#65). Inference-time, hard to diagnose.

- engine_env.should_torch_compile(device): requires CUDA + find_spec("triton")
  + the existing perf.torch_compile_disabled setting being off; logs the skip
  reason at INFO and falls back to eager.
- model_manager.py call site uses it instead of the bare cuda check.
- smoke-test.sh INST-02: import torch + ctranslate2 + whisperx (full ASR path)
  so a missing transitive dep fails the build instead of crashing mid-
  transcription (#116). Runs in the CI smoke-matrix on Win/macOS/Linux.

setuptools>=75.0 (fix-sequence step 1) already pinned (#58). Linux/CUDA+Triton
behaviour unchanged.

Tests (TDD): tests/test_torch_compile_gate.py (4). Closes #65; addresses
#129/#116.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(windows): also gate subprocess torch.compile on Triton (Greptile #138)

Greptile flagged that the in-process gate left a parallel gap: engine
subprocesses honour TORCH_COMPILE_DISABLE, but build_engine_env() only set
it on the user's Performance toggle — so a Triton-absent host (Windows, or
macOS) still exposed subprocess engines to the same crash this PR fixes
in-process.

- build_engine_env(): set TORCH_COMPILE_DISABLE=1 when the user disabled
  compile OR Triton is unavailable (find_spec), cross-platform — mirrors
  should_torch_compile(). Drops the Windows-only scoping (and the now-unused
  `import sys`).
- Refreshed the stale module docstring.
- 3 new tests cover the subprocess gate (triton-missing, triton-present,
  user-opt-out). 7/7 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* revert(engine_env): keep subprocess TORCH_COMPILE_DISABLE user-driven

Reverts the build_engine_env() broadening from the previous commit. Auto-
disabling subprocess torch.compile on Triton-absence conflicts with a
deliberate, tested contract (test_perf_settings: Windows + flag-off ⇒ no
injection; non-Windows ⇒ never inject) — the subprocess var is intentionally
under the user's explicit control.

The #65 fix is the in-process should_torch_compile() gate (unchanged here),
which IS automatic and fully tested. Pushing back on the subprocess auto-gate
as a separate, deliberate contract change rather than forcing it through by
rewriting established tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 10:22:19 +05:30
Palash DebnathandClaude Opus 4.8 ea868386e3 fix(windows): HF cache disk-fallback for WinError 448 (plan-01, closes #117 #118) (#137)
* fix(models): disk fallback when scan_cache_dir raises WinError 448 (#128)

plan-01 fix-sequence step 2. On Windows, huggingface_hub's scan_cache_dir()
raises WinError 448 "untrusted mount point"; the three call sites in
setup/models.py swallowed it and reported "not cached", so the app
re-downloaded models it already had — looping 5× and giving up (#117/#118).

- _is_cached_on_disk / _scan_cache_on_disk: walk the canonical HF layout
  <cache>/models--<org>--<name>/snapshots/<rev>/ directly (honours
  HF_HUB_CACHE/HF_HOME, so a relocated models dir works too).
- is_cached / list_models / recommendations now fall back to the disk scan
  when scan_cache_dir() raises. An empty snapshot dir is not counted.

Symlink-disable env + local_dir_use_symlinks=False were already shipped
(main.py, setup/download.py); this closes the remaining failure path.

Tests (TDD, fail-before/pass-after): tests/test_hf_cache_fallback.py (4).
No regression on the non-Windows path (fallback only triggers on raise).

Closes #117, #118. Addresses #128 (#64 configurable-dir is the follow-up).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(models): probe HF /hub subdir + close scandir handle (bot review)

Addresses #137 review:
- CodeRabbit (critical): hf_cache_dir() returns HF_HOME when HF_HUB_CACHE is
  unset, but repos live under $HF_HOME/hub/models--…. Added _hub_cache_roots()
  so the WinError-448 fallback probes both <dir> (HF_HUB_CACHE-set case) and
  <dir>/hub (HF_HOME-only case); previously it could miss the cache and
  re-download. Regression test added (HF_HOME-only layout).
- Greptile: wrap os.scandir() in `with` so the dir handle closes even when
  any() short-circuits (avoids handle leaks on repeated /models polls).
- CodeQL: drop unused `os` import in the test.

5 tests pass, incl. -W error::ResourceWarning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 09:27:43 +05:30