Migrations in Docker fell back to the additive-column self-heal because
alembic.ini was never copied; the real migration chain now runs. The
HEALTHCHECK covers plain docker-run (compose files keep their own), with
a start period sized for first-boot schema creation. Docs example tag
freshened.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The kill-before-wipe block used DRY_RUN; the script's flag is dryRun —
any run with a live instance crashed with ReferenceError before wiping.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
A backend that keeps running while its install is deleted or replaced
underneath it still answers /health and /system/info from memory — the
launcher's version check passed and the UI attached to a process that
500s every DB-touching route (raw errors without CORS headers, so the
webview reports access-control failures). The attach path now requires a
DB-touching probe (/profiles) to return an actual 200 status line, and
replaces the squatter otherwise — the status line is parsed explicitly
because the raw HTTP helper previously returned 500 bodies as Ok.
desktop-prod/desktop-fresh now terminate our own running processes
(bundle, dev binary, app-scoped port-3900 listener) before wiping, which
is how the zombie was produced.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
New installs rendered at 130% zoom, which read as oversized on typical
displays. Fresh sessions now start at 100%; anyone who already picked a
scale keeps it (uiScale is persisted and wins over the default).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Settings → Engines collapses from three stacked full matrices (where one
engine row could sprawl to 5+ lines and fill a viewport) into ONE section
with a TTS / ASR / LLM tab strip (the matrix's Radix Segmented — roving
tabindex + arrow keys, active engine named in each tab caption). The single
mounted matrix still issues exactly one GET /engines + one GET /model/loaded
per Settings open; switching tabs re-slices the fetched payload with no
refetch. openSettingsTab('engines') deep-linking is unchanged (nothing in
the app targets a specific family — audited).
Every engine row is now strictly two lines inside a fixed h-16 shell:
line 1 = EngineMark + truncated display name (full name via title, never
wraps) + active/in-memory badges; line 2 = engine id, cloning chip,
curated-model picker, one-line truncated hints (full text via title).
Header and rows share one grid template
(minmax(0,1fr) 108px 176px 92px 232px) so the STATUS / GPU COMPAT /
ISOLATION / ACTIONS columns align on every row; actions sit right-aligned
and vertically centered across both lines. Below 880px the three meta cells
re-place onto the row's second line (same DOM nodes) instead of forcing a
horizontal scroll. Unavailable-row details (reason, install hint, last
error, setup snippet) move out of the row into an aria-expanded expansion
panel that opens BELOW it, so sibling rows never lose alignment. The
previously hardcoded column labels are i18n'd (engines.col*).
Settings → Models already has its natural grouping as the role filter
strip (All/TTS/ASR/… with counts) + search over one list, so no tabs were
invented there; its rows get the same compactness treatment — 5px vertical
padding + 52px min-height two-line rows (virtualizer estimate updated),
with title attributes carrying the full label/repo text past the ellipsis.
Tests: EnginesTab suite rewritten for the tabbed layout (tab strip renders,
switching families doesn't refetch, single /engines + /model/loaded probes,
Use-on-ASR flows through the tab); matrix suite updated for the expansion
panel (reason/last-error/setup-snippet live behind the Why-unavailable
toggle) and extended with layout regressions: fixed-height two-line shell,
name truncation + title, header/rows sharing identical grid tracks,
panel open/close as a sibling below the row. 1139 frontend tests pass;
typecheck:ci, oxlint, oxfmt and vite build are clean.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Most users should never learn what ffmpeg is. The Setup Wizard's SYSTEM
PREFLIGHT stops listing FFmpeg / FFprobe / yt-dlp as user-installed
requirements ("brew install ffmpeg…"): they are internal dependencies the
app provisions for itself. Genuine user facts (OS, RAM, disk, GPU,
network, Python) are untouched.
Backend
- New services/media_tools.py: per-tool status {version, path, origin:
sidecar|bundled|system|custom}; background acquisition of a pinned,
SHA-256-verified static ffmpeg+ffprobe build (immutable-commit fetch
from the same upstream the static-ffmpeg pip package uses — that
package itself was audited and rejected: mutable raw/main URL, no
checksums, writes into site-packages); binaries are `-version`-probed
via the existing _binary_runs before being trusted, installed under
DATA_DIR (update-surviving, frozen-build-safe), zero new Python deps.
- ffmpeg_utils resolution chain gains the acquired-bundled tier — and
ffprobe finally has a bundled tier at all (imageio-ffmpeg ships none),
closing the source-install gap.
- New /media-tools router (loopback-gated, same contract as
/system/set-env): status, acquire, {tool}/custom-path | use-system |
restore, ytdlp/update | restore. Overrides persist via the existing
env.FFMPEG_PATH / env.FFPROBE_PATH prefs convention — one store, no
competing controls.
- yt-dlp updates: audited in-venv pip/uv upgrade and rejected (venv is
uv-managed with no pip; yt-dlp is a locked dep, so the updater's
--inexact drift sync would revert it). Instead the newest wheel —
verified against PyPI's own sha256 — lands in a DATA_DIR overlay
prepended to sys.path at startup: survives app updates, works in
frozen builds, and "Restore tested version" is just deleting the
overlay. Gallery now runs yt-dlp via `python -m yt_dlp` (module, not
PATH) so the CLI can never be a user-install task either.
- /setup/preflight drops the three tool rows, carries a media_tools
verdict, and self-heals: kicks the bundled download in the background
when no tier resolves (never re-fires after a failure — the wizard's
card owns Retry). diagnose + the ffmpeg-missing notification now point
at Settings → Audio tools instead of package managers.
Frontend
- Wizard: new MediaEngineCard — renders NOTHING when the engine is ready,
a one-line progress while acquiring, and only on failure an actionable
card (Retry / Use a system copy / Choose file…).
- Settings → Audio tools (new category, System group): FFmpeg + FFprobe
rows with version, path, origin badge, Use system copy / Choose file… /
Restore bundled, header-level "Update bundled build"; yt-dlp row with
Update + Restore tested version (+ restart affordance). Package-manager
commands appear only as copyable prose, never executed.
- The FFmpeg-path override moved out of Settings → Network (pointer row
deep-links to Audio tools; no second writer of env.FFMPEG_PATH).
Notifications gain a settings-tab action type.
- All strings i18n (en + defaultValue), a11y labels on every control.
Tests: 29 new backend (origin classification, checksum/size/probe
rejection, override persistence, overlay update/restore, router gating +
route-shadowing) + preflight contract tests (tool rows gone, verdict
present, auto-acquire fires once); 14 new frontend (wizard hide/progress/
failure-card, Audio tools rows/badges/actions). Route snapshot
regenerated. Docs (macos/linux install, troubleshooting §7b) describe the
new reality in the same commit.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
desktop-prod fixes:
- `tauri build --debug` used to produce every bundle and THEN exit 1 at the
updater-artifact signing step (no TAURI_SIGNING_PRIVATE_KEY on dev
machines); the script papered over it with a blanket "non-fatal bundle
error" grep that also swallowed real bundling failures. Local emulation
builds now pass `--config '{"bundle":{"createUpdaterArtifacts":false}}'`
and only build the bundle the script launches (--bundles app / appimage,
--no-bundle on Windows), so the build exits 0. Any nonzero exit now FAILS
the script — the sole tolerated case is a specifically-detected
linuxdeploy/FUSE failure on Linux when the raw debug binary was produced.
- The HF cache wipe ran `rm -rf ~/.cache/huggingface` on macOS/Linux — the
SHARED global cache (backend/core/config.py only relocates it on Windows),
deleting models unrelated to OmniVoice. Non-app-scoped cache paths are now
kept with a "models will be reused" notice; FRESH_NUKE_HF=1 opts in.
- Honest clean marks (removed ✓ / already-clean ○ instead of ✗ for success),
`open -n` always (plain `open` focused a stale running instance instead of
launching the freshly built one), stale-AppImage removal on Linux.
New `bun desktop-fresh` (+ desktop-fresh:run), macOS-only with explicit
refusal elsewhere: true new-user emulation.
- Blank slate: everything desktop-prod cleans PLUS the traces that survive a
reinstall + data wipe — ~/Library/WebKit (webview localStorage), Caches,
HTTPStorages*, Preferences plist (+ defaults delete), Saved Application
State. Per-path found/removed/absent status with sizes; --dry-run prints
the full plan without touching anything.
- Dev-machine camouflage: launches by direct exec of the bundle's Mach-O
(which inherits env — `open` hands off to launchd and drops it) with PATH
stripped of /opt/homebrew/{bin,sbin} + /usr/local/bin and HF_TOKEN /
HUGGING_FACE_HUB_TOKEN / HF_HOME / HF_HUB_CACHE / HF_ENDPOINT /
OMNIVOICE_* unset, and prints a banner of what is hidden.
Shared pure helpers live in scripts/desktop-common.mjs, covered by 9 node
tests (tests/frontend/desktopScripts.test.mjs): every cleanable path is
app-scoped and under $HOME, the PATH/env sanitizers strip exactly the
intended entries, and the build args carry the updater-artifacts-off config.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The omni_ui session persisted dubStep verbatim, including in-flight values
(uploading/transcribing/generating/stopping). Quitting or crashing mid-dub
froze that step into localStorage, and every relaunch restored a wait on
work that died with the process — a blank Dub pane with an eternal spinner
that even reinstalling couldn't clear (the webview's localStorage survives).
Restores now clamp to settled states: editing when the session has segments,
idle otherwise; unknown/corrupt values are treated as transient.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
HuggingFace token (ApiKeysPanel):
- "Test now" actually re-runs whoami: GET /api/settings/hf-token/state gains
?fresh=1 which drops the resolver's 300s validation cache (the invalidate
hook existed but was never wired to any endpoint), so a fixed network or
rotated token no longer shows a stale verdict for up to 5 minutes. Plain
panel mounts keep the cache.
- Initial load renders a "Checking token sources…" placeholder instead of
flashing a false amber "not set" for all three sources.
- Source rows are now a valid ARIA list (the old role="table" had rows with
no cells, hiding the status from screen readers).
- Enter in the token input respects the in-flight guard the Save button
already had (no duplicate POSTs).
LLM Providers:
- Test / Fetch models abort when the implicit save fails, instead of probing
the previously-stored config and pairing a green "Test ok" badge with a
save error.
- A failed initial load now offers a Retry button instead of dead-ending
until the panel remounts.
LLM Skills: the per-skill provider Select carries an accessible name
("Provider for <skill>") instead of announcing as an unlabeled combobox.
MCP voice bindings:
- All user-facing strings go through i18n (the panel was the only Settings
surface with hardcoded English throughout).
- First-run guidance moved out of per-row hints (which never rendered with
zero bindings and duplicated per row) into the section header + an
InfoHint that links to docs/mcp.md; an empty state invites the first add.
- Delete asks for confirmation via the shared askConfirm, disables the row's
button while in flight, and re-syncs the list even when the DELETE fails
(a 404 row no longer lingers on screen).
- The add row exposes the optional label the API already accepted (the row
title rendered b.label without any way to set it); default_engine stays
MCP-side-only and is documented as such.
- First component test file for the panel (load/empty/add/delete/error/a11y).
Tests: backend fail-before/pass-after for the fresh=1 cache bust; new
frontend coverage for every behavioral change above.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Settings → Network / Models / Sharing / Privacy / OpenAPI fixes:
- NetworkTab: a proxy persisted in a previous session can now be cleared —
the Clear button and "Set" badge derive from the backend-persisted value
(sysInfo.proxy_url), not only from a save in the current session. Proxy row
copy now matches its real semantics ("Applies now" badge; desc/toast no
longer claim a restart is needed or leak yt-dlp jargon — reworded in all
21 locales). FFmpeg path placeholder is platform-appropriate instead of
Windows-only on every OS.
- HFMirrorPanel: the panel no longer disappears when the initial GET fails —
the section shell always renders, with a loading state and an error +
Retry affordance. Saving now toasts, the active preset is marked
(aria-pressed), and the custom-URL row is labelled "Custom mirror URL"
instead of raw HF_ENDPOINT jargon (env var moved to the row note).
- RemoteBackendPanel: full i18n (was 100% hardcoded English); Save & reload
now validates the URL (http/https, parseable) and asks for confirmation
before saving a URL that hasn't passed a connection test — a typo'd base
no longer bricks every API call after reload. Dropped the contradictory
"Restart required" badge (saving reloads the app itself; description says
so). docs/remote-gpu.md updated to match (docs-sync).
- PrivacyTab: the "Network calls" row no longer shows the green "Offline
translator" assurance when the backend is down or reports 'unknown' —
green is reserved for confirmed-offline providers (nllb/argos/
libretranslate), everything unconfirmed shows a neutral "Unknown" badge.
The online-translator warning now deep-links to Translation settings.
- OpenApiPanel: a failed clipboard copy toasts an error instead of silence.
- a11y: all five text inputs across these panels now carry accessible names
(aria-label), previously announced only by their vanishing placeholders.
Tests: new colocated suites for NetworkTab, HFMirrorPanel,
RemoteBackendPanel, PrivacyTab; OpenApiPanel suite extended with copy
success/failure. Frontend suite 140 files / 1061 tests green; i18n parity
probes green (new keys en-only with defaultValue, reworded keys updated in
every locale).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Settings system-group cleanup — every fix keeps existing behavior contracts
and adds a fail-before/pass-after regression test:
- Factory reset now does what it promises: clears every locally-persisted
preference via a single registry (utils/prefKeys.js) instead of only the
zustand blob — nav-rail side, capture live-typing, stories speed, logs
footer state, last settings category, dismissed tips, donate prompts, and
the legacy omni_ui blob included. User data and connection state
(omni_transcriptions, ov_backend_url, ov_api_key) are explicitly preserved,
and prefKeys.test.js scans the source tree so any future localStorage key
must be categorized or CI fails. The failure toast now carries the actual
error message.
- Disk-usage "Clear logs" is confirm-gated with the same wording as
Settings → Logs — it truncates the crash log (the bug-report artifact), so
it can no longer be a single stray click.
- Temporary files got a reclaim action: a confirmed "Clear temp files"
button backed by POST /api/settings/storage/temp/clear, which deletes only
the omnivoice* entries in the OS temp dir (symlinks unlinked, never
followed) and invalidates the cached report.
- Performance panel goes through i18n end to end (title, row, note, hint,
errors, aria-label) — it was the last fully hardcoded panel; the
non-Windows subtitle now reads "Windows only — not needed on this
platform" instead of "not applicable".
- History retention: GET failures now surface an alert and hold Save until
a load succeeds (404 from older backends stays silent), Enter saves, the
dead !res.ok branch is gone, and the bespoke button is the shared Button.
- Logs tab: "Open folder" reveals the log file, "Copy visible log" copies
the tail, the viewer autoscrolls to the newest lines, and the scroll box
is keyboard-focusable (role=log) with a labelled source switcher.
- Storage paths: the app-data row is labelled "App data stored at" (it was
borrowing the Privacy tab's "Uploads stored at"), and all three path rows
gained Open folder.
- i18n stragglers routed through t(): storage load/open/clear fallbacks,
the backend-status badge, and the frontend log buffer label.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Settings → Dictation / Pronunciation / remote-ASR panel fixes:
- RefinementPanel no longer vanishes when the initial GET fails (backend
down/restarting): the section shell always renders, with the error, a
Retry button, and a loading line — matching its sibling panels.
- RefinementPanel offers the "Open LLM Providers" deep-link as soon as
llm_ready is false, not only after the first refinement failure.
- Pronunciation test preview gains a preview-language selector and sends
it to POST /pronunciation/test, so language-scoped entries finally show
up in the preview instead of a misleading "No entries match"; a hint
explains that Global previews skip language-scoped entries.
- Preview requests are debounced and sequence-guarded (a slow stale
response can never overwrite a newer one), failures surface as a
"preview unavailable" note instead of silently blanking, and the
preview re-runs after add/toggle/delete/import so it never goes stale.
- Dictionary backup & restore: Export JSON / Import JSON buttons wired to
the existing GET /pronunciation/export and POST /pronunciation/import
endpoints (import prompts replace-vs-merge when entries exist).
- a11y: each entry's enable switch is named after its term ("Enable
GIF"), all add-form/test inputs and selects carry aria-labels, and the
cramped language field gets a short placeholder with the long
explanation moved to the row hint.
- RefinementPanel + AecPanel converted to i18n (`dictation.*` keys; the
refine-failure helper now returns a key instead of hardcoded English),
per the all-UI-strings-through-i18n convention; "experimental" is now
sentence-cased via its key.
- Copy: empty state says "Add one below" (the form is below the list, in
every locale) and the test row is titled "Test a sentence" instead of
its ellipsized placeholder.
- AsrOpenAICompatPanel: Save is disabled until a field actually differs
from the server values and shows a "Saved" confirmation after saving —
URL/model-only edits are no longer silently ambiguous.
- Enter submits the pronunciation add form; Add is disabled while the
term is blank.
New pronunciation locale keys are translated in all 21 locales (keeping
that namespace fully covered); the new `dictation.*` namespace is en-only
with fallback, matching the `models.asrOpenAICompat*` precedent. No
backend changes — /pronunciation/test already accepted `language`.
Tests: RefinementPanel + AsrOpenAICompatPanel component tests added,
PronunciationPanel tests extended (language-scoped preview, stale-response
guard, preview error, re-run after add, Enter-to-add, per-entry switch
names, export/import round-trip), refine-note test updated for i18n keys.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- About linked the wrong project: "OmniVoice on GitHub" and "Model card"
opened k2-fsa/OmniVoice. The GitHub button now derives from a single
REPO_URL constant in utils/bugReport.js (issue/search URLs derive from it
too), and the Model card button is gone — a multi-engine app has no single
model card. A test pins the button to the constant so links can't drift.
- Picking Arabic now actually flips the UI to RTL: the languageChanged
handler sets document dir + lang from i18n.dir(), covering any future RTL
locale as well.
- A Settings search that matches nothing now shows "No settings match" with
a Clear action instead of a silently blank sidebar (and an option-less
nav <select> in the narrow layout).
- Hotkey recording no longer swallows modifier-less presses in silence — it
shows inline "add a modifier" feedback, the Record button becomes a Cancel
toggle while listening, window blur cancels the global key listener, and
the row copy states the modifier requirement.
- About no longer dead-ends on fixable problems: "HF token set: no" and
failing self-checks deep-link into the owning Settings category via
openSettingsTab.
- Sidebar search now also matches translated setting-row titles
(keywordKeys), so localized users can find categories by localized names;
English keywords keep working everywhere.
- Network gets its missing restart flag (the FFmpeg-path row is a
restart-bound env write); a lockstep test keeps RestartBadge usage and
category flags in sync.
- The theme-dot and font-tile radiogroups implement the real WAI-ARIA radio
pattern: roving tabindex plus arrow-key movement with focus following
selection.
- Copy cleanup: sentence-case "UI scale" / "Commercial license"; the review
segmented control moves off the orphaned engines.review_* keys to
settings.review_mode_on/off (renamed across all 21 locales) with plainer
English labels.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The engine list gains a scannable identity mark per engine (EngineMark),
capability badges (cloning, device routing with reasons, sidecar isolation),
and surfaces available-but-has-advice hints that list_backends previously
dropped (new additive hint field; the ready-with-advice convention). The
model store gains a filter, disk context near downloads, in-memory residency
indicators with safe unload, copyable setup snippets, and actionable
empty/error states. Registry additions are additive only (hint,
supports_cloning with the property-descriptor guard).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
A first-run breaker: all blobs download fine, but the snapshots/<rev>/
entries are dangling symlinks (0 KB) — os.path.isfile() is False on a
dangling link, so transformers reports the weights missing even though
the bytes are on disk, and the existing resume repair can't fix it.
New services/hf_cache_repair.py deletes exactly the broken snapshot
entries (dangling symlinks + zero-byte weight/config stand-ins; never
blobs, never resolving entries) and restores them via snapshot_download,
verifying afterwards — if the restore recreates broken links (hub's
memoized symlink probe passing while real links come out broken), it
forces hub into copy-mode and repairs once more with real files.
model_manager retries the load exactly once per repo per process
(rung 0 of the cache-recovery ladder); dead-end errors now name the
exact models--<org>--<name> folder to delete. failure.py classifies the
class as MODEL_CACHE_CORRUPT so the user-facing error and auto bug
report explain the automatic repair.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Three hardenings of the voxcpm2 engine path, all backward-compatible and
platform-identical:
- Version floor: every install hint now says pip install "voxcpm>=2.0.3"
(2.0.3 fixed an Apple-Silicon/MPS audio-quality bug). Floor only — an
already-installed older version stays available and working; it just
surfaces an actionable upgrade hint in the is_available reason and a
load-time warning.
- Reference-clip prep: the voxcpm package no longer trims reference audio
itself, so raw user clips reached the model unconditioned. The clone path
now trims leading/trailing near-silence (-50 dBFS floor, 50 ms edge pad)
and caps the reference at 30 s. Fail-open (any prep problem falls back to
the raw clip) and a strict no-op for short clean clips.
- Trailing-silence guard: generated output is trimmed to the last voiced
sample + ~0.3 s natural tail via the new audio_dsp.trim_trailing_silence.
Silence-trim only, no content analysis; a no-op on outputs without a
silent tail and on all-silent (dead) renders.
22 new fake-module tests in tests/test_voxcpm2_guardrails.py; existing
engine/hint tests strengthened to guard the floor.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The engine-agnostic text-normalization pre-pass now runs at the three
remaining text→engine choke points, applied exactly once per request:
/v1/audio/speech (req.language), /ws/tts (whole text, before the sentence
chunker fans it out), and the batch queue's per-segment _gen (target
language) — matching the /generate, dub, and audiobook wiring. Route-level
tests pin exactly-once (spy) + toggle-off-raw for each path.
Also fixes a pre-existing /ws/tts bug the new test exposed: any request
omitting emo_alpha hit a KeyError and got an error frame instead of audio.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Every generate already recorded a generation_history row; now that history is
usable: a takes rail in the workspace history lists recent takes with star/
unstar, replay, and one-click restore as the active output. Alembic migration
0009 adds the starred column (the startup schema self-heal covers pre-
migration DBs), a retention cap (setting, default 200) prunes the oldest
UNstarred rows — starred takes are never pruned — and history WAVs are only
deleted when no other row references them.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
New pure planning layer (services/duration_planner.py) runs after translation,
before TTS: estimates each translated line's natural speech duration (self-
calibrating from the job's already-synthesized segments, static per-language
rates as cold-start fallback) and classifies it fits/tight/impossible against
slot + capped gap borrow, with thresholds derived from fit_planner's own caps
so "impossible" means "would be trimmed". Verdicts ride the /dub/translate
response and badge the segment table; an opt-in (default OFF) LLM pass attaches
one-click shorter-rewrite suggestions for impossible lines. Never blocks
generation — informs before GPU time is burned.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
One up-front LLM pass over the full transcript extracts a theme summary +
terminology map, merges it under the user's manual glossary (user entries
always win), caches it on the dub job per target language (job_data blob, no
schema change), and injects the brief into every per-segment prompt. A new
reflect pass then critiques each segment's direct translation for wordiness /
stiff register and rewrites it as natural spoken dialogue — any failure or
divergence silently keeps the direct translation. Both stages have Dub-tab
toggles (default ON for the LLM engine, persisted; MT engines unaffected),
with i18n strings across all 21 locales and docs updated.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
New conservative, idempotent pre-TTS normalization pass
(services/text_normalization.py): strips zero-width/control junk, caps
pathological repeat runs, expands digits/times/ordinals/currency via
num2words (29 locales) and per-language abbreviation maps (EN/DE/ES/FR).
Wired once at each text-to-engine choke point — /generate, dub segments
(+ preview), and longform chapters — BEFORE the pronunciation dictionary
so user respellings stay the final say. Pref-gated
(text_normalization_enabled, default ON) with OMNIVOICE_TEXT_NORMALIZATION
env override; num2words promoted to a direct dependency.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Adds a content-addressed segment cache (segment_cache_key + SegmentCache,
cache_dir/segments/) under the existing chapter cache: a changed chapter now
reuses every untouched span's WAV and synthesizes only the edited/missing
ones, and an interrupted chapter render resumes from the segments that already
finished (each persists the moment it renders). The chapter key derivation is
unchanged so on-disk caches from released versions keep hitting, a fully-
unchanged chapter never touches segment files, and prune_cache_dir now walks
both layers so one byte cap bounds the whole cache. Chapter SSE events gain
additive segments/cached_segments counts.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
A multi-minute generation pushed the whole waveform through the AudioSeal
generator in one call; its activation memory grows linearly with length, and
a reporter's 16 GB Windows box failed a single ~2.2 GB CPU allocation mid-
generate (DefaultCPUAllocator: not enough memory). Embedding and detection
now slice audio into ~30 s chunks (sub-second tails fold into the previous
chunk), so peak memory is flat regardless of audio length. Detection keeps
the best-confidence chunk, which also stops whole-file averaging from
diluting spliced audio.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The popover was hard-anchored bottom-[60px] — always growing upward
from the textarea. ScriptPanel's only mount (CloneDesignTab) puts that
input at the very top of the panel, so the tag list (max-h 280px,
including the CMU phoneme chips visible in the owner's screenshot)
extended past the viewport top, unreachable and unscrollable. Anchored
top-[calc(100%+6px)] instead: below the input, where the panel's
topmost placement guarantees room in its one mount.
Regression test locks the placement (top-anchored, bottom-[60px]
banned).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
playBlobAudio-path audio (generate auto-play, profile/segment previews,
story lines, gallery voices, Projects renders) played through a bare
Audio()/AudioContext with no on-screen player; the #1032 stop pill was a
stop-only band-aid with a fixed-overlay overlap quirk at 1440x900.
- playback.js: claimTrackedPlayback extends the single-playback manager
with label + seek/pause/resume transport and a timeupdate-driven track
snapshot (currentTime/duration/paused/peaks); claimPlayback stays as
the thin wrapper, single-playback invariant unchanged.
- media.js: every playBlobAudio path registers tracked - element paths
get real seek/timeupdate, the Tauri Web Audio path gets offset-based
seek + suspend/resume pause, and peaks are computed once from the
blob/decoded buffer already in hand (never refetched). onDone(reason)
lets callers chain (stories) or reset card state (gallery).
- GlobalAudioPlayer.jsx: persistent bottom bar (only for source
'output' — exact pill exclusion semantics) with peaks canvas,
click/drag/keyboard seek, play/pause, elapsed/total, label, stop.
- Layout: the bar is a real grid row (row 3) above the LogsFooter,
mirroring the footer's in-flow fix — content physically ends at its
top edge, so the pill's overlay-overlap class cannot recur; fixed
overlays anchored above the footer also clear --audio-dock-height.
Verified headless (Chromium 1440x900 + 1000x700, isolated vite, all
:3900 traffic intercepted): bar meets footer edge-to-edge, clears the
nav rail, seek/pause/stop drive the owner callbacks.
- Callers pass labels: "Generated audio" (useTTS/first-sound), profile
name / segment text (useProfiles), story line (StoriesEditor), voice
name (VoiceGallery/CommunityZone/ImportsZone), render title
(Projects). VoiceGallery drops its bespoke copy of the Tauri playback
detour; StoriesEditor line previews now actually play under WebKit
(blob: media URLs never worked there) and are stoppable mid-chain.
- PlaybackStopPill.jsx + its test deleted; intent migrated into
GlobalAudioPlayer.test.jsx (appears on output/hidden when idle/stop
works/excluded sources) plus transport coverage; playback.test.js
covers the tracked API; playBlobAudioTracked.test.js covers the
media wiring incl. onDone reasons; logsFooterInFlow.test.js now
guards both bars' grid rows and the overlay anchor calc.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(tts): model-load time stops eating the generate timeout budget (#1033, #1037)
The generate guard (OMNIVOICE_GENERATE_TIMEOUT_S, 300s) wrapped the
adapter's lazy _ensure_loaded() — weight download included — together
with the synthesis. A cold first request burned the whole window on
the download and died with the VRAM-guidance 503; #1014's T4
verification measured it (0% GPU util for the full 300s), and #1033 +
#1037 match the signature.
New public TTSBackend.ensure_ready() (dispatches to the adapter's
_ensure_loaded when present) runs FIRST under the model-load budget
(OMNIVOICE_MODEL_LOAD_TIMEOUT, 1200s) in both /generate's adapter path
and /v1/audio/speech — the same load/generate split get_model()
already gave the native engine. Warm engines no-op. A load exceeding
its own budget 503s with load-specific text pointing at Settings →
Models, never the misleading 'too heavy for compute' guidance.
Tests: end-to-end class test (load slower than a tiny generate budget
but inside the load budget → succeeds; fail-before verified), the
stalled-load error path, and the base-hook dispatch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* changelog entry for the load-budget split (#1033, #1037)
* catch the builtin TimeoutError base — reload-proof class identity (CI-only miss)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@shakib30 built and tested a working Colab notebook for the project
and offered it upstream. Linking it from the README (community-
maintained, credited) makes the no-local-GPU path discoverable without
taking on notebook maintenance in-repo.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Three-part fix for the v0.3.5-comparison report:
1. Perf: since v0.3.6 (#308), a clone reference without a stored
transcript triggered a FULL ASR model load + transcribe on every
/generate — get_active_asr_backend() builds a fresh whisper backend
per call. Measured live: 92.7s wall vs 14.9s of actual TTS. Now the
first auto-transcript is persisted onto the (unlocked, clone-kind)
profile row, and transcribe_reference caches results by audio
content hash (bounded LRU, no model/VRAM held), so the cost is paid
once per clip, not per request. User-typed transcripts are never
overwritten; locked/design profiles are excluded from the persist.
2. Clear History: the workspace UX overhaul (#374) moved history into
the right-side WorkspaceHistory panels and dropped the old Sidebar's
clear-all control (the Sidebar is now hidden in every mode). Both
the Voice and Dub panels get a scoped Clear History button wired to
the existing DELETE /history and /dub/history endpoints, with the
same confirm dialog the Sidebar used.
3. Auto-play: the finished-render playback (playBlobAudio) has no
on-screen player and the only stop lived in the Voice ActionBar's
CTA morph — unstoppable from the Dub workspace, profile pages, or
after navigating away. A global PlaybackStopPill now appears for any
'output' playback on every page. The existing Settings → Appearance
"Auto-play preview" pref (#667) now also gates the generate path,
as its label always promised (default ON — no behavior change).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
A contributor's measured Tesla T4 verification (PR #1014) caught that
POST /v1/audio/speech accepted num_step/guidance_scale in the JSON
body with a 200 OK and discarded both (pydantic's default
extra=ignore) — API callers could never reach the model's documented
quality preset (num_step=32) through the OpenAI-compatible surface,
while the native /generate exposes both as form fields.
Both are now declared as validated optional extensions (num_step 1-128,
guidance_scale 0-20) and passed through to the engine's generate()
kwargs — omitted means absent (engines that don't accept the kwargs
never see a stray None), exactly like the existing duration/seed
extensions.
Tests: passthrough reaches the engine kwargs; omitted stays absent.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* docs: add AGENTS.md with verified Tesla T4 (16GB) inference notes
Documents two things found while verifying inference on a real T4:
1. Cold-cache first /v1/audio/speech call can hit the 300s
OMNIVOICE_GENERATE_TIMEOUT_S because the checkpoint download happens
inside that budget — workaround via existing POST /models/install or
raising the timeout, no code change needed.
2. The OpenAI-compatible endpoint silently ignores num_step/guidance_scale
(schema doesn't declare them) — use native /generate for those.
Also documents the T4 acceleration checklist (dtype/attention/int8/CUDA
graphs) and measured VRAM (peak 2.05GB). No code changes.
* fix(docs): make /models/install workaround command actually executable
Addresses Greptile review: the instruction omitted the required
repo_id body field (InstallModelRequest rejects an empty body).
* fix(docs): correct port in /models/install example (3900, not 8000)
The app serves on port 3900 (confirmed: /health returns 200 there,
connection refused on 8000). Verified the exact corrected curl command
returns 200 {"status":"install_started",...}.
* move T4 notes to docs/hardware-notes-tesla-t4.md — AGENTS.md is the auto-loaded agent-instructions filename
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The re-transcribe added for the (ref_audio, ref_text) mismatch fix is
dispatched with a bare run_in_executor(_gpu_pool, ...), and refine_ref_text
calls asr_backend.transcribe() directly. Its try/except catches a raised
error but not a *hang* — a wedged whisperx/CTranslate2 transcribe (#730)
holds the GPU-pool worker forever. On a <=10 GB card the pool is 1 worker,
so that starves every later GPU job into the misleading "can't reach the
local backend", and there's no ping on the await so the EventSource drops.
Route both refine dispatches (per-speaker and per-segment) through the same
run_transcribe_guarded the rest of dub_core.py already uses (the chunk loop
and the whole-file "Dub" transcribe). On timeout it resets the pool and
raises ASRTimeoutError; keep the original clones, matching refine_ref_text's
own "failure is a strict no-op" fallback.
Adds a repro test: refine_ref_texts dispatched raw is unbounded on a hang;
through the guard it times out and falls back to the original ref_text.
Co-authored-by: stronghamjji <289942360+stronghamjji@users.noreply.github.com>
Two skills in the standard skills/<name>/SKILL.md layout (vercel-labs/
skills CLI; listed on skills.sh via install telemetry):
- omnivoice — teaches any agent (Claude Code, Cursor, Codex, …) to
speak and transcribe through the user's LOCAL install via the
OpenAI-compatible API at localhost:3900: health preflight, TTS with
cloned-voice-profile discovery via /v1/audio/voices, STT with
srt/vtt subtitle formats, and the local-first rule (never silently
fall back to a cloud API).
- oss-maintainer — the maintainer methodology this repo is actually
run with, distilled from real sessions: absorbed-or-declined queue
discipline, check-the-PR-queue-before-implementing, root-cause →
fix-the-class → regression-test, structural merge gates with
flaky-vs-real judgment, the release protocol, and
thank-contributors-specifically.
Every endpoint/flag in the omnivoice skill verified against
backend/api/routers/openai_compat.py and the README's API section.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Every app update whose uv.lock changed ran `uv sync --frozen` to
reconcile the venv (#307 drift path) — and uv sync's exact mode
UNINSTALLS every package not in the lockfile. That silently deleted
user-pip-installed optional engines (voxcpm, kittentts — packages the
app's own Settings → Engines hints tell users to install into this
venv) on every single update. Reported as "VoxCPM2 is automatically
uninstalled after updating Studio."
Fix: the routine drift sync now carries --inexact — locked deps are
still installed/upgraded exactly per the lockfile, but extras the user
added on purpose are left alone. Deliberate asymmetry: the venv-REPAIR
sync stays exact, because repair runs when the venv is broken and a
user-installed extra is a plausible cause — healing must restore the
known-good locked state. First-run syncs are untouched (a fresh venv
has no extras; exact == inexact there).
Both sync arg sets are now named constants with contract tests pinning
the asymmetry, so neither side can silently regress.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Asked directly on Discord ('how it compares to something like 11 labs
in quality?'). The old answer ('yes, comparable for most use cases')
oversold — the honest version names where ElevenLabs still wins
(out-of-the-box English polish/consistency) and where OmniVoice is
genuinely competitive (cloning from clean references, 646 languages,
structural advantages), plus the dubbing-specific truth another
same-day report surfaced: a dub is a chain, and incoherent output
usually traces to transcription quality on the user's source audio —
with the check-the-original-text-first debugging step that actually
helps.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Settings → Engines now stacks one pinned Engine Compatibility Matrix per
family (TTS, ASR, LLM) instead of a single TTS-titled table with the other
families tucked behind a low-discoverability tab. The backend select/prefs
path (family="asr" → prefs.asr_backend, env > prefs > auto-detect) already
worked but was unexercised and undocumented — it's now locked by API and
resolution-order tests, and README + the openai-compat-asr doc stop
promising a picker that didn't exist / denying one that now does.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Three classes of staleness that actively misled agent sessions:
- The Project section hardcoded "latest stable v0.3.5 / main at v0.3.6"
— eight releases behind. Now points at the sources of truth
(frontend/package.json, the Releases page) and documents the current
AUTO_VERSION_BUMP-off holding behavior instead of a version literal
that rots every release.
- ~165 lines of May-2026 stack research for five capabilities that have
ALL since shipped (HF-token panel, prefilled-URL bug reporting, uv
mirror fallback, Supertonic-3, in-repo docs). Compressed to the
durable don'ts it established (no telemetry endpoints, no app-side
GitHub tokens, no setx, no MkDocs, no hf_transfer) plus a pointer to
prefer what's already pinned.
- The GSD Workflow Enforcement gate referenced /gsd-quick//gsd-debug/
/gsd-execute-phase skills that exist nowhere in this environment; the
owner explicitly chose direct edits over restoring them (2026-07-08).
It cost a real mid-task detour when a subagent correctly refused to
work under an unsatisfiable rule. Replaced with the owner decision
and the working conventions that actually bind (merge gating,
check-the-PR-queue-first).
244 → 83 lines. GSD section markers preserved so the generating tool
can still find its blocks.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The launcher decided whether to export WEBKIT_DISABLE_COMPOSITING_MODE
by asking the host's pkg-config — but LD_LIBRARY_PATH makes the
BUNDLED libwebkit2gtk the one that actually runs, so on any machine
where the two diverge the detection read the wrong number. This was
the second bug identified during #961's investigation (the reporter
built from source, so their dev packages answered pkg-config with a
healthy version while the shipped bundle ran an older lib) and was
explicitly deferred in #1007 as not-safely-fixable at runtime.
The fix makes it knowable by construction instead: inject-apprun.sh
runs at bundle time ON the build host whose libwebkit2gtk gets
bundled, so it stamps that version into .bundled-webkitgtk-version
inside the AppDir. AppRun reads the stamp first and only falls back to
host pkg-config for bundles predating it. Empty/unreadable stamp fails
safe (workaround on), same philosophy as the missing-pkg-config path.
Tests: 3 new cases in AppRun.test.sh — marker-beats-host in both
directions (broken-marker/healthy-host and the #961 inversion,
healthy-marker/broken-host) plus empty-marker fail-safe. Also wires
AppRun.test.sh into pytest (tests/test_apprun_launcher.py) — it was
previously run by NO CI job, so the launcher could regress silently.
Also documents Windows install-to-another-drive behavior in
docs/install/windows.md (#938): local drives work via the wizard's
directory picker, mapped network drives are a Windows Installer
limitation, and the data directory moves independently of the app.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
troubleshooting.md §16 claimed the macOS microphone-permission bug was
an unresolved upstream Tauri/wry limitation with no available fix.
That was wrong: @MahdiHedhli read the wry/tauri sources more carefully
and found the real cause — Tauri's Hardened Runtime default blocks mic
hardware access without com.apple.security.device.audio-input in the
bundle's entitlements, which also explains why TCC never listed the
app. Their fix (#1016) is merged; §16 now documents the real mechanism,
credits the correction, and keeps the record-elsewhere workaround for
users on ≤0.3.12 builds.
Also brings CHANGELOG [Unreleased] current for the three merges that
lacked entries: #1016 (mic fix), #1020 (shutdown wait 3s→20s), #1021
(CI flaky-trio root cause + guard).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
test_effects_chain / test_generation_audio_guard / test_persona_bundle
failed intermittently on CI (never locally) with identical signatures
across three unrelated PRs today (#1002, #1019, #1016) — costing a
full CI cycle per occurrence and repeatedly muddying merge decisions.
Root cause, confirmed by local reproduction: a leaked
torch.set_default_dtype(torch.float16) from some earlier test in the
CI-Linux ordering. The smoking gun was test_generation_audio_guard's
observed 0.0999755859375 — exactly float16(0.1), i.e.
torch.tensor([0.1, …]) built under a leaked fp16 default. Reproducing
with a simulated polluter locally produced the trio's exact failures:
Pedalboard refuses fp16 audio outright ("only supports 32-bit and
64-bit floating point") and silently returns unmodified audio for
every preset, so test_effects_chain's preset outputs compare
identical; and the fp16 tensor value breaks the sanitize approx-check.
Fix: an autouse conftest guard (same philosophy as the existing
LLM-state isolation guard, #878) that checks torch's default dtype
after every test, resets any leak to float32, and emits a UserWarning
naming the offending test's nodeid — so the actual CI-only polluter
identifies itself in the next CI log instead of being chased blind.
Regression test: a deliberate-leak pair proving reset-between-tests.
Fail-before/pass-after verified: with the guard stashed, a simulated
polluter + the trio reproduced 2/3 failures locally with the exact CI
signatures; with the guard active, 73/73 pass and the warning names
the polluter.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Root cause of #1013 (macOS: "Microphone access denied" but OmniVoice never
appears in Privacy & Security → Microphone to enable it):
Tauri's macOS bundle config defaults `hardenedRuntime` to true, and Hardened
Runtime blocks camera/microphone hardware access unless the matching
entitlement is present — regardless of Info.plist's NSMicrophoneUsageDescription
(that only supplies the *prompt text*, it isn't itself the grant) and
regardless of wry's own WKUIDelegate already granting the request at the
WebKit/JS layer (WryWebViewUIDelegate::request_media_capture_permission
unconditionally calls WKPermissionDecision::Grant — confirmed by reading
wry 0.55.1's source; that part was never the problem). With Hardened Runtime
on and zero entitlements, TCC never registers a request at all, which is
exactly the reported symptom: nothing to enable because the OS never saw a
legitimately-entitled process ask. This also explains the workaround in
#1013 and its comments (launching the raw binary from Terminal works, but
as Terminal's identity, not the app's) — Terminal is a properly entitled,
hardened-runtime process; the ad-hoc/unentitled app binary isn't.
Adds src-tauri/entitlements.plist (com.apple.security.device.audio-input,
plus com.apple.security.device.camera matching the forward-looking
NSCameraUsageDescription already in Info.plist) and wires it in via
tauri.conf.json's bundle.macOS.entitlements. Also corrects the stale
"nothing to do here" module comment in lib.rs that documented the
incomplete assumption this bug falsified.
Verified: built a debug .app (`tauri build --debug --bundles app`) and
diffed `codesign -dv --entitlements -` before/after this change — the
entitlements dictionary goes from absent to containing exactly the two
keys added here, alongside the runtime (Hardened Runtime) flag that was
already on. `cargo test` — 60 passed, 0 failed.
Greptile's review of the merged #1002 flagged a real residual gap: a
cold transformers import alone can exceed the 3s shutdown wait, and
cancelling the asyncio task doesn't stop the underlying OS thread —
so quitting during an unusually slow preload could still let shutdown
report "done" while that thread was alive, the exact #1000 class with
lower odds. Python cannot forcibly kill a running thread, so no finite
bound eliminates this outright; 20s shrinks the window from "any
preload" to "an unusually slow cold-import," the practical ceiling
before a long shutdown becomes its own complaint. New source-level
contract test pins the production bound at ≥15s so a future edit
can't quietly shrink it back without deliberate consideration.
Also absorbs the one test case from community PR #1015 (superseded by
the earlier-merged #1017, which duplicated it — my fault for not
checking the PR queue) that the merged version lacked: the
design/instruct path with no ref kwargs at all stays untouched by the
ref_text forwarding fix.
Co-authored-by: mergetest <test@local>
Co-authored-by: MahdiHedhli <noreply@github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
WaveformTimeline's play button (disabled={!ready}) stayed permanently
disabled whenever the initial WaveSurfer decode failed and the
component fell back to loading pre-computed peaks. The waveform still
rendered fine from those peaks (nothing looked visibly broken), but
`ready` was only ever flipped by the 'ready' event re-firing on that
recovery load — which this component's own error-handling never
actually confirmed, just assumed. Each of the three fallback ws.load()
calls now explicitly confirms readiness once it settles (via .then()/
.catch(), or the existing synchronous-throw catch), instead of hoping
the event fires again.
Regression test: WaveformTimeline.readyFallback.test.js — a
source-level contract guard (driving a real decode-failure/recovery
sequence through jsdom is brittle, same house pattern as the sibling
WaveformTimeline.unlock.test.js) asserting every fallback load in the
error handler is followed by an explicit setReady(true).
Also removes the "Support OmniVoice" heart button from NavRail — the
donate page stays reachable from Settings' footer and the Contact page.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
buildDesignInstruct() already keeps Studio's design/clone generate
calls (useTTS.js) from round-tripping a 400 "Unsupported instruct
items" — it filters free-text against the active engine's supported
vocabulary client-side, with a toast instead of a failed request. Three
other call sites built their own instruct string directly and skipped
it entirely:
- handleSegmentPreview (Dub tab's per-segment preview) — instruct comes
straight from segment/preset data; a preset's raw attrs merged with a
free-text style field can carry phrases outside the vocabulary.
- handleSaveProfile / handleSaveHistoryAsProfile — both always create a
kind='clone' profile; the backend only sanitizes instruct on save for
kind='design' (see profiles.py's heal_design_instruct branch), so a
clone profile could silently persist an unusable instruct and then
400 every single time it's later used to generate.
All three now filter through the same buildDesignInstruct({}, instruct)
call useTTS.js's own clone path already uses, with the same
unsupported/duplicate-item toasts.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
MLXAudioBackend.generate() reads voice/ref_audio/language/speed from
its kwargs but never extracted ref_text — it was built, then silently
never passed through to self._model.generate(). CSM (sesame.py) only
builds its cloning context when BOTH ref_audio AND ref_text are
present; with ref_text missing, the context list stays empty and
indexing into it raises "IndexError: list index out of range" deep
inside mlx-audio, instead of the clone ever being attempted. Voice
cloning on the CSM engine could never have worked as shipped.
generation.py already threads ref_text all the way through — even
auto-transcribing it via the GPU pool when the caller supplies
ref_audio without one (~line 780) — so the value was always available
in kwargs; it just never survived the crossing into this specific
backend.
Reported with the precise root cause and a working fix (community
member independently diagnosed and patched it locally, confirmed
working on MPS/0.3.12). Two-line fix: extract ref_text and pass it
through when both ref_audio and ref_text are present (guards against
passing an orphaned ref_text with no accompanying audio to engines
that don't expect it).
Tests: tests/test_engines.py — ref_text is passed through when paired
with ref_audio, omitted when ref_audio is absent.
Also documents the second bug from the same report (#1013): macOS
microphone permission never prompts, so OmniVoice never appears in
System Settings to grant access. Root-caused to an unresolved upstream
Tauri/WebKit limitation (WKWebView's requestMediaCapturePermissionFor
delegate — wry#1195, tauri#11951, fix wry#1196 still open/unmerged, no
released version to bump to) — not something fixable here without an
unverified native Rust/WKWebView hack this session has no way to test.
Documented in docs/install/troubleshooting.md with the confirmed
workaround (record elsewhere, upload the file).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Found while triaging Discord: a community member (lehoangan227) hit
"Could not create that voice — the engine may be loading" trying to
use an archetype from the Gallery. That message is hardcoded and
shown for ANY failure — the actual cause (a 500, a validation error,
anything) is caught and discarded.
api/client.js's ApiError already builds a clean, user-facing message
for every failure mode (HTTP status + backend detail, a network
failure, or a detected backend crash) — this codebase's own
established convention elsewhere is to interpolate that message via
`{{message}}` (see BatchQueue.jsx, Settings.jsx, ToolsPage.jsx). The
Gallery's own catch blocks just weren't following it.
Fixed the whole class across VoiceGallery.jsx (use/preview),
CommunityZone.jsx (add-to-voices, whose catch clause didn't even bind
the error), and ImportsZone.jsx (search/upload/save/delete/trim —
handleDelete previously failed completely silently, no message at
all). All now interpolate the real error message, matching the
gallery.download_failed key that already did this correctly a few
lines away.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
extract_speaker_clones/extract_segment_refs pair each audio slice (cut
at ASR segment timestamps) with that segment's own `text` field, on
the assumption the two agree. They routinely don't — Whisper (and
friends) frequently drift on segment boundaries: a trailing word
audible in [start, end] but missing from text, or vice versa. When the
(ref_audio, ref_text) pair disagrees, zero-shot TTS prompt-priming
breaks down and the clone can emit the mismatched reference text
itself instead of the target-language line it was asked to speak —
reported with an exceptionally clear root-cause diagnosis and a
working A/B repro (matched pair: clean on the first try; mismatched
pair: wrong language 6/6 times).
Fix (as proposed in the report): re-transcribe each written reference
clip via the already-loaded, already-warm active ASR backend and use
that transcript as ref_text — this guarantees the pair matches by
construction, independent of whether the original segment text was
ever right. Falls back to the original text on any re-transcribe
failure or empty result — never a regression from current behavior,
only ever a fix.
New services.speaker_clone.refine_ref_text (single clip, unit-testable
against a duck-typed fake ASR backend) and refine_ref_texts (batch —
one executor round-trip per whole clones/seg_clones dict rather than
one per reference). Wired into dub_core.py's two clone-extraction call
sites, routed through _gpu_pool to match the established convention
for ASR-backend calls (the model is mid-lifecycle: TTS is offloaded,
ASR is loaded and exclusive, right where the existing per-chunk
transcribe calls already run on this same pool).
Tests: tests/test_speaker_clone_purity.py — 6 new cases covering the
mismatch-correction path, ASR-failure fallback, empty-transcript
fallback, no-backend no-op, and batch behavior (one failing entry
doesn't affect the others). Full backend suite: 2412 passed, 0 failed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The AppImage bundles whatever libwebkit2gtk-4.1-dev the build runner's
apt repos resolve at build time (see the "Linux system deps" step) —
AppRun's LD_LIBRARY_PATH then makes that bundled copy take priority
over the host's system WebKitGTK at runtime. ubuntu-22.04's version
was stale relative to what current distros (Ubuntu 24.04+, Fedora 44)
ship, which is why a from-source build (linking straight against the
host's healthy system library) worked fine on the exact machine where
the shipped AppImage white-screened — the released binary was running
an older, buggier WebKitGTK under the hood regardless of the host.
Bumped the Linux release matrix entry to ubuntu-24.04, and ci.yml's
Tauri shell-check job to match (its own comment already says "Mirror
release.yml" — now it actually does, so a green PR check accurately
predicts the release build will also succeed).
Raises the AppImage's glibc floor from 2.35 to 2.39 (Ubuntu 24.04+) —
README's system-requirements table corrected from the now-false
"Ubuntu 20.04+" claim. No reports of anyone on a pre-2022 distro.
This does not fix the AppRun launcher's separate, related bug (its
WebKitGTK-version auto-detection reads the *system's* pkg-config
version, not the version actually bundled and running) — that would
need a reliable way to read the bundled .so's version from within the
AppImage, which isn't straightforward (WebKitGTK's soname doesn't map
1:1 to its release version) and isn't verifiable without a real Linux
build environment to test against. Left as a known, separate gap.
Cannot be verified from here on a real Ubuntu 26.04 machine — shipped
on the strength of the root-cause diagnosis, pending the reporter's
confirmation.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
A user-pasted backend log revealed the real cause behind a class of
'can't reach backend' reports: three rapid restart cycles, each ending
with 'Shutdown: done.' immediately followed by a 'Model loading failed:
Could not import module AutoFeatureExtractor' error. That error text is
transformers' own generic lazy-import wrapper (import_utils.py's
_LazyModule.__getattr__), not a real dependency problem — pyproject.toml
already pins transformers/torch/torchaudio/soundfile/librosa as core,
non-optional deps, and the same venv loaded the model successfully 90
seconds later in the same log.
Root cause: preload_task and capture_preload_task were created at
startup but never referenced in the lifespan shutdown block — idle_task
and worker_task got cancelled-and-awaited, the preload tasks were simply
abandoned. Cancelling an asyncio task awaiting run_in_executor() can't
stop the underlying OS thread once it's inside blocking import/load
work, so 'Shutdown: done.' logged while a GPU-pool thread was still
mid-Version: ImageMagick 7.1.2-25 Q16-HDRI aarch64 037e46295:20260604 https://imagemagick.org
Copyright: (C) 1999 ImageMagick Studio LLC
License: https://imagemagick.org/license/
Features: Cipher DPC HDRI Modules
Delegates (built-in): bzlib freetype heic jng jpeg lcms ltdl lzma png tiff webp xml zlib zstd
Compiler: clang (21.0.0)
Usage: import [options ...] [ file ]
Image Settings:
-adjoin join images into a single multi-image file
-border include window border in the output image
-channel type apply option to select image channels
-colorspace type alternate image colorspace
-comment string annotate image with comment
-compress type type of pixel compression when writing the image
-define format:option
define one or more image format options
-density geometry horizontal and vertical density of the image
-depth value image depth
-descend obtain image by descending window hierarchy
-display server X server to contact
-dispose method layer disposal method
-dither method apply error diffusion to image
-delay value display the next image after pausing
-encipher filename convert plain pixels to cipher pixels
-endian type endianness (MSB or LSB) of the image
-encoding type text encoding type
-filter type use this filter when resizing an image
-format "string" output formatted image characteristics
-frame include window manager frame
-gravity direction which direction to gravitate towards
-identify identify the format and characteristics of the image
-interlace type None, Line, Plane, or Partition
-interpolate method pixel color interpolation method
-label string assign a label to an image
-limit type value Area, Disk, Map, or Memory resource limit
-monitor monitor progress
-page geometry size and location of an image canvas
-pause seconds seconds delay between snapshots
-pointsize value font point size
-quality value JPEG/MIFF/PNG compression level
-quiet suppress all warning messages
-regard-warnings pay attention to warning messages
-repage geometry size and location of an image canvas
-respect-parentheses settings remain in effect until parenthesis boundary
-sampling-factor geometry
horizontal and vertical sampling factor
-scene value image scene number
-screen select image from root window
-seed value seed a new sequence of pseudo-random numbers
-set property value set an image property
-silent operate silently, i.e. don't ring any bells
-snaps value number of screen snapshots
-support factor resize support: > 1.0 is blurry, < 1.0 is sharp
-synchronize synchronize image to storage device
-taint declare the image as modified
-transparent-color color
transparent color
-treedepth value color tree depth
-verbose print detailed information about the image
-virtual-pixel method
Constant, Edge, Mirror, or Tile
-window id select window with this id or name
root selects whole screen
Image Operators:
-annotate geometry text
annotate the image with text
-colors value preferred number of colors in the image
-crop geometry preferred size and location of the cropped image
-encipher filename convert plain pixels to cipher pixels
-extent geometry set the image size
-geometry geometry preferred size or location of the image
-help print program options
-monochrome transform image to black and white
-negate replace every pixel with its complementary color
-quantize colorspace reduce colors in this colorspace
-resize geometry resize the image
-rotate degrees apply Paeth rotation to the image
-strip strip image of all profiles and comments
-thumbnail geometry create a thumbnail of the image
-transparent color make this color transparent within the image
-trim trim image edges
-type type image type
Miscellaneous Options:
-debug events display copious debugging information
-help print program options
-list type print a list of supported option arguments
-log format format of debugging information
-version print version information
By default, 'file' is written in the MIFF image format. To
specify a particular image format, precede the filename with an image
format name and a colon (i.e. ps:image) or specify the image type as
the filename suffix (i.e. image.ps). Specify 'file' as '-' for
standard input or output., and interpreter finalization tore down module
state under it — producing exactly this misleading error.
Fix: extract the existing cancel+bounded-await pattern into
_cancel_and_await_tasks() and apply it to all four background tasks, not
just two. An early-stage load (still importing, not yet mid weight-
download) now gets a real chance to finish before shutdown proceeds; a
load genuinely deep in blocking work still times out at the same 3s
bound, and _reset_gpu_pool() abandons it same as before. Also: both
error handlers around this path logged only str(exc), discarding
__cause__ — added exc_info so a future incident (even one this fix
doesn't fully prevent) surfaces the real underlying error instead of the
misleading generic wrapper text.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
First slice of the community's two-track proposal for #877: a generic
OpenAI-compatible ASR backend that works TODAY, without waiting on
transformers to ship a direct Qwen3-ASR integration (tracked separately,
still blocked upstream). Points OmniVoice's transcription at any server
exposing POST /v1/audio/transcriptions — a self-hosted Qwen3-ASR/
FunASR/SenseVoice server, or OpenAI's own API.
- New OpenAICompatASRBackend (backend/services/asr_backend.py): a pure
network client, no local model, no install. Prefers
response_format=verbose_json for real per-segment timestamps,
degrades to plain text (matching MoonshineASRBackend's shape) when a
minimal server rejects that format. Never leaks a raw SDK/httpx
exception to the caller (#977 convention) — wraps network/auth
failures in a clean, actionable RuntimeError naming the server.
- Settings persist via the same encrypted-secret convention as
services/llm_providers.py (settings_store.set_secret for the API key
— Fernet-encrypted, never a .env row, never echoed back; get_text/
set_text for base_url/model). New GET/PUT /api/settings/
asr-openai-compat, loopback-gated like every other settings route.
- Frontend: a small settings panel (Settings → Models) mirroring
HFMirrorPanel's exact structure. No ASR engine picker exists yet for
ANY ASR backend (only TTS has one) — activating this engine still
needs OMNIVOICE_ASR_BACKEND=openai-compat-asr; documented plainly
rather than pretending otherwise.
- README's ASR Engines table (9 → 10 engines) and docs/features.yaml's
drift-checker inventory updated; the '9 engines, all fully local'
claim corrected since this one genuinely isn't.
- docs/engines/openai-compatible-asr.md: setup steps + an explicit
privacy note (unlike every other ASR engine, audio leaves the
machine to whatever server is configured).
Regression tests: tests/test_asr_openai_compat_877.py (12 tests) —
is_available() gating, verbose_json + plain-text response adaptation,
network-failure error hygiene, SDK retry disabling, and the settings
endpoints' persist/mask/clear-vs-unchanged semantics.
Fixed two real full-suite-only failures found during verification (not
brushed aside): the API route inventory snapshot needed regenerating
for the two new routes, and this file's own tests had a module-
staleness bug — a collection-time settings_store import went stale
relative to a test-time-fresh fixture when another test elsewhere in
the ~2400-test suite reimports the module — fixed by making
settings_store itself a fixture resolved at test-run time, same
lesson already applied to tests/test_mm2_lifecycle.py earlier this
session.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
19-issue GitHub sweep: 16 PRs merged since v0.3.11, most fixing reports
filed same-day or in the days prior. Highlights: MLX-Audio's 6 other
curated models are finally selectable (was silently stuck on Kokoro
regardless of what was downloaded), first-run no longer dead-ends behind
restricted networks or corporate TLS-inspecting proxies, dubbing/batch
TTS honor your active engine selection, and a run of sharp community
diagnoses (ROCm wheel index, Windows dictation focus-steal, a genuine
frontend crash regression) got fixed largely because reporters did the
hard diagnostic work themselves.
Full backend suite: 2390 passed, 0 failed. Full frontend suite: 918
passed, 0 failed. Version lockstep (tests/test_app_version.py): 6/6
passed. Docker frozen-lockfile parity (bun install --frozen-lockfile):
clean, no drift.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The guard itself (#851/#896) is working as designed — this is a message
gap, not a logic bug. The GPU-branch timeout guidance explained VRAM
contention but never mentioned the Flush/Unload action that actually
resolves it, even though: (a) that action already exists (POST
/system/flush-memory, wired to the header's Flush button), and (b) the
sibling ASR-timeout guard's message already recommends it verbatim
(asr_backend.py's _CUDA_VRAM_BUDGET_GB guidance). The maintainer ended up
manually explaining 'Settings → Models → Flush caches / Unload' in an
issue thread reply — information the error message should have carried
itself.
String-only change, no control-flow touched, mirrors the exact precedent
of #896 (a guidance-only change to this same function).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
On some Windows configurations, Tauri's custom-protocol IPC probe fails
once at startup and Tauri logs a console.warn before silently — and
successfully — falling back to postMessage + WebSocket. Fully functional,
happens at most once per launch, and not a bug in our code (confirmed:
this is Tauri's own internal fallback mechanism, structurally intentional
across its recent 2.11.x releases, not something being actively patched
upstream — so not bumping the framework speculatively for this).
It IS real noise though: as a captured console.warn it spuriously flips
the Settings > Logs footer's Frontend pill to "1 warning" on every
affected Windows launch. Filtered at the capture source (consoleBuffer.js)
rather than the display layer, so it never enters the ring buffer or a
copied diagnostic dump either — narrowly scoped to this one known message
prefix, not a general warning-suppression mechanism.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The preflight system check already warns in-app when yt-dlp is missing
(Voice Gallery/Dub YouTube downloads fail without it), but the install
docs never mentioned it — a user has to hit the in-app warning first
instead of seeing it up front alongside the other optional prereqs.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Root-caused and verified against the actual code (community diagnosis in
#982 was accurate): showing the dictation pill — an always-on-top top-level
WebviewWindow — grants it Win32 foreground activation by default on
Windows, which macOS doesn't do for a shown window. Nothing marked the pill
non-activating, so on Windows the synthesized Ctrl+V from `simulate_paste`
landed back in the pill instead of the app being dictated into. Code review
also found a second, more direct culprit at the same call sites: the
global-shortcut Pressed handler's `win.set_focus()` was only skipped on
macOS (`#[cfg(not(target_os = "macos"))]`), so Windows explicitly focused
the pill on top of the implicit Win32 activation; the tray "dictate" menu
handler called `win.show(); win.set_focus();` unconditionally on every
platform, including Windows.
This is the Windows counterpart of #287 (macOS auto-paste — don't steal
focus): same class of bug, same "pill must stay unfocused so the paste
lands in the target app" intent (already stated in the `grant_webview_
media_permissions` module comment), different OS mechanism.
Fix, mirroring #287's platform-cfg pattern:
1. WS_EX_NOACTIVATE applied to the pill's HWND once, right after creation
(`mark_pill_noactivate`), via the `windows` crate pinned to the exact
0.61.x tauri itself already resolves to — Cargo.lock confirms this
unifies to the same `windows 0.61.3` already in the graph, so
`WebviewWindow::hwnd()`'s HWND and our Win32 calls share one type and
no new crate version was added.
2. `ShowWindow(SW_SHOWNOACTIVATE)` (`show_pill_noactivate`) in place of
`.show()` at the two dictation-trigger call sites (global shortcut +
tray "Start Dictation"), since `.show()` alone still raced the style
bit on some paths.
3. The explicit `set_focus()` calls at those same two sites are now
skipped on Windows too, the same way they already were on macOS.
macOS and Linux are untouched: the macOS cfg branch is unchanged, and the
Linux branch of the `set_focus()` guard still runs exactly as before.
The pill's auto-dismiss (`scheduleDismiss`/`dismiss` in CaptureWidget.jsx)
was checked and is a plain unconditional setTimeout chain — it is not
gated on any native focus-loss/paste-completion signal, so there's no
independent bug to fix there. The "never dismisses" symptom is a
consequence of the focus-steal, not a separate stall: once the pill wrongly
held foreground for the whole session, hiding it later left Windows'
foreground state inconsistent. With the pill never taking focus, the
target app stays foreground throughout and there's nothing to reconcile.
Win32 window-activation syscalls (`#[cfg(target_os = "windows")]`) can't
run under `cargo test`/`cargo build` on this non-Windows CI runner, so the
new `pill_noactivate_tests` module tests the pure flag math
(`with_noactivate_style`) instead — platform-agnostic, runs everywhere,
verified passing here. The actual HWND-touching code is logic-reviewed but
UNVERIFIED on real Windows; the reporter offered to test a patched build,
which is the recommended next step before this ships in a release.
`cargo build` and `cargo test --lib` both pass (60/60 tests, including the
3 new ones); the pre-existing `setup.rs` unreachable_code warning (#286) is
unrelated.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Crash: DesignMethodPanel's optLabel() called val.replace(...) on an
undefined category value — a regression from f678e33, which swapped a
safe plain template literal for an i18n lookup that assumes vdStates[key]
is always a string. Both occurrences (the label kicker and the chip
list) are now guarded, falling back to 'Auto' the same way the rest of
the component treats an unset category.
Root cause: vdStates could actually go partial in real usage. Selecting
a design profile (useProfiles.js) or restoring legacy localStorage state
(useAppData.js) applied the backend/stored vd_states object as-is, with
no check that all 6 CATEGORIES keys were present — so an older client,
hand-edited payload, or partial API write reproduced the crash on
selection. Both call sites now run the restored object through
mergeDescribedAttrs() (voiceInstruct.js), the existing completion helper
already used for the "describe your voice" path, which fills any
missing/unknown category with 'Auto'. useAppData.js also gained the
typeof === 'object' guard useProfiles.js already had.
Closes the class at the source: POST /profiles now completes vd_states
against CATEGORY_ORDER (core/describe_voice.py, the same list the
frontend's CATEGORIES mirrors) before persisting, so a design profile
can never be *saved* with an incomplete shape regardless of which
client wrote it — updated two existing tests whose fixtures asserted
the old (partial) persisted shape.
Regression tests: DesignMethodPanel render test with a partial vdStates
input, a mergeDescribedAttrs unit test for the exact partial shape from
the issue, and a backend test asserting POST /profiles fills all 6 keys.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
mlx-audio multiplexes 7+ curated models (Kokoro, CSM, Qwen3-TTS, Dia,
Chatterbox, MeloTTS, OuteTTS) behind a single "mlx-audio" backend id, but
MLXAudioBackend resolved its active model ONLY from the
OMNIVOICE_MLX_AUDIO_MODEL env var — invisible to Settings and unreachable
without restarting the packaged app with that var set. A user who
downloaded e.g. Llama-OuteTTS via Settings → Models had no way anywhere
in the UI or API to actually load it; the backend silently kept using
Kokoro.
Fix:
- MLXAudioBackend.__init__ now resolves its model via
prefs.resolve("mlx_audio_model_id", env=..., default=...), mirroring
active_backend_id()'s env > prefs > default order exactly.
- get_active_tts_backend()'s switch-detection now also tracks the
resolved mlx-audio model key, so a model-only change (same backend id)
invalidates the cached instance and reconstructs it — no app restart
needed to pick up a different curated model.
- POST /engines/select gained an optional model_id field; for
family=tts/backend_id=mlx-audio it validates against
MLXAudioBackend.CURATED_MODELS (or a raw HF repo id, matching the
class's existing tolerance) and persists it via prefs.
- GET /engines now includes a curated_models roster + active_model_id on
the mlx-audio entry only.
- Settings → Engines renders a small model dropdown on the mlx-audio row,
pre-selected to the active model, wired through selectEngine's new
optional modelId argument.
Regression coverage: prefs resolution + env override, cache invalidation
on model-only switch, /engines/select 400s on an unknown model id and
persists a valid one, curated_models present only on mlx-audio, and a
new EngineCompatibilityMatrix vitest suite for the dropdown.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Windows users behind a corporate/antivirus TLS-inspecting proxy got a raw
`[SSL: SSLV3_ALERT_HANDSHAKE_FAILURE]` on every model install — the TCP
connection reaches the server fine, but the handshake fails because the
OS trusts the proxy's re-signed root CA and Python's bundled certifi CA
list doesn't. A genuinely different failure mode from #984 (that was
TCP-level unreachability to a blocked host, before any TLS negotiation).
- backend/core/failure.py: new SSL_HANDSHAKE_FAILURE classification
(handshake/cert-verify-failed/sslv3_alert/sslcertverificationerror
substring markers) with an actionable hint, added to
_CONTEXT_FREE_HINT_CLASSES so append_hint() (already called by
setup/download.py's install worker) surfaces it without further wiring.
- backend/main.py: truststore.inject_into_ssl() at module level, before
any huggingface_hub/requests/httpx network I/O — patches ssl.SSLContext
to verify against the OS trust store instead of only certifi's bundled
CA list. Not platform-gated (correctness improvement everywhere);
wrapped in try/except so it never blocks startup.
- pyproject.toml/uv.lock: truststore>=0.9 — pure Python, MIT, PyPA-
maintained, zero transitive deps, same class of fix as socksio.
Verified: uv lock --check + uv sync --frozen clean (lockfile diff is
just the one new package); main.py imports cleanly; full backend suite
passes; no hiddenimports entry needed (main.py is PyInstaller's direct
entry script per backend.spec, so a top-level import traces normally —
unlike socksio's case, which was httpx's internal lazy import).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Root cause: MLXAudioBackend.generate() blindly truncated the full
language display name to two characters (language[:2].lower()),
assuming an ISO code — 'Dutch' -> 'du', which crashed Kokoro's vendored
pipeline's internal assertion (assert lang_code in LANG_CODES, (lang_code,
LANG_CODES)) for any language whose first two letters didn't coincidentally
match one of Kokoro's single-letter codes. The raw AssertionError's
tuple-containing-a-dict args then leaked straight into the user-facing
500 message via two stacked f"...{e}" formatters in generation.py.
- resolve_kokoro_lang_code() resolves against the AUTHORITATIVE
ALIASES/LANG_CODES table read from the installed mlx_audio package
(never a hardcoded guess), and only applies when Kokoro is the actual
active curated model — other curated models (CSM, Dia, Qwen3-TTS,
OuteTTS, ...) either ignore the kwarg or expect a different format, so
Kokoro's strict validation doesn't wrongly reject them. Unsupported
languages now raise a clear ValueError naming what Kokoro supports,
which generation.py already converts to a clean 400.
- _safe_exc_text() hardens both generic exception formatters in
generation.py: if any element of an exception's .args is a container
(dict/list/tuple/set), never interpolate str(e) raw — name the
exception type and point at the log instead. Protects every current
and future engine's generate() from leaking a raw container repr, not
just this one Kokoro assertion.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The Engines page told users to run `pip install nemo_toolkit[asr]` for
the NeMo Parakeet ASR engine. nemo_toolkit[asr]==2.7.3 hard-pins
transformers>=4.57,<4.58, which is unsatisfiable alongside OmniVoice's
own transformers>=5.3 requirement (needed by
omnivoice/models/omnivoice.py for HiggsAudioV2TokenizerModel). A user
who followed the hint ended up with a backend that wouldn't start
(ImportError: cannot import name 'HiggsAudioV2TokenizerModel').
_INSTALL_HINTS["nemo-parakeet"] in backend/services/asr_backend.py now
states plainly that installing into the shared venv will break the
backend, names the transformers conflict, and tells users to use a
separate/dedicated Python environment instead — without implying a
safe one-line fix or an isolated-venv env var exists (unlike
dots-tts/moss-tts-v15/confucius4-tts, nemo-parakeet has no isolated
venv option yet; that's a separate, larger follow-up).
Also adds one sentence to docs/install/troubleshooting.md's existing
"engine venv clash" section (#11) pointing at the same class of issue
on the ASR side, and a regression test asserting the hint never again
contains the literal bare `pip install nemo_toolkit[asr]` string.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
README's Quickstart badges linked a 'Download Debian .deb' button
straight to the releases page — but .deb bundling was deliberately
dropped from release.yml (tauri-cli bug, 'Failed to create control
scripts') and no release has ever shipped one. A community member
investigating #961 confirmed this by checking the actual release
assets. Users clicking that badge got a broken promise, not a package.
Removed the badge; docs/install/linux.md's '## Install (.deb)' section
now honestly states it's unavailable pending a tauri-cli fix, points to
the AppImage as the supported path, and keeps the historical pre-v0.3
.deb upgrade note (ffprobe conflict) since that's still relevant to
existing installs.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Issue #980 reported a raw 400 for Hebrew text ('שמואל') typed into the
Clone tab's Style field. Investigated: this is the same failure class
as #612 (Vietnamese free-text) and was already fixed when #612 landed
in commit 10b9d69 (first released v0.3.8) — buildDesignInstruct() drops
any unsupported free-text client-side before it ever reaches the
backend's validator, regardless of script. The reporter was on v0.3.7,
which predates that fix.
No behavior change needed — only a regression test, since the existing
Vietnamese test case covered Latin-script-with-diacritics but nothing
exercised a right-to-left / non-Latin script specifically.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Community-diagnosed (issue #972, Kaihui-AMD): pyproject.toml pins
torch==2.8.0, but the rocm6.2 wheel index only ever published up to
2.5.1 — the reinstall silently failed to resolve and fell back to the
default CUDA build, which runs on CPU on an AMD GPU. The failure was
correctly logged (bootstrap.rs's emit_log warning), just never actioned
because the index itself couldn't succeed. rocm6.4 carries a matching
torch==2.8.0 build.
Docs updated with the corrected index plus a repo.radeon.com find-links
path for users who want a driver-matched ROCm 7.2.x build the PyTorch
index doesn't carry (OMNIVOICE_TORCH_INDEX only accepts a PEP 503 index,
not find-links, so that's documented as a manual step).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(engines): dub and batch TTS honor the active-engine selection — with a real capability gate, not a silent OmniVoice fallback
Dub generation and batch TTS hardcoded services.model_manager.get_model()
(OmniVoice) regardless of the engine picked in Settings → Engines. A user
selecting VoxCPM2 (or any other engine) still got OmniVoice output with no
error — the silent fallback IS the bug class, not just the one report.
Root-caused and fixed for the whole class:
- New `TTSBackend.supports_cloning` capability flag (default True) marks
engines that can only offer fixed preset voices — kittentts,
supertonic3, sherpa-onnx set it False. MLXAudioBackend exposes it as an
instance property (Kokoro doesn't clone, CSM does) since the adapter
multiplexes multiple models with different capabilities.
- `cloning_capable_engine_ids()` and a shared `resolve_generation_backend()`
helper in services/tts_backend.py centralize engine resolution
(id → is_available() → routing gate → optional cloning gate), mirroring
generation.py's /generate resolution instead of inventing a third
parallel mechanism. Both routers now standardize on the existing
get_active_tts_backend() cache (unload-on-switch already handled).
- dub_generate.py's two TTS-generate call sites (main run + OOM retry) and
the /dub/preview-segment route resolve once, up front, with
require_cloning=True — dub's ref_audio is populated for essentially
every real job, so an engine that can't clone fails the whole job with
one actionable message instead of mis-cloning per segment.
- batch.py resolves once per job, require_cloning only when voice_id is
pinned — an unpinned batch job runs fine on any engine.
- Applied the three pre-existing TODO(#312) comments: mastering now skips
via `applies_own_mastering` for both pipelines, matching generation.py.
Regression tests cover the capability-id list, the fail-fast gate (proving
no OmniVoice fallback), the success path on a selected non-OmniVoice
engine, batch's pinned-vs-unpinned voice_id behavior, and the mastering
skip for both pipelines. Three existing dub tests that mocked get_model()
directly were updated to mock the new resolver instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(engines): exclude model-dependent adapters from cloning_capable_engine_ids()
getattr(cls, "supports_cloning", True) at the CLASS level returns a
property descriptor object (always truthy) when the flag is an instance
@property, not a plain attribute — MLXAudioBackend uses exactly this
pattern because its cloning capability depends on which of its 7+ curated
models is loaded (only CSM clones; Kokoro etc. don't). Without this fix,
the dub/batch capability-gate error message would always recommend
'switch to mlx-audio' even when the user's configured MLX model can't
clone, sending them in a circle back to the same error.
isinstance(value, bool) distinguishes a resolved boolean from a
descriptor object, so mlx-audio is excluded from the suggestion list
until its actual per-instance capability can be checked (already handled
correctly by resolve_generation_backend()'s per-call instance check).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): engine-aware dub/batch entry (#987)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* fix(ui): loaded-models panel says when a resident model is not the active engine (#TBD)
A switched-away TTS model stays resident in VRAM until Unload/Flush or the
idle evictor, so users who picked another engine (e.g. voxcpm2) saw
"OmniVoice TTS - cuda:0 - 1937 MB" in the LOADED MODELS flyout and concluded
synthesis was still routing to OmniVoice. It wasn't - the panel just gave no
hint that resident != active.
/model/loaded entries for TTS-family models (in-process OmniVoice +
subprocess sidecars) now carry engine_id + is_active_engine, computed against
active_backend_id(); attribution failure degrades to the old shape
(is_active_engine: null) and non-TTS entries (ASR, diarization) are left
unannotated. The flyout renders a muted "not active - safe to unload" tag
(i18n: header.model_not_active, en + zh-CN) on inactive entries; Unload/Flush
behavior is unchanged. Regression tests cover both attribution states, the
ASR non-label, and the degradation path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): loaded-models active-engine hint entry (#985)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(mm2): string-target monkeypatch for active_backend_id — immune to sys.modules reimports
The two attribution tests patched the collection-time module alias; other
suites pop+reimport services.* modules mid-run, so in full-suite order the
patch landed on a stale module object while _active_tts_id late-imported the
fresh one (CI-only failure). String targets resolve at patch time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(audio): remove hidden reverb from the mastering pre-stage — reverb is preset-declared only (#TBD)
Field report (Discord): baked-in echo/reverb on some voices. apply_mastering()
hardcoded a Reverb that ran on every non-raw synthesis before the user's
preset chain — broadcast shipped reverb it never declared, podcast broke its
"no reverb" promise, cinematic/warm got doubled reverb.
The mastering pre-stage is now data-driven (MASTERING_CHAIN: highpass +
compressor, same params as before) and reverb-free; cinematic/warm keep their
user-chosen reverb. Regression tests pin the contract, incl. a burst-then-
silence echo-tail check and pedalboard-missing passthrough.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): hidden mastering reverb entry (#986)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(setup): first-run network check is mirror-aware and never hard-blocks
Field report (Discord, China): the Launchpad preflight probed hardcoded
huggingface.co:443 and any failure disabled Continue outright — users behind
the GFW were stuck on the very first screen, before Settings (and its
HF mirror quick-pick) was even reachable.
- The probe now targets the HF endpoint actually in effect (HF_ENDPOINT /
hf_endpoint pref via configured_hf_mirror), with the real port.
- An unreachable endpoint is a WARNING, not a blocker: local-first — cached
models work offline, and downloads surface their own actionable errors.
- When huggingface.co is blocked but hf-mirror.com answers, the fix text says
exactly that, and the wizard shows an inline mirror quick-pick (presets +
custom URL) that applies via PUT /hf-mirror — effective immediately for
downloads — then re-checks.
- Docs updated (downloading-models, install troubleshooting); regression
tests cover warn-not-fail, mirror-host probing, and the mirror suggestion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): open [Unreleased] with the preflight mirror fix (#984)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
package.json + three mirrors -> 0.3.11 in lockstep; Cargo.lock/uv.lock/
bun.lock regenerated; CHANGELOG [Unreleased] -> [0.3.11] — 2026-07-05
with the multi-language-release headline; nine entries since v0.3.10.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(diagnostics): backend crashes become self-documenting — exit code + stderr tail surfaced and attached to bug reports
When the backend PROCESS died (native CUDA abort, OOM kill, DLL crash) the
user saw only "Can't reach the local OmniVoice backend" and the evidence
died with the process — every #941-class report needed a logs-please
round-trip nobody answers. The v0.3.9 guard fixed HANGS; this fixes the
class of invisible DEATHS:
- Rust (crash.rs): every unexpected child exit — detected by the startup
health poll and the post-Ready supervisor — writes a rotating (last 3)
JSON crash marker next to the backend logs: ts, exit code/signal,
backend version, uptime, ~40-line stderr tail. Intentional shutdowns
never forensicate: app-quit raises the quitting flag first (now also on
macOS Cmd+Q via ExitRequested), and retry/clean-retry kills set a
BACKEND_KILL_INTENDED flag cleared when the fresh child is tracked.
- Tauri commands get_last_backend_crash / acknowledge_backend_crash;
ack is a persisted watermark, never a delete — bug reports still get
the evidence after the user viewed it.
- Crash-loop escalation: the supervisor budget goes 5-in-60s → 3-in-10min
so slow crash loops stop respawning and land on the Failed screen with
the last exit code + stderr tail.
- Frontend: apiFetch's transport-failure path swaps the vague message for
"the backend crashed (exit code X) N s ago…" when an unacknowledged
marker exists, and BackendCrashNotice (banner + details dialog,
i18n'd, ack-on-view) surfaces it even with no request in flight.
- Bug-report prefill gains a "Last backend crash" section (exit code +
home-path-scrubbed stderr tail via the existing scrubText), so the next
report arrives WITH the evidence.
Tests: cargo --lib 57 pass (marker rotation write-4-keep-3, ack
semantics, store IO, ExitStatus decomposition, 3-in-10min policy);
vitest 909 pass incl. crash-notice branch, client crash-message branch,
bug-report enrichment; legacy node:test 41 pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add backend crash forensics under [Unreleased] (#969)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): timeline box colors pre-blended in JS — visible on any WebView2, color-mix dependency removed (#963)
#951 moved the segment-box palette to `color-mix(in srgb, tint 45%,
var(--chrome-bg))` strings applied as inline styles. WebView2/Chromium
< 111 has no color-mix, so the CSSOM rejects the whole `background`
assignment — and since .seg-track__box declares no background of its
own, the boxes rendered fully transparent on pinned/enterprise WebView2
runtimes (the Windows installer never enforces a minimum runtime).
Fix the class, not the instance: no engine-dependent CSS may reach this
lane's inline styles. The 0.45·tint + 0.55·bg blend now happens in JS —
timeline.js keeps the tints as numeric [r,g,b], reads --chrome-bg off
the document root (fallback #0f1011), and emits literal `rgb(r, g, b)`
strings every engine parses. Pixel-identical to what color-mix painted.
Theme-awareness is preserved by re-blending when [data-theme] changes
on <html> (the seam App.jsx switches themes through), observed via
MutationObserver; SegmentTrack subscribes with useSyncExternalStore so
mounted boxes recolor live.
Guards updated: palette entries must match plain opaque rgb() (no
color-mix/var()/alpha), the default-theme blend is asserted against
independently computed literals, theme-change re-blend and rgb()/
garbage --chrome-bg parsing are covered, and SegmentTrack's rendered
inline background is asserted to be a literal rgb() — fails on any
reintroduction of engine-dependent CSS in this lane.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add WebView2 box-color fix under [Unreleased] (#968)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(dub): dialogue starts stop snapping to footsteps — sustained-energy onsets, bounded snap distance (#963)
Field report #963 (point 3): dubbed speakers start seconds early or late.
The reporter's own theory was right on the money — 'when a noise is heard
(a sigh or footsteps), it's interpreted as the start of the conversation.'
The #280 onset snapper took the FIRST 20 ms frame above an adaptive RMS
threshold as the speech onset, so any transient qualified; it also had no
snap-distance bound (a wrong onset could move a start by the whole segment
minus 0.3 s) and ran even when Demucs had failed and the 'vocals' track was
really the raw mix, where every ambient sound is a candidate.
Three layered guards, all pure NumPy (no new deps):
- Sustained energy: an onset only counts when >=160 ms of the following
300 ms stays above the threshold. Footsteps/door thuds light up one or
two frames and die; syllables keep the energy up.
- Bounded snap distance: shifts beyond 1.5 s are only trusted when the
skipped span is (near-)silent — that is exactly the genuine #280
whisper start-stretch on the vocals track (Demucs removed the music,
leaving real silence), so long trims over silence still work in full.
Long jumps over audible content (e.g. quiet speech under the relative
threshold) are refused instead of playing the dub seconds late; an
isolated transient in the span (<10% audible frames) doesn't block it.
- Source-aware: snapping now runs only on the separated vocals track.
dub_core detects the Demucs fallback (vocals_path == audio_path, see
dub_pipeline) at both call sites and passes separated_vocals=False on
mixed audio, disabling snapping — whisper's own timestamps beat a
confidently wrong snap when music/ambience is sustained energy too.
Tests (tests/test_onset_align.py, fail-before/pass-after): transient burst
rejected at detect- and snap-level, transient-only window yields no onset,
long jump over audible content refused, bounded shift over audible lead
still allowed, >1.5 s trim over true silence still snaps (#280 regression
guard), mixed-audio mode is a no-op. 28 pass in the file; full dub-adjacent
suites green.
Credit: theory and repro description by the #963 reporter.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add onset-snap robustness under [Unreleased] (#967)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(net): SOCKS-proxy users can synthesize again — ship socksio, cache-first model resolution, degrade LLM clients (#959)
Under ALL_PROXY/HTTPS_PROXY=socks5:// without socksio installed, httpx
raises ImportError AT CLIENT CONSTRUCTION ("Using SOCKS proxy, but the
'socksio' package is not installed"). huggingface_hub's get_session()
builds exactly that client inside snapshot_download, so POST /generate
500'd with the bare message even for a fully installed model, and
preload_model's model_info probe hit the same error and silently
skipped warm-up. Latent since v0.3.5 — #947's fresh-process engine
spawning unmasked it in v0.3.10 by handing the user's proxy env
directly to a clean backend process.
Three layers, so the class (any session-construction failure) is dead,
not just the reported instance:
* Ship SOCKS support: socksio>=1.0 in [project] dependencies (pure
Python, MIT, zero transitive deps) AND in backend.spec hiddenimports
— httpx imports it lazily in try/except, so PyInstaller's tracer
misses it and the frozen installers would stay broken without the
explicit entry. uv.lock regenerated; `uv lock --check` and
`uv sync --frozen` (the Docker/release bootstrap semantics) verified.
* Cache-first model resolution: from_pretrained's snapshot resolution
extracted into _resolve_snapshot_dir() — local dir, else
snapshot_download(local_files_only=True) (a complete cache resolves
with NO HTTP session constructed), else the original network path.
preload_model's failed network probe now falls back to a cache-only
check and warms up anyway instead of silently skipping (honest log
either way).
* Class guards: resolve_skill_client wraps OpenAI() construction —
env-shaped construction failures degrade to the existing "LLM
unavailable" contract instead of 500ing the calling feature; and
core.failure learns SOCKS_PROXY_SUPPORT_MISSING with an actionable
hint, appended on the raw-string surfaces (global 500 handler,
model-install SSE) via the new append_hint().
Fail-before/pass-after verified by reverting the fix: 11 of the 12 new
tests fail pre-fix (the remaining one is the unchanged network-fallback
contract). 165 tests green across the touched suites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add SOCKS-proxy resilience under [Unreleased] (#966)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): a saved LLM provider survives restart — explicit save activates, stale TRANSLATE_* prefs stop hijacking (#963)
"Ollama works until I restart OmniVoice" had three stacked causes:
1. Only "Save & use for translation" persisted the selection. Plain
"Save" and "Test" sent make_active:false, and on restart
active_provider_id() deliberately excludes local providers
(Ollama/LM Studio) from auto-select — so a saved-and-tested Ollama
was never resolved active again. The PUT handler now also claims the
active slot on an explicit save when the user has never chosen a
provider (new llm_providers.stored_active_provider_id(): the stored
row only — no env pin, no legacy fallback, no auto-detect). An
explicit prior choice is never stolen; an unconfigured provider
can't claim the slot; make_active:true still flips.
2. Users of the retired (≤v0.3.7) Translation-LLM panel had
env.TRANSLATE_* rows in prefs.json, re-imported into os.environ
every launch — and a live TRANSLATE_BASE_URL resolves the active
provider to "custom" ahead of auto-select on every restart. New
startup migration (llm_providers.migrate_legacy_translate_prefs,
run in main.py BEFORE the prefs→env import) moves those values into
the custom provider's own settings-store rows (only where the store
has no value yet) and deletes the prefs rows. Real process env vars
are never touched; a failed store write keeps the prefs row and
retries next boot. The legacy endpoint keeps working — via the
store, without hijacking the active slot.
3. The panel read as "done" after a green Test even when another
provider stayed active. It now shows a notice after save/Test when
the edited provider is not the effective active one (suppressed
while LLM_DEFAULT_PROVIDER pins the choice — the env banner already
covers that).
Tests (fail-before): 7 new backend tests fail on the old code
(save-activates, never-steals, migration semantics, env untouched,
end-to-end ollama-beats-legacy-env) and the new panel test fails
without the notice; all pass after. Full LLM/settings suites, frontend
vitest (890), typecheck:ci, oxlint, oxfmt and vite build are green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add LLM-provider persistence fix under [Unreleased] (#965)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(dub): per-language translations + per-track caches — switching languages stops destroying work (P1)
Multi-language dubbing translated per language (#957) but stored everything
in single-slot state, so tracks silently destroyed each other's work:
P1.2 — per-language translation storage (additive):
- Frontend keeps every translation in s.translations[langCode] alongside the
legacy s.text slot (still = the shown language). Translate All writes both;
the new store action switchDubLangCode swaps text through the map on a
user-driven language switch (non-destructive; restore paths keep the plain
setter); manual edits / restore-original update the current language's
entry; merge joins per-language texts, split drops them. Rides project
save/load inside dubSegments — legacy projects behave exactly as before.
- Backend mirrors it as job["segments_i18n"] = {lang: {segKey: text}}
(segKey = stable id, index for id-less legacy rows), written by
_sync_job_segments; job["segments"] stays byte-identical for every existing
consumer. /dub/srt|vtt?lang= and subtitle burn-in now emit THAT language's
text when present — ExportModal's "all dubs" batch stops producing N
identical files. Legacy jobs without the field fall back to today's output.
P1.3 — per-track WAV cache + fingerprints:
- Per-segment WAVs are language-keyed (seg_{lang}_{id}.wav). The partial-regen
read path falls back to legacy seg_{id}.wav ONLY while the job has no
other-language track — single-language jobs keep their whole on-disk cache;
multi-track jobs stop splicing the last-generated language into the current
track. Read-only endpoints (segment preview, clips zip) gained ?lang= with
the permissive legacy fallback they always had.
- Fingerprints include the track language (segment_fingerprint(track_lang=…),
/tools/incremental lang=…) and live in job["seg_hashes_by_lang"]; the flat
job["seg_hashes"] stays as the current track's mirror so the done event,
history restore and older frontends read it unchanged. A legacy flat map is
attributed to the job's last-generated language (dropped when unknown) and
reads stale once — the safe direction. seg_wav_kind is per-track too.
- The frontend stores fingerprints per language and judges "Regen N changed"
against the ACTIVE track; project save/load and dub-history restore carry
all tracks' hashes (segHashesByLang / seg_hashes_by_lang, additive).
Tests: fail-before regression coverage — two-track regen never splices the
other language's audio (sample-level assert on the mixed track), legacy
single-track cache reuse + multi-track gate, per-lang seg_hashes with flat
mirror + migration semantics, /dub/srt|vtt?lang= emitting different text per
track with legacy fallbacks, per-lang burn-in, /tools/incremental lang
scoping, and 14 frontend tests for translations round-trips, per-track
fingerprints and legacy-project behaviour. Full backend + frontend suites,
typecheck, lint and format:check green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add per-language storage + per-track caches under [Unreleased] (#958)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(dub): multi-language generate translates each language first + picks persist with the project
P1.1 — the "Generate N dubs" loop never translated: the backend synthesizes
segment text verbatim, so every multi-language track rendered the same words
and at most one was actually in its language. The loop now runs
translate → generate per pick:
- handleTranslateAll(langOverride?) accepts an optional ISO-code override
(no-arg Translate All behavior unchanged; a click-event first arg is
guarded). It resolves true only when a translation actually landed, and
both it and handleDubGenerate snapshot segments from the store at call
time — the click-time closures went stale the moment the previous pick's
translate pass rewrote the segments.
- A pick whose translate fails (request error or all-segments-errored) is
SKIPPED — never a wrong-language track — the batch continues, and the
skipped languages are reported in a final toast.
- The redundant first translate is skipped only when pick 1 targets the
language the editor text is already translated into; every later pick
always translates.
- Honest progress: the pill shows "Translating → {lang} (i/N)…" before each
generate, and the header CTA is inert while translating so a re-click
can't start a second batch (belt: a ref guard in the loop).
P1.4 — multiLangMode/multiLangs move from DubTab component state into the
dub store slice and ride the project save/load payload (exportTracks too).
Additive and back-compat: legacy payloads default to off/empty and leave the
in-session exportTracks untouched (utils/projectState.js).
Tests (fail-before verified: 9 failures on the pre-fix code):
- handleTranslateAll override targets + return semantics + call-time
segment snapshot (dubTranslateAllOverride.test.jsx)
- per-language translate-before-generate call order, skip-on-failure with
continuation + skip-report toast, first-pick skip heuristic, unchanged
single-language path (dubMultiLangGenerate.test.jsx)
- slice defaults/setters/reset, payload round-trip, legacy-payload defaults,
App.jsx wiring guards (dubMultiLangPersist.test.js)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add multi-lang auto-translate under [Unreleased] (#957)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
#956 merged with a red Tests gate — my merge script ran unconditionally
instead of aborting on the gate value; the failure was oxfmt-only on the
two new test files. Whitespace-only fix, tests re-verified green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dub): completed tracks always show their tabs + history keeps its language (P0)
Root cause chain: the track switcher's visibility expression required
dubLangCode !== 'und' and ended in a tautology (dubTracks?.length > 0 ||
!!dubTracks), so it was effectively keyed to the language dropdown, not
the persisted tracks. History restore always handed the frontend 'und'
because the dub_history language/language_code COLUMNS froze at the
ingest-time "" — the save_job UPSERT never updated them after generation
set them on the job dict (only the job_data JSON carried the real value).
Net effect: a restored project with finished tracks showed no track tabs
until the user re-picked a language.
- DubTab: hasDubbedTrack = done && dubTracks.length > 0 (tracks only;
also stops the tautology from showing a trackless switcher).
- DubTab auto-jump: membership-guarded — the preview only jumps to a
language that has a track, else tracks[0]. Kills the preview-404 class
(restores falling back to 'en' with tracks ['bn'] pointed the player
at /dub/preview-video?lang=en).
- dub_pipeline.save_job UPSERT: language/language_code now update when
non-empty (same CASE guard as content_hash), so new saves heal the
frozen columns and empty re-saves can't clobber them back.
- App.restoreDubHistory: falls back to job_data's language/language_code
so EXISTING rows in users' DBs restore correctly with no migration.
- P0.2 polish: track pills get duration + timing-strategy tooltips,
hydrated lazily and failure-silently from the existing
GET /dub/tracks/{job_id} via new api/dub.dubListTracks; all new
strings through i18n (en.json).
Tests (fail-before/pass-after): DubTab-level visibility + auto-jump
membership-guard tests (3 of 4 fail pre-fix), pill-tooltip hydration
tests, and save_job language heal/no-clobber tests (heal fails pre-fix).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): open [Unreleased] with the dub track-tabs fix (#956)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Header reordered per owner: row 1 = title (+ duration/segments) with the
action buttons, row 2 = the Upload→Export pipeline spine directly beneath
with a tight 2px gap (was: stepper and title side-by-side on one row).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
package.json (source of truth) + the three mirrors -> 0.3.10, in lockstep;
Cargo.lock/uv.lock/bun.lock regenerated (one line each; bun --frozen-lockfile
verified). CHANGELOG [Unreleased] -> [0.3.10] — 2026-07-05 with the release
headline; nine fixes since v0.3.9, mostly same-day field-report turnarounds.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(audiobook): chapter render no longer crashes on mixed 1-D/2-D audio chunks (#897)
Root cause: synthesize_chapter (backend/services/audiobook.py) built
inter-span pause silence as bare 1-D torch.zeros(n) while every real
engine's synth returns (1, samples) per the TTSBackend.generate contract
(OmniVoice's model.generate(...)[0] included) — so the chapter's final
hard concat in chunked_tts.concatenate_audio_chunks hit
torch.cat with mixed ranks and died with
'RuntimeError: Tensors must have same number of dimensions: got 1 and 2'.
Any chapter containing a [pause] span (Stories/audiobook longform)
crashed; existing tests missed it because their stub synth returned 1-D.
The crossfade branch had the same latent bug for mixed-rank chunks.
Fix, both layers:
- concatenate_audio_chunks now normalizes chunk shapes before any cat
(_normalize_chunk_shapes): lower-rank chunks gain leading singleton
dims to the highest rank present, then singleton channel dims
broadcast to the widest channel count (mono follows stereo). Covers
both the hard-cut and crossfade branches; homogeneous input passes
through untouched, so all-1-D / all-2-D callers keep their exact
output shapes. No future backend's output rank can re-break the join.
- synthesize_chapter materializes silence AFTER the loop, matching the
rendered audio's channel dims / dtype / device — the same pattern
generation.py's _render_with_pauses already uses for the single-shot
path — so the data is rank-consistent at the source too. A
silence-only chapter stays 1-D float32 as before.
Regression tests: mixed-rank hard-cut (both orders), mixed-rank
crossfade, mono->stereo broadcast, all-1-D/all-2-D shape stability, a
2-D-engine + [pause] chapter through synthesize_chapter (the exact #897
scenario), and a spy asserting the parts reaching the concat are
rank-homogeneous. All fail before the fix with the reported error.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add the audiobook pause-span concat fix under [Unreleased] (#953)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(dub): the speaker-count hint is honored on every diarization path + clone-purity guard
The dub "Speakers" count reached _diarize() and then died on 3 of its 4
branches, so setting it changed nothing, speakers blended, and auto-clones
were cut from mixed-speaker audio ("made up" voices):
- FunASR inline-turns shortcut returned before the hint was ever consulted
→ now an explicit num_speakers routes the job through pyannote (the one
engine that honors an exact count); turns stay the fast path only when no
hint is set, and remain the fallback (with an honest "hint ignored"
warning) when pyannote can't load or crashes mid-run.
- pyannote-unavailable fallback used a hardcoded 2-speaker silence-gap
heuristic → assign_speakers_heuristic now takes num_speakers and cycles N
labels on gap boundaries (1 → single speaker; None → legacy alternation),
and the existing diarization warning says the hint is only approximately
honored.
- pyannote-crash fallback dropped the hint the same way → same treatment.
No branch drops the hint silently anymore: every degraded path extends the
existing `warning` SSE payload (detail + a machine-readable speaker_hint
field) that the frontend already renders.
Parity + purity:
- POST /dub/transcribe/{job_id} (the CLI's endpoint) gains the same clamped
num_speakers query param, forwarded to pyannote and the heuristic; the
omnivoice-dub CLI gains --speakers N.
- Clone-purity guard: _pick_reference_slices rejects sub-1.5s slices, prefers
slices not temporally adjacent (<0.3s) to another speaker's turn (scoring
preference, not a hard filter), and extract_speaker_clones skips extraction
entirely when labels came from the heuristic (labels_source kwarg threaded
from _diarize; missing kwarg keeps the old behavior) — with a user-facing
warning pointing at Settings → Models → pyannote.
Tests: fail-before/pass-after coverage in tests/test_speaker_hint.py (all
four _diarize branches driven through the real SSE stream), clone-purity
guards in tests/test_speaker_clone_purity.py, heuristic hint semantics in
tests/test_segmentation.py.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add the speaker-hint + clone-purity fix under [Unreleased] (#952)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): dub timeline boxes can no longer flash invisible during playback (#373 class, completes #381)
Root cause: the segment lane animated an inline
`transform: translateX(-scrollLeft)` on every playback tick, so Chromium
promoted it to a compositor layer — and on some Windows GPU/WebView2
driver combos, composited semi-transparent paints (the 0.45-alpha box
fills) flash invisible/visible while the layer moves, settling only when
paused. PR #381 removed `will-change` and raised the alpha, which only
dampened the symptom; the animated transform kept the lane composited.
Fix the class — no composited translucent paints on the lane, ever:
- Position boxes in pure layout: the lane transform is gone; each box's
`left` is start·pxPerSec − scrollLeft (viewport coordinates). The
virtualization window already derives from the same scrollLeft, so
windowing stays consistent by construction. The selfScroll WebKit
fallback keeps lane coordinates (its viewport is a real scroll
container), unchanged.
- Belt-and-braces: REGION_COLORS are now fully opaque — each entry
pre-blends the old 45% tint against the surface behind the lane
(`--chrome-bg`, the .studio-panel background) via color-mix, which is
pixel-identical to the previous alpha compositing (0.45·tint + 0.55·bg)
in every theme, with zero alpha.
Regression tests (fail on pre-fix code): lane carries no transform at
rest and after a scroll update, box lefts are viewport-relative for a
scrolled view, no double-shift in the selfScroll fallback, and every
REGION_COLORS entry is alpha-free with the 45% ratio preserved.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add the timeline-box compositor fix under [Unreleased] (#951)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Root cause (v0.3.9 field report): the refine paths had no-op output guards.
Cinematic's ADAPT step only checked _looks_like_target_script, which returns
True unconditionally for every Latin-script target (no _SCRIPT_RANGES entry)
— so any non-empty LLM reply (hallucinated dialogue, refusals, commentary,
or the REFLECT critique itself) shipped as the dub line. Autofit's
adjust_for_slot accepted ANY non-empty reply, and its best-candidate tracker
(closest rate_ratio to 1.0) actively selected the most-padded output, while
_EXPAND_PROMPT invited invention with no ceiling. Both call paths also ran
at the provider-default temperature 1.0, unlike the working Fast path which
pins 0.2.
The fix, class-level:
- Shared divergence guard translator.refine_output_ok (length window
0.4–2.5x, env-tunable via OMNIVOICE_REFINE_RATIO_MIN/MAX, with an
absolute cap for short references; target-script check; critique-echo
detection). Rejected ADAPT output degrades to the literal with
error="adapt-diverged" (wrong-script keeps its adapt-wrong-script:<lang>
marker), riding the existing degradation machinery unchanged.
- Autofit validates every reply against the ORIGINAL input text (divergence
compounds across attempts otherwise); rejected candidates are discarded
(attempt burned, graceful degradation to the input preserved) with
error="fit-diverged"; lines under 15% of their slot skip LLM expansion
entirely (fit-skip-short) — they could only "fill" the slot with
fabricated dialogue.
- temperature=0.2 pinned on the cinematic (_chat) and fit (llm.chat) calls;
chat/chat_messages gained an optional temperature param that is only sent
when set, so refinement/director/glossary callers keep provider defaults.
- Prompts hardened: ADAPT forbids introducing facts/names/dialogue not in
the source line; EXPAND forbids inventing information and more than
doubling the line.
- speech_rate strict-mode docstring made honest: strict changes only the
upper tolerance bound; expansion still runs (now guard-bounded).
Fail-before/pass-after regression tests for the reported bugs (10x runaway
ADAPT on an es target, critique echo, hallucinated slot-fill expansion,
refusal replies, tiny-line expansion skip, pinned temperature) plus the
previously-untested wrong-script fallback and legit-output acceptance.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The table described aspirational channels (#showcase/#help/#feature-requests/#dev)
that don't exist on the server; it now matches reality (#announcements,
#releases+#changelog, #issues and #ideas forums, #discuss-ideas, #general).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(launcher): replace a stale old-version backend instead of attaching to it
After an update, an orphaned backend from the PREVIOUS version can survive
holding the port. It still answers /system/info, so both attach paths
(lib.rs launch bootstrap + bootstrap.rs retry) treated it as "already
serving OmniVoice — attaching" and the new UI silently ran OLD backend
code: every fix in the update appeared to change nothing. Reported on
Discord as "a bound port which blocked the newer versions"; the app
already knew how to kill_orphan_on_port on both Unix (lsof) and Windows
(netstat) — it just never applied it to a healthy-but-stale backend.
The attach decision now compares versions: running_backend_version()
reads app_version from /system/info (string-sniff, no new deps), and
same_app_version() compares BASE versions (pre-release -N suffix
stripped, so a preview build 0.3.10-4 still attaches to its 0.3.10
backend). Same version → attach exactly as before. Different or missing
version → the orphan is killed and the bundled backend spawns. Foreign
processes keep the existing port_in_use take-ownership path; the
post-spawn health polls are untouched (we spawned that backend
ourselves).
Rust unit tests cover the /system/info parse shape and the
match/preview/stale/unversioned decisions; 51 pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add the stale-backend port-reclaim fix under [Unreleased] (#947)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Backend rows store timestamps as Unix SECONDS (time.time() REAL columns:
generation_history, dub_history, exports, longform jobs, projects), while
frontend-local records carry milliseconds (Date.now() story projects) or ISO
strings (transcriptions). Projects/OmniDrive fed the seconds straight into a
millisecond-based diff (fmtTime), so every generation-history card rendered
as ~1970 ("20617d ago") — and, because the same raw value drove the recency
sort, history items also sank to the bottom of the drive.
Fix the class, not the label: a single shared, unit-tolerant normalizer
(frontend/src/utils/relativeTime.js) now backs every relative-time call site.
- toMillis(ts): numbers < 1e12 are seconds (×1000), >= 1e12 already ms; ISO
and numeric strings parse; Date instances pass through; null/0/undefined/
garbage -> null. Backend storage format is untouched (backward compat).
- timeAgo(ts): "—" for missing stamps (never an epoch age), "just now" for
future stamps within 1 min of clock skew, s/m/h/d buckets, short absolute
date beyond 7 days.
- absoluteTime(ts): unit-safe tooltip text, '' when missing (no more
"Jan 1 1970" titles on null rows).
Converted call sites: pages/Projects.jsx (drop local fmtTime + per-source
*1000 juggling; normalize ts once so sort and label agree), components/
Sidebar.jsx + components/WorkspaceProjects.jsx (drop duplicated local
timeAgo copies and caller-side *1000), pages/BatchQueue.jsx (drop local
formatAge; missing created_at used to render an epoch date), pages/
Transcriptions.jsx + components/TranscriptionPicker.jsx (parse via
toMillis, keep their i18n labels; unparseable stamps no longer render
"Invalid Date").
Tests (fail-before/pass-after): utils/relativeTime.test.js covers seconds/
ms/ISO/numeric-string/Date inputs, null/0 -> "—", clock-skew "just now",
and the 1970 regression (a seconds stamp from today must not render as
thousands of days ago); test/ProjectsRelativeTime.test.jsx guards the
OmniDrive wiring end-to-end (seconds created_at renders "2h ago", null
renders "—", mixed-unit sort orders by real recency). Full frontend suite:
106 files / 843 tests green; oxlint, oxfmt, typecheck:ci, node:test green.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(translate): the Dub LLM engine now runs on the configured LLM provider
Picking "LLM (OpenAI-compatible)" in the Dub tab read only the raw
TRANSLATE_* env vars — completely bypassing Settings → LLM Providers, so a
provider the user had configured AND tested in-app silently didn't power
the engine (empty key → raw 401 per segment). The Cinematic refiner was
already rewired through LLM Skills (#910/#912); this closes the gap for
direct LLM translation:
* new "dub_translation" LLM skill (Settings → LLM Skills) — per-skill
provider override → global active provider, same resolution as every
other skill; disabled == unconfigured, no new degradation modes
* the provider=openai branch resolves through resolve_skill_client();
TRANSLATE_BASE_URL/TRANSLATE_API_KEY/TRANSLATE_MODEL stay working as
the power-user override (env-only setups see zero behavior change,
except the stale gpt-3.5-turbo default is now gpt-4o-mini, matching
the cinematic path)
* per-segment calls are now bounded by the LLM timeout (45s default via
OMNIVOICE_LLM_TIMEOUT) instead of the SDK's 600s default
* fully unconfigured → an up-front actionable 400 naming Settings → LLM
Providers / LLM Skills instead of a per-segment 401
* provider-store keys are resolved into the error scrubber so a provider
echoing the key can't leak it (parity with the env-key scrub)
* translation_engines registry: honest notes + a configured/configured_via
stamp on LLM entries so the Engine dropdown can show ready-vs-needs-setup
before the user clicks Translate
Tests: 4 new (skills-resolved client wins with its model+timeout; 400s
name the right settings page for no_provider vs disabled; env fallback
keeps working incl. TRANSLATE_MODEL); skills registry coverage updated;
existing openai-branch tests routed deterministically through the env
branch via the shared fake helper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): add the dub-translation provider wiring under [Unreleased] (#944)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Swap the static social-preview banner for the live v0.3.9 Launchpad shot
and drop the now-duplicate Launchpad row from the gallery (shown once).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- Emoji section headers with explicit <a id> anchors. Emoji breaks
GitHub's auto-generated heading slugs, so every in-page nav target keeps
a stable explicit anchor (verified all href="#..." resolve).
- Refresh the screenshot gallery. The prior set was from April, predating
the launchpad / settings / dictation UI overhaul, so it misrepresented
the app. Captured fresh at retina from the live v0.3.9 UI and led the
gallery with the new Launchpad home: launchpad, studio, voice design,
voice gallery, dubbing, engine-compatibility matrix, model store,
embedded API reference (Scalar), and the in-app changelog reader.
- Fix the stale engine count (11 -> 14 TTS engines) in the comparison
table, FAQ, and roadmap to match the engine table + backend registry.
- Use <kbd> keycaps for the dictation shortcut (Opal detail).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(engines): snapshot lazy registry keys so /engines can't 500 under concurrency
`list_backends()` runs in a FastAPI threadpool and iterates the lazy TTS/ASR
registries via `items()` → `__iter__`, which held a *live* `dict.__iter__(self)`
open across each engine's slow `is_available()` probe. Meanwhile the lazy
`__getitem__` resolves a deferred entry by mutating the dict (`self[key] = cls`).
A second concurrent `/engines` request (or any ASR op) materializing the lazy
`faster-whisper-isolated` entry therefore changed the dict size mid-iteration:
RuntimeError: dictionary changed size during iteration
asr_backend.py:1729 list_backends → _REGISTRY.items()
asr_backend.py:1665 __iter__ → for k in dict.__iter__(self)
Both `_LazyRegistry` (TTS) and `_LazyASRRegistry` (ASR) now snapshot their live
keys up front with `list(dict.__iter__(self))` — consumed atomically under the
GIL — so a concurrent lazy insert can no longer trip the iteration. The slow
per-engine probes then run over the snapshot, not the live iterator.
Deterministic fail-before/pass-after regression for both registries:
tests/backend/services/test_lazy_registry_concurrency.py.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(changelog): add the /engines concurrency fix under [Unreleased] (#940)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
A per-chunk temp-WAV write that fails with OSError EINVAL ("[Errno 22]
Invalid argument") — a missing/read-only/full temp dir, a removed drive,
or antivirus — collapsed into "Transcription produced no segments.
[Errno 22] Invalid argument" with no next step. classify() now names the
class (OS_INVALID_ARGUMENT) so build_failure attaches an actionable
temp-dir/disk/AV hint at the exact surface the streaming dub path already
feeds it (dub_core.py:672) — same treatment the ffmpeg and compute-type
classes get. Fail-before/pass-after regression added; the errno-22 token
keeps it from colliding with the errno-2 transformers-import class.
Also stamps the [0.3.9] CHANGELOG section with today's release date
(2026-07-04) ahead of tagging.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
A user selected the sherpa-onnx TTS engine and got a 500 that read "TTS
engine stopped mid-generation. This usually means it ran out of memory.
Try the Flush button…" — when the real cause was a pure setup problem:
"OMNIVOICE_SHERPA_MODEL not set. Point it to a sherpa-onnx TTS model
directory (containing model.onnx + tokens.txt)." Same misclassification
class as #880/#893, which tightened the OOM catch-all on the generation
path — but the engine-not-configured case still fell through to memory.
Two layers, fixing the whole class:
1. Error classification (backend/api/routers/generation.py): a new
`_is_config_failure()` recognizes "required engine model path / env
var not set" over the whole exception chain (OMNIVOICE_* named with
"not set"/"point it to"/"set omnivoice_…", sherpa's "no model.onnx
found in", "not configured", "venv not found. set" for the dedicated-
venv opt-ins). `_oom_friendly_reraise` checks it BEFORE the OOM branch
and re-raises actionable setup guidance that names the variable, points
at Settings → Engines, and never mentions memory or Flush. Generalizes
to sherpa/Confucius4/dots/MOSS and any future env-gated engine.
2. Engine gating (backend/services/tts_backend.py): SherpaOnnxBackend
ships no bundled model, so is_available() now gates on
OMNIVOICE_SHERPA_MODEL (set + contains model.onnx) — like the other
path-configured opt-in engines — returning False with an actionable
reason instead of "ready", so the picker marks it unavailable-with-a-
reason rather than selectable-but-broken. Added the copy-paste setup
snippet for the Compat Matrix. Backward-compatible: a correctly
configured OMNIVOICE_SHERPA_MODEL keeps the engine available.
Tests (fail-before/pass-after): config-classification of the sherpa
"model not set" error and the wider not-configured class (no "out of
memory"/"Flush"); is_available gating on the env var + model.onnx and the
setup-snippet registration.
Fixes#919
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Two sides of the same class the router-smoke leak (#932) traced to
test_pronunciation_api's importlib.reload teardown:
- test_pronunciation_api now re-runs init_db() on the restored data dir so
the reloaded core.db/main.app is never left on a schema-less DB.
- test_db_migration_safety catches db_module.MigrationError dynamically
instead of the collection-bound name, so a reload that rebinds the class
can't make pytest.raises miss it.
Both orderings (real + reversed) now pass; no product change.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
tests/test_router_smoke.py showed ~10 `sqlite3.OperationalError: no such
table: jobs` failures in the full suite (and in isolation on a clean data
dir), but passed when a schema-creating module ran first.
Root cause: the `client` fixture builds a bare `TestClient(app)` with no
`with` block, so the FastAPI lifespan never runs — and `init_db()` (the
only place the schema is created) lives in that lifespan (main.py). The
smoke tests therefore free-rode on whatever schema an earlier module left
on the active DB. A module that reloads `core.config`/`core.db` and leaves
`core.db.DB_PATH` pointed at a fresh, schema-less DB (test_pronunciation_api's
`importlib.reload` teardown restores the env var but never re-runs init_db
on the restored data dir) strands router-smoke on a DB with no tables ->
every DB-backed route 500s. Same class as #878 / #917.
Fix (test-only, zero blast radius): the `client` fixture now calls
`core.db.init_db()` against whatever DB is active at run time before serving
requests — the same `init_db()` pattern test_api.py / test_personas_api.py
use. Because it targets the live `core.db.DB_PATH`, it re-creates the schema
regardless of which path any prior module left active, making the suite
self-sufficient and order-independent.
Verify:
- `pytest tests/test_router_smoke.py` alone: 10 failed -> 24 passed
- `pytest tests/test_pronunciation_api.py tests/test_router_smoke.py`
(deterministic reproducer): 10 failed -> 38 passed
- `pytest tests/` full suite: 2215 passed, 20 skipped, 10 xfailed,
4 xpassed, 0 failed / 0 errors
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Bullets for #922 (release titles), #923+#924 (sponsors), #925 (contact),
#927 (models), #928 (openapi), #930 (engines) — the agents kept off
CHANGELOG.md during the merge chain. Plus a portable how-we-set-up-
sponsorship playbook for reuse on other projects.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Add a Settings → OpenAPI page that renders an interactive Scalar API
reference for OmniVoice's own local backend, plus a compact footer button
that opens it.
- New OpenApiPanel fetches the live spec from the resolved backend base
(getApiBase()+"/openapi.json", via apiFetch so it follows remote-backend /
LAN-share overrides), owns loading + unreachable-backend fallback (with
Retry), and hands the parsed spec inline to Scalar.
- Scalar is bundled via the @scalar/api-reference-react npm package — NO CDN
script tag. It is lazy-loaded (ScalarApiReference.jsx) so its ~heavy Vue
bundle stays out of the initial load and only downloads when the page opens.
- CDN-free hardening: withDefaultFonts:false (drops the fonts.scalar.com
@font-face rules), proxyUrl:'' (Test Request client goes direct to the local
backend, not proxy.scalar.com), spec passed as inline content (no external
spec fetch). The Tauri CSP is the hard backstop. Verified the built dist:
external hosts appear only as inert/gated strings inside the on-demand Scalar
chunks and are absent from the initial-load chunks.
- settingsCategories: new 'openapi' category (Braces icon, api/openapi/scalar/
rest/swagger/docs keywords) in the System group; Settings render case wired.
- LogsFooter: compact Braces icon button (openSettingsTab('openapi')) next to
the discord/mail cluster, chrome-muted → accent on hover, uniform 14px icon.
- i18n: all strings via t() with English defaultValue fallbacks (openapi.*,
logs.open_api*, settings.openapi); keys added to en.json.
- Test: OpenApiPanel.test.jsx (mocks the spec fetch + stubs Scalar) — renders
the reference container on success, shows the unreachable fallback on failure,
recovers on Retry.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(engines): real synthesis "Self-test" + copy-paste setup snippet for opt-in engines
Builds on #905's Engines-settings fixes (verified still green: license dialog
mounts, matrix reloads on select, cpu_fallback routing toast, cpu-native →
cpu_only). Two enhancements, no #905 behavior touched.
Real "Self-test" for in-process TTS engines
-------------------------------------------
The existing /engines/{id}/health probe only imports the package and reports
"deps OK" for in-process engines — it never proves the engine can emit audio.
New POST /engines/{id}/selftest runs a *tiny real synthesis* from a fixed short
ASCII phrase and reports ok + duration + sample-rate + sample count, proving the
engine actually produces audio. Guardrails keep it cross-platform-identical and
CPU-cheap: TTS + available + in-process only, bounded wall-clock timeout
(OMNIVOICE_SELFTEST_TIMEOUT_S, default 90s) that returns ok=false/timed_out
instead of hanging the panel, a process-wide lock so a click-storm can't stack
model loads, loopback-gated, and only ever on user click (never on load). The
Compat Matrix gains a "Self-test" button (with cooldown) that renders
"0.82s @ 24 kHz in 820 ms". HF tokens in a synth error are redacted like the
health route. Verified end-to-end: kittentts synthesized 89,200 samples @ 24 kHz.
Copy-paste setup snippet for path-gated opt-in engines
------------------------------------------------------
IndexTTS / MOSS-v1.5 / dots.tts / Confucius4 gate on an OMNIVOICE_*_DIR env var.
list_backends() now emits a single-sourced `setup_snippet` (the exact
`export VAR=/path/...` line) surfaced with a Copy button inside the matrix's
"Why unavailable?" disclosure, so users don't reconstruct it from the docs.
Also tightened the incomplete SelectEngineResponse TS type to include the
routing echo (routing_status/effective_device/routing_reason) the post-select
toast already reads at runtime.
Tests: backend selftest success/subprocess-reject/unavailable/unknown/loopback/
exception-capture/timeout/HF-redaction + setup_snippet shape; frontend self-test
render, timeout marker, subprocess+ASR gating, setup-snippet render. New route
added to the API route snapshot. Full vitest (808) + backend engine/routing/asr/
route-inventory/no-CJK green; lint 0 errors; format + typecheck:ci clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): allow setup_snippet key in list_backends shape assertion
The engine self-test PR added setup_snippet to each backend entry but only
updated the route-shape test; test_list_backends_shape strict-asserts the key
set. Add setup_snippet there too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Two Model-management enhancements building on #908 (no re-do of its fixes).
Unify the two HF-token entry points. The Model Store toolbar saved the
token via /system/set-env (env var + HF-CLI file) while Settings →
Credentials saves to the encrypted app store — two stores with an
asymmetric clear path, so a toolbar-set token silently outlived the
Credentials "Clear" (a support-ticket generator). The toolbar now POSTs
the SAME canonical endpoint Credentials uses (/api/settings/hf-token →
encrypted store + huggingface_hub.login()), so there is one store with
one clear path. In-process parity is preserved (login() populates the HF
canonical file, so downloads pick it up immediately).
Surface an incomplete/partial cache. A truncated download (config landed,
weight shard didn't) occupies disk but used to read as a plain "not
installed". The backend already flags it as `incomplete`; the row now
shows an "incomplete · N MB" warn badge, relabels the primary action to
"Repair" (re-runs snapshot_download to finish the missing shard), and
offers a Delete to clear the partial bytes.
Tests: modelStoreTokenPath (toolbar hits /api/settings/hf-token, never
/system/set-env) + modelStoreIncomplete (badge, Repair→onInstall, Delete,
no false positives on normal not-installed/installed rows). Full vitest
green; lint + format clean. i18n keys added to en.json (models.incomplete,
incomplete_title, repair_btn, repair_title).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(contact): make "Get in touch" a guided, well-typeset help page
Replace the flat 4-row link list (Discord / Email / Issues / Website) with
five guidance cards, each an icon + heading + a "use this when…" sentence so
users pick the right channel instead of guessing:
- Report a bug → reuses ReportBugButton (prefilled GitHub issue + scrubbed
diagnostics; nothing sent until the user reviews & submits)
- Request a feature / ask → GitHub Issues
- Get help & community → Discord (setup help, sharing dubs)
- Support the project → routes to the existing Support page (no Ko-fi
duplication)
- Report a security issue → GitHub Security Advisories (private, per
SECURITY.md)
Bigger, friendlier typography ("We'd love to hear from you" header, roomier
measure and spacing) and a container-reflow card grid (CSS grid auto-fit, no
viewport @media, so it stays correct under --ui-scale zoom). Email + website
kept as quieter direct channels. External CTAs are real <a rel="noreferrer">
links, keyboard-focusable, opened via the shared openExternal helper. All
strings go through i18n under contact.* with English defaultValues for locale
fallback.
Adds a ContactPage render test (sections render, each channel targets the
right URL, bug-report affordance present, Support routes to donate).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(i18n): prune 4 orphaned contact.* keys from 20 locales (Contact-page rewrite)
The Contact page rewrite renamed its i18n keys; the old keys lingered in the
20 non-English locales as orphans, failing the locale_no_orphan_keys probe.
Pruned; new keys fall back to English per i18n config.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Adds a way for companies and people to visibly support OmniVoice.
- config/sponsors.js: single source of truth — an (empty) SPONSORS array with
a documented { name, logoUrl, url, tier } shape + tier order, and a
SPONSOR_CONTACT object whose githubIssue is a prefilled, zero-token
"become a sponsor" issue (same pattern as the bug reporter) plus the Ko-fi
link and a SPONSORS.md docs URL. Logos are added here + in SPONSORS.md.
- LogsFooter: a compact "Sponsors" link next to the donate heart (a link, not
a logo strip in the 28px bar) that opens the in-app Support/Sponsors view.
- SupportPage: a Sponsors section — logo grid grouped by tier when populated,
a tasteful outlined "be the first — your logo here" slot while empty, a
primary "Become a sponsor" button opening the prefilled issue, and a
one-line explainer linking to SPONSORS.md.
- SPONSORS.md: what sponsors get + how to become one, kept in lockstep with
the config.
- All strings via i18n (support.sponsors_* / logs.sponsors) with English
defaultValues so non-English locales fall back cleanly. Logo links are lazy,
max-height capped, aria-labelled, rel="noreferrer", and open in the system
browser via the app's external-open helper.
- Test: SupportPageSponsors renders the empty placeholder + asserts the
become-a-sponsor CTA targets the contact URL, and (with injected sponsors)
that each renders as an external logo link.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Add a sponsorship home (SPONSORS.md) with Backer/Bronze/Silver/Gold tiers —
described as placements/benefits, with $ amounts left as `<!-- OWNER: set
amounts -->` placeholders (no invented prices). Primary "become a sponsor"
path is a prefilled GitHub issue form (.github/ISSUE_TEMPLATE/sponsor.yml:
name/org, logo URL, tier, contact), with Ko-fi/PayPal as direct paths and an
OWNER placeholder for a public contact email.
README gains a Sponsors subsection (logo-slot placeholder + SPONSORS.md link),
a Sponsors nav entry, and a note about GitHub's native Sponsor button.
FUNDING.yml adds the SPONSORS.md link alongside the existing ko_fi/PayPal.
Keeps the honest "agent bills" framing; sponsorship is a thank-you, not a
paywall — OmniVoice stays fully free and AGPL-3.0. Docs-only; no fabricated
sponsors, prices, or testimonials.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
GitHub's release-list sidebar clips the title mid-string, hiding the version
when it trails 'OmniVoice Studio'. Name stable releases 'vX.Y.Z — OmniVoice
Studio' and the preview 'Preview — OmniVoice Studio'. Existing releases were
renamed to match.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The PR #909 data-safe-update tests passed in isolation but failed only in
full-suite CI order. Two independent, order-dependent leaks were at play:
1. Module-identity leak (the #878/#894 class). The `isolated_db`/`fresh_app`/
`fresh_resolver` fixtures in tests/backend/** purge `core.*`/`services.*`
from `sys.modules` and never restore them, so `sys.modules["core.db"]`
afterward is a DIFFERENT object than the one the migration-safety tests
imported at collection. `monkeypatch.setattr("core.db.DB_PATH", ...)`
re-resolved the dotted string to the re-imported module, while
`_run_alembic_upgrade`/`init_db` (bound at collection) kept reading the
ORIGINAL module's globals — so the patch missed and the upgrade ran against
the ambient session DB. Result: no backup at the asserted path, and the
mid-flight-failure injection never hit the expected DB (DID NOT RAISE).
The same divergence hit the lazy `from core import db_backup` inside
`_run_alembic_upgrade`, so patching `MAX_BACKUP_DB_BYTES` was silently lost.
2. Logger-disable leak. Alembic's env.py called `fileConfig(...)` with the
default `disable_existing_loggers=True`, which disabled the already-created
`omnivoice.db.backup` logger the first time any earlier test ran a real
`alembic upgrade` — so the oversized-DB "Skipping pre-migration DB backup"
line was never emitted and the caplog assertion failed. This also silently
mutes the live app's logging after a real startup migration.
Fixes:
- env.py: `fileConfig(..., disable_existing_loggers=False)` so a migration
never mutes the app's (or another test's) loggers.
- core/db.py: import `db_backup`/`APP_VERSION` at module level so
`_run_alembic_upgrade` uses a stable reference immune to a `sys.modules`
purge, matching what tests patch at collection.
- test_db_migration_safety.py: patch DB_PATH on the imported `core.db` module
object rather than the re-resolvable dotted string — the correct,
self-contained seam.
Verified: the four migration-safety tests + the oversized-backup test pass in
full-suite order and in isolation; full `pytest tests/` is green
(2206 passed, 0 failed).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
PR #904's deck-of-cards fan pinned the seven Launchpad feature cards
(Voice Clone, Voice Design, Video Dubbing, Stories, Audiobook, Voice
Gallery, Transcripts) inside a fixed ~780px box, leaving dead margins on
a maximized display. Replace it with one full-width grid that fills the
content edge-to-edge and reflows its column count from a maximized
~2560px display down to the 900x600 minimum.
- LaunchpadDeck renders a single `.lp-cards` grid at every shell width
(no deck-vs-fallback split): `repeat(auto-fit, minmax(--lp-card-min,
1fr))` derives the column count from the grid's OWN width, so columns
reflow 7->1 with zero viewport @media (which fire at the wrong width
under the shell's `zoom: --ui-scale` model). Every column stretches
(1fr) -> no dead margins, no horizontal scroll.
- The only responsive knob is `--lp-card-min`, set inline from
useShellNarrow (the `.app-container` shell-narrow/shell-mini own-width
classes): 200px wide, 240px narrow -> fewer, comfier columns on narrow
shells. No viewport media queries.
- Cards keep #904's character: animated waveform faces, cursor
spotlight + eternal breath ring (phase-offset per card via --lp-i),
and a hover/focus-forward raise (`lp-action-card--raised`) driven from
React state so pointer and keyboard share one path. Reduced-motion
freezes the waveform. All 7 navigation targets and i18n keys preserved.
- Removed the old `.lp-deck*` fan CSS, the ActionCard narrow fallback,
and the launchpad viewport @media overrides. Rewrote the regression
suite to assert full-width grid layout, the narrow-vs-wide floor, and
the raise interaction for pointer AND focus.
Verified in a real browser (chromium): 7 cards fill the full width in
one row at 2472px content, reflow to 3 columns at 920/876px, and the
grid width equals the container at every size (no overflow).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
New Settings → System → LLM Skills area: every LLM-powered capability
(Cinematic & Autofit translation, speech-rate slot fitting, glossary
auto-extract, direction parsing, dictation cleanup) becomes a "skill" the
user can toggle or route to a specific provider (local Ollama/LM Studio vs
a remote key) instead of everything riding the one global active provider.
Backend:
- services/llm_skills.py — skill registry + settings_store persistence
(llm_skill.<id>.enabled / .provider), resolution precedence
override > active > none, resolve_skill_client() (OpenAI-compat client
bound to the effective provider; None when disabled/unconfigured) and
skill_backend() (OffBackend when disabled — the exact no-LLM object every
caller already degrades on).
- All five consumption points wired through the registry; a disabled skill
degrades exactly like "no LLM configured" today (Fast translation
fallback, refinement pass-through, heuristic direction parse, no-llm slot
fit, 503 on glossary auto-extract). No new degradation modes; defaults
(enabled + no override) keep existing setups byte-identical.
- OpenAICompatBackend gains an optional bound provider (None = active, the
historical behavior).
- GET /api/settings/llm-skills + PUT /api/settings/llm-skills/{skill_id}
(404 unknown skill/provider); route snapshot updated.
Frontend:
- LLMSkillsPanel (Sparkles, next to LLM Providers): one row per skill —
i18n name/description, enable toggle, provider Select ("Use active
provider" + configured providers, local ones tagged), ready /
needs-setup badge linking to LLM Providers. All strings via t()
(settings.llmskills_*).
Tests: 30 backend (precedence, per-consumption-point disabled semantics,
endpoint round-trips, validation) + 4 panel render/PUT tests. Docs:
translation-engines.md gains an LLM Skills section.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Nine PRs (#904-912) shipped without their changelog bullets (agents were
kept off CHANGELOG.md to avoid merge conflicts across the wave); this
backfills them per the changelog hard rule.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
P0 — Cinematic/Autofit silently no-op'd on argos/nllb/openai. Those three
branches returned BEFORE _maybe_cinematic, so only the deep_translator
fall-through reached the refine/fit pass. A user on the DEFAULT Argos engine
who picked Cinematic/Autofit got plain Fast output with a success toast and
no quality_used/cinematic_skipped/rate_ratio. All three now route through
_maybe_cinematic. provider=openai is already an LLM translation, so it skips
the reflect/adapt re-refine (new already_llm flag) but still stamps
rate-ratio badges and runs the Autofit fit pass; the dialect it baked into
its translate prompt is now reported applied.
P1 — the Autofit fit pass ran one blocking adjust_for_slot per segment in the
merge loop, OUTSIDE any budget (a 50-seg dub vs a slow provider spun
~50×timeout unbounded). New speech_rate.adjust_for_slot_many fans it out
concurrently under a wall-clock deadline SHARED with the cinematic refine;
segments still running at the deadline degrade to their literal with
rate_error='fit-budget'. Also set max_retries=0 on the OpenAI clients used
for translate/refine/fit so a 429 + Retry-After can't sleep through the budget.
P2 — glossary auto-extract's no-LLM message now points at Settings → LLM
Providers (was the stale TRANSLATE_BASE_URL/TRANSLATE_API_KEY). Provider error
bodies on the glossary auto-extract, the OpenAI translate-segment path, and the
DeepL/Microsoft translate-segment path are now scrubbed
(core.scrub.scrub_provider_error) — they could echo the API key / a user_id.
DubTab re-polls LLM availability on window focus / visibility so configuring a
provider in Settings lifts the Cinematic gate without a remount. Documented
LLM_DEFAULT_PROVIDER in docs/dubbing/translation-engines.md.
Tests: fail-before/pass-after for argos+cinematic (refine runs), argos+cinematic
no-LLM (cinematic_skipped), argos Fast (rate_ratio stamped), openai+autofit
budget bound, and provider-error scrubbing on the translate + glossary paths.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
P0 — Refinement blocked every dictation final with no timeout. With refinement
auto:true and a slow/dead LLM endpoint, maybe_refine ran unbounded and blocked
the final send in all three capture_ws handlers (~51s measured; the pill hung
"Transcribing…" until the widget's 15s fallback fired). Fix the class: a hard,
env-tunable budget (OMNIVOICE_REFINE_TIMEOUT_S, default 4s) via a new
maybe_refine_async — a slow/dead endpoint now falls back to the unrefined (but
polished) text within the budget and can NEVER delay the final beyond it. The
LLM HTTP call is bounded to the same budget so the orphaned worker unwinds
instead of holding a connection for the client's full 45s. Refinement is now
also fully best-effort in the legacy handler (it can't turn a good final into
an error frame).
P1 — REST /transcribe lacked polish parity. capture.py never applied
polish_text, so REST returned raw "…test" while the WS returned "…test."
Apply text_polish.polish_text to `text` and `refined_text` (segments stay raw),
so the widget POST fallback and MCP/CLI callers match the live socket.
P1 — The #888 "instant first dictation" preload was a no-op. The preload called
warmup() only `if hasattr`, but SherpaDictationBackend had none, and the WS
handlers built a FRESH backend per session so a warm singleton wasn't reused.
Add SherpaDictationBackend.warmup() (builds the recognizer) and share one warm
recognizer per model id across sessions (get_sherpa_dictation_backend, same
invalidation + a shared lock as the capture singleton); each session keeps its
own decode stream. First dictation no longer pays the 1.3–2.5s load.
P1 — llm_ready is a lie (feeds the P0). It only means "an endpoint is
configured", so a placeholder key reads as ready. The P0 timeout makes a dead
endpoint harmless; add last_refine_status so RefinementPanel flags a
configured-but-failing LLM and links to LLM Providers → Test.
Regression tests (fail-before/pass-after): slow-LLM WS final arrives < budget;
maybe_refine_async hard timeout + status; REST polish parity + refined_text
polish; warmup builds the recognizer and a second session reuses it; the panel
honesty note. Backend refinement/capture_ws/capture/sherpa suites, CJK + route
inventory gates, full vitest (733), lint (0 errors) and format all green.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Backend:
- core/db_backup.py: WAL-safe SQLite snapshot to omnivoice.db.backup-<version>-<n>
before pending alembic migrations run; keep newest 3, prune older; skip >500MB
with a log line. Restore is never automatic.
- core/db.py: _run_alembic_upgrade now plans the run (up_to_date / pending /
unknown_revision), snapshots first when migrations will execute, and raises
MigrationError on a mid-flight failure — startup stops with the backup path
named instead of continuing on a half-migrated DB. The #552/#547
unknown-revision class stays non-fatal (warn + additive reconcile).
- core/changelog.py + GET /api/settings/changelog: parse the shipped
CHANGELOG.md (single-line and wrapped bullet styles) into structured releases.
- GET /api/settings/db-backup: newest pre-migration backup for the panel.
Rust (bootstrap.rs):
- #314 heal guard: an exit-signature match alone can no longer delete the venv —
venv_rebuild_justified requires a structural problem or a failed direct
interpreter probe; a venv that probes healthy is kept and the real error
surfaced. Drift/repair remains in-place `uv sync` (non-destructive).
- CHANGELOG.md now ships as a bundle resource and is copied/refreshed into the
project dir so the changelog endpoint works in packaged installs.
Frontend (Settings → Updates):
- Available update shows its actual release notes (updater metadata body)
through a safe markdown-lite renderer (text nodes only, refs stay plain).
- "Your data is backed up before every update" line with the latest backup
timestamp from the new endpoint.
- "What's new" changelog reader (accordion, newest expanded) over the shipped
CHANGELOG.md; GitHub releases list reuses the same renderer.
- One-time, non-blocking "What's new" footer pill after an update
(persisted last-seen version; fresh installs baseline silently).
- All strings via t() with en keys (other locales fall back to English).
Tests: db backup/rotation/failure-path units, migration-safety units, changelog
parser (both bullet styles + real CHANGELOG.md), endpoint tests, route
inventory regenerated, Rust decision-logic + probe tests, vitest suites for
renderer/viewer/panel/pill logic.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Live-audit fixes for the Models settings surface — the P1s were cases where
the feature silently didn't work for the user.
P1-A — Async install errors were invisible. The `install_error` SSE event
carries excellent mirror-aware text (#890 core/failure.py), but the Model
Store auto-purged the errored row ~800ms later (same as a success) and the
first-run WizardLibrary DELETED the row without ever reading `ev.error`. The
SSE→rowState reduction is now a pure, tested reducer (downloadReducer.js /
reduceWizardDownloadEvent); only SUCCESS terminals auto-purge
(isAutoPurgeTerminal), an error persists on the row with inline text + Retry +
Dismiss (Model Store) / a Retry (wizard).
P1-B — No disk-space check on install. `POST /models/install` now compares the
FDL-05 plan's exact `to_download_bytes` (+ MIN_FREE_GB headroom) against
`shutil.disk_usage(cache).free` BEFORE downloading and emits an actionable
install_error naming the sizes (needs X, headroom Y, have Z) instead of failing
mid-download. `/models` also surfaces `disk_free_gb` in the header. MIN_FREE_GB
+ disk_free_bytes are single-sourced in setup/models.py (wizard delegates).
P2-A — Wired the orphaned cancel. `POST /models/install/cancel` (FDL-11) had
zero frontend refs; the in-progress row now shows a Cancel button that calls it
and transitions the row to install_cancelled.
P2-B — Honest restart_required. The HF-mirror PUT returned restart_required:true
unconditionally; it now returns true only when the persisted value actually
changed, with accurate copy (Model Store downloads use the new mirror
immediately — resolved per-call; only transformers model loads need a restart).
P3 — i18n the un-localized panels (HFMirrorPanel, ApiKeysPanel source
labels/help/status, MODEL_ROLE_LABEL) via new en.json keys; other locales fall
back to en.
Tests: new tests/test_install_disk_space.py (reject-when-over-budget incl. the
worker wiring; allow-when-fits; degrade on unknown size/unprobeable volume),
updated tests/test_hf_mirror_settings.py (change-only restart_required), and new
frontend reducer + column-render tests for install_error persistence, Retry,
Dismiss, and Cancel.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Settings → Storage now opens with a Disk usage panel backed by a new
loopback-gated GET /api/settings/storage endpoint:
- Per-volume totals (grouped by st_dev) + du-style sizes for everything
the app owns: the HF model cache (with its ~10 largest models), the
app data dir broken into voices/outputs/dub_jobs/batch/preview/
database/logs/other subtotals, engine venvs (backend/engines/*/.venv
+ the app venv), and omnivoice* entries in the OS temp dir.
- Bounded scanning: per-category 10 s deadline → partial totals with an
"unreadable" warning instead of a hung request; results cached
in-process for 5 minutes, ?refresh=1 forces a rescan; the walk runs
in a worker thread so the event loop never blocks.
- Server-side warnings reuse the setup wizard's MIN_FREE_GB: free <
min → critical, free < 2×min → low, volume holding the cache/data
>90% full → volume_pressure, unreadable/timed-out paths → unreadable.
The panel renders severity-colored banners, a data-volume gauge,
proportion bars per category, Open-folder buttons (existing
/export/reveal pattern), a Model Store jump for reclaiming model
space, and the existing clear-logs action on the logs row. A critical
warning is also surfaced outside Settings via the app-wide toast —
once per session. All strings via i18n (en fallback).
Tests: tests/test_storage_report.py (sizes, thresholds, cache/refresh,
timeout partials, endpoint wiring) + StorageUsagePanel.test.jsx
(categories, banners, once-per-session toast, refresh=1, error state);
route added to tests/fixtures/api_routes.txt via the dump script.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Live-audit fixes for Settings → LLM Providers / Translation.
Retire the legacy LLMEndpointPanel from the UI (backend endpoint kept).
TranslationTab no longer embeds the inline endpoint panel — it now points to
Settings → LLM Providers (openSettingsTab('llm-providers')), which fully covers
it via the `custom` provider (a lone TRANSLATE_BASE_URL still resolves to
`custom`). Kills the panel's lying "reachable" badge, its hardcoded-English
strings, and one of three duplicate TRANSLATE_* surfaces. The third duplicate —
TranslationTab's "Provider keys" collapsible — drops the TRANSLATE_* trio
(now owned by LLM Providers) and keeps only the DeepL/Microsoft translator
keys; its toast no longer claims "saved for session" (these are in
PERSISTENT_KEYS, restored at startup). GET/PUT /api/settings/llm-endpoint is
untouched (DubTab gates Cinematic off it; tests + route inventory cover it).
Surface env overrides. describe() now reports base_url_from_env / model_from_env
/ active_from_env (mirroring key_from_env). The panel disables env-pinned
base_url/model/account fields with an explainer, and — when
LLM_DEFAULT_PROVIDER pins the active provider — disables make-active and shows a
banner, instead of silently reverting the user's edit / no-oping the button.
Fix the Cloudflare account-id flow (broken two ways): describe() now returns the
stored account_id (the field no longer resets to empty) and shows the RAW
base_url template ({account_id} kept literal) instead of the substituted value;
save_overrides drops a base_url override equal to the built-in default, so the
UI posting the shown value back can't freeze the URL — later account-id changes
take effect again (also self-heals if a default URL changes in a release).
Fast-fail the Test / Fetch-models probes. Pass max_retries=0 to the probe
OpenAI clients so a 429/timeout returns in seconds instead of ~34s on the SDK's
default retry ladder. /models now returns truncated:true when capped at 200 and
the UI hint reads "first 200 shown".
Tests: registry env-flag + Cloudflare round-trip/no-freeze regressions; router
truncation + max_retries=0 assertions; panel disabled+explained + banner;
new TranslationTab test (pointer wired, legacy panel gone, TRANSLATE_* dropped).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Six live-audit fixes for the Engines settings surface:
- P1-A: the Supertonic license dialog was dead since #101 — `useState`
threw away the state value (`const [, setLicenseDialogFor]`) and the
imported dialog was never mounted, so "Accept license" did nothing.
Keep the value and render LICENSE_DIALOGS[selected] with open/onClose/
onAccepted (accept → matrix reload).
- P1-B: the matrix went stale after "Use" — active badge, Use buttons and
family-tab captions stayed old until a manual Refresh. Await onSelect,
then reload() so the picked engine reflects immediately.
- P2-A: consume the /engines/select routing echo. A `cpu_fallback` pick now
shows a warn-tone toast naming the reason ("running on CPU — …"); the
plain success toast stays for accelerated/cpu_only. Shared helper used by
both Settings→Engines and the first-run WizardLibrary.
- P2-B: a CPU-native engine (gpu_compat == ("cpu",)) has nothing to fall
back FROM, yet on a GPU/MPS host it was mis-classed cpu_fallback (warn).
New routing rule classifies ("cpu",) as cpu_only (neutral) on any
accelerator host; multi-target engines that could accelerate elsewhere
are untouched.
- P3-A: the routing reason was only a badge `title` (unreachable on
keyboard/touch) — surface it as small visible text under the badge.
- P3-B: an in-process "Test engine" pass is an import/liveness check, not a
synthesis test — label it "deps OK" instead of a misleading "0 ms"
latency; subprocess rows keep their real ping latency.
Adds RTL + unit regression tests for all six and updates the routing unit
tests to the corrected cpu-native intent. i18n keys added to en.json.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The seven launchpad feature cards now render as an overlapping deck fanned
left-to-right: each card sits in a fan slot with a subtle tilt (±4°) and
vertical stagger (≤14px), peeking ~33% out from under its right neighbor.
Every card face carries its lucide icon + name on the always-visible peek
edge, a one-line description, and a decorative CSS-only animated waveform
strip in the card's accent color (stagger-delayed scaleY bars, aria-hidden,
static under prefers-reduced-motion).
Hovering OR keyboard-focusing any card brings it fully forward — it
straightens, scales up and takes the top of the stack while every other
card slides toward it and tucks underneath (dimmed, scaled down, overlap
increased). The raise/tuck classes are React-state-driven so pointer and
focus share one code path and tests can assert it. Fixed deck height —
zero layout jump.
Navigation targets, i18n keys, per-feature accent hues and profile/project
counts are unchanged; both renderings share a single feature list so they
can't drift. On shell-narrow/shell-mini (the app-container's own width
classes — not viewport @media, per the UI-scale rationale in App.jsx) the
deck degrades to the pre-existing flat ActionCard grid, tracked live via
MutationObserver (new useShellNarrow hook), keeping 900×600 usable.
New LaunchpadDeck.test.jsx covers: 7 cards in canonical order, every
navigation target (incl. clone/design → studio + defineMethod), raise/tuck
partitioning for hover and focus, waveform decorativeness, the narrow
fallback under both shell classes, and the runtime class flip.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(bootstrap): port cuDNN 8 (NVIDIA CUDA GPU) + VC++ redist install into ensure_venv_ready()
* fix(bootstrap): address #869 review — drop dead VC++ half, cache negative CUDA probe, gate on ROCm, sync docs
Per maintainer review on #869:
1. Drop the VC++ Redistributable half: LoadLibraryA("vcruntime140.dll")
from the running Tauri exe is a tautology (the exe itself links the
MSVC CRT, so the process wouldn't be running without it), and torch's
real failure mode is msvcp140.dll inside the venv python process.
Dead code removed; a comment records why for future readers.
2. Stop taxing every non-CUDA launch: a negative torch probe (CPU /
Intel / AMD — most installs) is now cached in a
.venv/.cudnn8_probe_negative marker, so the synchronous `import
torch` runs at most once per venv lifetime. Invalidated on every
path that can change the torch build (drift sync #307, repair sync,
first-run sync, ROCm reinstall) and implicitly by a venv rebuild.
A probe that fails to run cleanly is skipped WITHOUT caching so a
transient error can't wedge a real CUDA machine.
3. Rewrite docs/install/troubleshooting.md §10 to the actual root
cause: packaged installs never had the cudnn8_compat libs (so
reinstalling never restored them); the bootstrap now installs them
automatically on CUDA machines, with the manual uv pip command as
the offline fallback and PyTorch Whisper as the sidestep.
4. Gate the ~700 MB nvidia-cudnn-cu12 download on the venv torch being
a real CUDA build: the probe now reports 'hip' before checking
cuda.is_available() (which HIP spoofs), so opt-in ROCm installs
(#124) never fetch the CUDA wheel.
Also reflow the CHANGELOG entry to house style (bold one-line lead,
1-3 lines of why, (#827, #869) refs) and extend the bootstrap unit
tests: classify_cuda_probe verdict mapping and the marker
write/invalidate round-trip (6 cuDNN tests total, 43 lib tests green).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Residual A — the chunked dub-stream had a PARALLEL wedge mechanism (its own
ping-loop timeout, its own _reset_pool_on_wedge, a dead-end "Try restarting
the server" message). A wedged chunk now routes through the SAME
run_transcribe_guarded bound+reset as the whole-file paths (#731/#851): the
guard resets the poisoned pool once per wedged attempt (no double-reset on
retry) and the user sees the actionable ASRTimeoutError. The reset logic is
extracted to asr_backend.reset_pool_after_wedge — one shared mechanism, so
the semantics can't drift again. run_transcribe_guarded also gains a
timeout_env param so chunk errors name OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S
instead of the whole-file knob.
Residual B — the crash-isolated ASR sidecar (#393, faster-whisper-isolated)
is wired as an explicit ESCAPE HATCH, not a default:
- selectable end-to-end: Settings engine list gets an explanatory
install_hint; honest gpu_compat ("cuda","cpu" — it wraps the same
CTranslate2 engine as faster-whisper); get_active_asr_backend now hands
back a process-wide singleton for subprocess-isolated backends (a fresh
instance per request would leak atexit hooks and respawn the sidecar —
reloading its model — on every transcribe).
- on the SECOND consecutive guarded timeout-with-reset in one session
(resets aren't recovering the hang; the wedged thread keeps its VRAM),
the error the user sees + the log recommend switching to the isolated
engine in Settings → Engines. Never auto-switched (owner rule: no silent
behavior divergence); a completed transcribe resets the streak.
Tests (fail-before/pass-after verified against origin/main): wedged-chunk
SSE integration (reset count + actionable error + recommendation surfaces),
consecutive-timeout streak (fires at 2, resets on success, suppressed when
already on the isolated engine), timeout_env parametrization, shared-reset
helper, isolated backend in list_backends with hint + honest availability,
singleton caching, gpu_compat matrix entry. Docs: troubleshooting §14 gains
the chunk knob + escape-hatch guidance.
Closes the residuals tracked on #730.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Root cause: LLM provider selection reads three process-global surfaces —
env vars (LLM_DEFAULT_PROVIDER, per-provider *_API_KEY/*_BASE_URL,
TRANSLATE_*), the SQLite settings store (llm.active_provider & co.), and
prefs.json (llm_backend). Importing `main` (TestClient fixtures do)
dotenv-loads the developer's .env and ~/.config/omnivoice/env straight
into os.environ, and several tests/endpoints mutate these surfaces
without teardown — so whichever test imported the app first flipped what
later tests' active_backend_id()/active_provider_id() resolved to
(order-dependent failures in test_engines.py,
test_llm_endpoint_settings.py, test_llm_providers.py).
Fix the class, not the instances:
- tests/conftest.py: redirect OMNIVOICE_DATA_DIR to a per-session tmp dir
and OMNIVOICE_ENV_FILE into it (before collection freezes
core.config.DATA_DIR), so tests never read or write the developer's
real app state and local runs behave like clean CI.
- tests/conftest.py: autouse `_isolate_llm_provider_state` fixture
snapshots env (derived from llm_providers._PROVIDERS, so new providers
are guarded automatically), llm.* / secret.llm_key.* settings rows, and
the prefs llm_backend/env.TRANSLATE* keys before every test and
restores them exactly afterwards.
- shared `clean_llm_env` fixture clears the FULL provider env surface;
the four LLM test modules' hand-picked partial delenv lists (which left
e.g. LLM_DEFAULT_PROVIDER / OPENROUTER_API_KEY standing) now use it.
- tests/test_llm_state_isolation.py: deterministic fail-before/pass-after
regression pair — pollutes all three surfaces without cleanup, then
asserts the guard restored them.
Verified: the issue's two-test repro passes; the five LLM-related test
files pass in order; full suite green (2046 passed, 20 skipped,
10 xfailed, 4 xpassed).
Fixes#878
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
After an unclean shutdown (Windows BSOD), the WebView2 profile cache
(%LOCALAPPDATA%\com.debpalash.omnivoice-studio\EBWebView) can corrupt:
Tauri's IPC custom protocol fails AND the postMessage fallback breaks,
so invoke() hangs forever. useBootstrapStage's poll loop rode entirely
on that IPC — a hung bootstrap_status call silently killed the loop and
the splash sat at "preparing" forever, even with a fully healthy
backend answering over plain HTTP.
Class fix, three parts:
- splashWatchdog.js: IPC-independent escape hatch. If no IPC signal
arrives within 10s, poll GET /health over plain HTTP; healthy →
proceed to the app as if 'ready' was received (console.warn
breadcrumb so diagnostic bundles carry it). Any successful IPC
response disarms it for good.
- Recovery panel (stage 'ipc_lost'): if neither IPC nor HTTP succeed
within 45s, show an actionable panel instead of the infinite
spinner — "Open logs" (with an inline path fallback when IPC is
dead) and, Windows-only and only in this error state, "Repair and
restart". Health polling continues behind the panel so a slow
first-run install with broken IPC still reaches the app.
- clear_webview_cache_and_relaunch (Rust): writes a marker and
relaunches; the fresh process deletes EBWebView at the top of run()
before any webview exists (WebView2 holds locks while running),
with a bounded retry while the old instance exits. Runtime cfg!
guards keep the whole path compiling on every platform.
Tauri 2 exposes no reliable flag for the postMessage-fallback mode
(closure-local in its injected ipc.js), so the logged detector is the
observable combination: zero IPC signals + working plain HTTP.
Fail-before/pass-after regression tests: hung invoke + healthy HTTP →
ready; hung invoke + dead backend → recovery panel, then auto-continue;
working IPC → normal path untouched, zero HTTP polling. Plus watchdog
state-machine unit tests and recovery-panel render/interaction tests
(6/7 fail on the pre-fix component). Troubleshooting doc gains the
matching section (docs-sync).
Fixes#879
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
A kittentts first-use HuggingFace download died with httpx's "Cannot send
a request, as the client has been closed", and the generation error
classifier's catch-all fallback told the user (CPU-only ~80 MB ONNX engine,
12 GB-VRAM box) they were OUT OF MEMORY and to press Flush — the wrong
remedy for a network failure.
Three-part class fix:
- generation.py: new #880 branch (before the OOM hint) classifies
httpx/requests transport failures — matched over the whole exception
chain (type names like ConnectError/ReadTimeout plus stringified
signatures like "client has been closed") — as a download/network
problem with a retry/check-connection remedy.
- generation.py (the real class bug): the OOM hint is no longer the
catch-all. It now requires an actual OOM signature (typed
OutOfMemoryError/MemoryError anywhere in the chain, or CUDA/MPS/CPU
allocator wording); genuinely unknown errors surface as unrecognized
with the underlying detail instead of a false "ran out of memory".
- tts_backend.py: KittenTTS's first-use load retries exactly once with a
fresh HF Hub client (huggingface_hub.utils.close_session()) on the
specific closed-client failure — hub ≥1.x shares one global httpx
client, and a closed one is recoverable, so the download self-heals
instead of failing the generation.
Fail-before/pass-after tests: classifier (closed-client message, wrapped
httpx type names, unknown error, real OOM signatures incl. typed
OutOfMemoryError, WinError 1455) + the retry helper (recovers once,
walks the chain, no retry on unrelated errors, single-shot).
Fixes#880
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
When a non-default HF_ENDPOINT (Settings → Models → Hugging Face mirror,
e.g. hf-mirror.com) is configured and a model load/download fails with a
connectivity error, the raw transformers message ("We couldn't connect to
'https://hf-mirror.com' to load the files…") leaked to the UI as a bare 500
with no next step.
Class fix — one shared classifier in core/failure.py covers every surface:
- classify()/build_failure(): new HF_MIRROR_UNREACHABLE class with a dynamic
hint that names the configured mirror, says it may be down, points at
Settings → Models → Hugging Face mirror, suggests the official endpoint
when the model isn't cached, and notes the restart requirement (HF reads
HF_ENDPOINT at backend start). Checked before the video-download network
class so a model download's "timed out" no longer gets the "video server"
hint. Feeds /model/status and every build_failure event (dub, tasks).
- main.py global 500 handler: appends the hint to the surfaced detail, so
ALL routes that can leak a model-load error benefit (generate, dub,
archetypes, …), not just TTS generate.
- setup/download.py install SSE: the install_error event gets the same hint.
- error_journal: "couldn't connect to" / "max retries exceeded" now classify
as NETWORK_ERROR (was UNKNOWN) for auto-attached bug reports.
- model_manager (#886 family): the "cache incomplete and could not be
auto-repaired" message now names WHY the auto-repair failed (mirror
outage, offline mode, full disk no longer read identically), which also
lets the mirror hint fire on that surface when applicable.
Fail-before/pass-after regression tests in tests/test_hf_mirror_error_class.py
(12 of 13 fail on main).
Fixes#874
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
torch >=2.3 ships no macOS x86_64 wheels (transformers 5.x needs torch >=2.6),
so `uv sync` can never resolve on an Intel Mac — per the platform-parity rule
the honest option is declaring the platform unsupported, not letting first
launch die in a raw resolver error:
- bootstrap.rs: pre-check on macOS x86_64 before any venv create / uv sync
(first-run AND repair paths) fails fast with an actionable message
(remote-backend escape hatch + docs link); healthy pre-torch-bump venvs are
deliberately untouched. Unit test pins the message's load-bearing phrases.
- BootstrapSplash: routes the failure to a dedicated localized hint
(bootstrap.hint_intel_mac, all 21 locales) and suppresses the useless
Retry-oriented hints for it.
- README + docs/install/macos.md (+ troubleshooting #9): every Intel-Mac
support claim now says UI-installs-but-backend-cannot-run, including the
from-source path (also broken); remote backend documented as the only use.
- release.yml: #889 note on the macos-15-intel leg — artifact is UI-only;
keep-or-drop is an owner call, deliberately not changed here.
- docs/install/windows.md: new "Portable install (Windows)" section promised
in #766 — custom MSI wizard folder / msiexec INSTALLDIR=..., what lives in
OmniVoiceStudio-Data next to the exe, and the Program-Files-greyed-out why.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The per-segment call already has a 45s timeout and concurrency is capped, but a
slow or rate-limited provider on a large dub (hundreds of segments) can still keep
the "Translating…" spinner spinning for minutes as segments queue through the
bounded pool. There was no ceiling on the *whole* pass.
Add an overall wall-clock budget, OMNIVOICE_CINEMATIC_BUDGET_S (default 180s,
<=0 disables). Segments that finish in time keep their cinematic refine; any still
in-flight when the budget hits is cancelled and degrades to its literal (Fast)
translation with error="cinematic-budget", so the translate ALWAYS returns instead
of hanging. Order and length of the result are preserved. Abandoned executor
threads follow the same fire-and-forget pattern as the GPU-pool wedge guard (#730).
Regression tests: a 3s-per-segment refine under a 0.3s budget returns in <2s with
literal fallbacks; budget<=0 runs every segment to completion.
Co-authored-by: mergetest <test@local>
Two bugs from real reports:
1. LLM not wired — translator._llm_client()/_llm_model() read TRANSLATE_*/OPENAI_*
directly, bypassing the LLM Providers registry (#854). So a provider set up
in Settings → LLM Providers never powered Cinematic/Autofit. Now resolves the
ACTIVE provider (base_url/key/model) via llm_providers; the 'custom' provider
still maps TRANSLATE_* so legacy env setups keep working.
2. Transcription 'missing the beginning' — the chunked dub transcribe dropped a
whole chunk's window on failure/timeout (returned empty segments, no retry).
A transient wedge on the FIRST chunk (whisperx cold-loads its model there, the
#730 hang) therefore lost the start and left only middle+end. Now retries a
failed/timed-out chunk once on a fresh pool (OMNIVOICE_TRANSCRIBE_CHUNK_ATTEMPTS,
default 2) so the recovered chunk fills the hole.
Imports + dub_transcribe/translator/llm_providers tests green.
Co-authored-by: mergetest <test@local>
Redesign ArchetypeCard for a calmer visual hierarchy and design-token
surfaces, no behavior change (all props/handlers/loading states identical).
- Replace hardcoded surfaces with tokens: chips/wand/preview bg → tokens
(bg-white/[0.05] → --color-bg-elev-2, bg-white/[0.03] hover → --chrome-hover-bg),
text → --color-fg / --color-fg-muted / --color-fg-subtle, and the literal
#1d2021 hover text on Use voice → --color-fg-inverse.
- Borderless by direction: drop the hover/state border classes on the action
buttons and the card; convey hover via background tint + text color and the
playing state via an accent box-shadow ring (no literal/token borders).
- Hierarchy: name is the focal point (semibold, --color-fg); metadata line is
smaller/muted (--color-fg-muted) so it recedes.
- Chip row renders only when there are chips (no empty min-h reserve); the grid
stretches rows so mt-auto still bottom-aligns actions.
- Accent used tastefully: tinted Use voice → solid accent on hover/focus with
inverse text; favorite star stays subtle until hover/active. Focus-visible
rings intact.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Stepper (inline): smaller step gap/font (0.66rem), 19px icons, 10px connectors
→ the 6 stages take far less width so title + actions fit before wrapping.
- Title: lighter weight (medium/0.78rem), normal-case, min-w-0 truncation; meta
0.68rem; project name truncates too. Tighter header padding + gaps.
- Removed the hardcoded header border + border-left divider (borderless) and the
rgba bg → token --color-bg-elev-1.
Co-authored-by: mergetest <test@local>
* feat(engines): Confucius4-TTS scaffold (opt-in, needs hardware validation) (#590)
Plumbing for netease-youdao's Confucius4-TTS — LLM-based 14-language
cross-lingual zero-shot voice cloning, Apache-2.0 — mirroring the opt-in
subprocess-venv pattern of dots.tts / MOSS-TTS-v1.5:
- engines/confucius4/__init__.py: Confucius4Backend(SubprocessBackend), CUDA-only
(gpu_compat=("cuda",)), language passthrough, ref_audio→prompt_wav. is_available
reports a clear reason and stays unavailable without a clone.
- bootstrap.py: dedicated Python 3.10 venv resolution (user clone-level venv →
package venv → uv bootstrap), import-probed on `confuciustts`.
- main.py: sidecar speaking the same length-prefixed JSON-over-stdio protocol as
the other engines, calling ConfuciusTTS(config_path, device).generate(text,
lang, prompt_wav).
- Registered lazily in _LAZY_REGISTRY; docs/engines/confucius4-tts.md.
Gated behind OMNIVOICE_CONFUCIUS4_TTS_DIR — inert on every default install, never
imports the upstream package unless opted in. The sidecar's synthesis API is
derived from the upstream README and is NOT yet validated on a CUDA box; the
module, docs, and CHANGELOG all flag this. 4 tests pin registration +
inert-by-default. No version bump.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(#590): register Confucius4 in install-hints + docs inventory (CI gates)
Registering the engine tripped two completeness gates: every backend needs an
install_hint (test_issue_fixes) and every registry engine must appear in the
tts_engines docs inventory + README (check-docs-drift). Add the install_hint,
the docs/features.yaml entry, and the README engine-table row (with the scaffold
caveat). Docs-drift clean; gates pass. No version bump.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(confucius4): finalize — validate API vs upstream, add 22 sidecar unit tests, document external deps (Amphion/w2v-bert/weights)
The synthesis API (ConfuciusTTS(config_path, device) → generate(text, lang,
prompt_wav) → tensor, model.sample_rate) is confirmed against the
netease-youdao/Confucius4-TTS repo. Added runnable unit tests for the sidecar's
pure logic (language norm, tensor→PCM mono/stereo/clip, config resolution, wire
framing, synthesize dispatch with the model mocked) — 22 cases, all green.
Docs now list the external deps (Amphion/MaskGCT codec, facebook/w2v-bert-2.0,
~2-4GB HF checkpoint) and CUDA 12.6. Softened the scaffold warnings to reflect
API-validated + unit-tested status; a one-time CUDA GPU run is still needed to
confirm live inference + true sample rate.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 1 — physically remove the token-based structural border utilities that
kept rendering stray frames (history panels, cards, rows, settings) whenever a
`--*-border` token didn't resolve transparent (theme re-declare, or bare
`border` = currentColor under Tailwind v4). Converted every
`border[-trbl]-[var(--chrome-border…)]` / `[var(--color-border…)]` (83
occurrences across 32 components/pages) to `border-transparent` — keeps the 1px
box (no layout shift, matches the badge.tsx convention), drops the frame, and
active/selected state stays visible via the existing bg-tint/text cues. Also
converted button.tsx's `border-border`/`border-input` variants and Panel's
header divider. Kept: focus-visible rings, aria-invalid, dashed drop-zones, and
the waveform/segment editor. Strengthened tests/test_no_literal_borders.py with
`test_no_token_border_utilities_in_jsx` so a reintroduced token border fails CI
(allowlists the editor + shadcn form-control primitives).
Task 2 — aliased the accent family in the base :root to the themed brand token
(`--chrome-accent: var(--color-brand)`, `-bg`/`-border` via color-mix), so
donate/support/commercial CTAs, active tabs, .btn-primary, status pills and
GoalBar/Pip track the active theme instead of the fixed pink. Replaced the
hardcoded `#d3869b`/`#f3a5b6`/`rgba(243,165,182,…)` pinks and the DONATE_HUE
constant in SupportPage.jsx with `var(--color-brand)` tints.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Native <select> chrome (option popups, scrollbars, form UA elements) rendered
in the OS light scheme on dark themes because `color-scheme` was never set as a
property (only a `prefers-color-scheme: light` media query existed, which is
not the same thing). The dropdown caret was also a hardcoded gray SVG that
ignored theme + accent. Owner report: gallery/install/language selects looked
wrong for accent + dark/light.
- Declare `color-scheme: dark` on :root (default Gruvbox Dark) and re-assert it
on every [data-theme] block. All six shipped themes are dark (verified by
their real --color-bg lightness: midnight #0f172a, nord #2e3440, solarized
#002b36, rose-pine #191724, catppuccin #1e1e2e), so all get `dark`. The empty
auto/light scaffold is left at dark (no light theme ships yet; a light value
there would mismatch the still-dark surface) with a note for when one lands.
- Replace the hardcoded %23a1a1aa caret in select.input-base and .ui-select with
a single --select-caret token, overridden per theme to that theme's muted
foreground (a background-image SVG can't read a CSS var, so the color is baked
per theme). Both selects consume the one token (DRY).
- Paint <option>/<optgroup> from --color-bg-elev-1 / --chrome-fg so Chromium
(Windows/Linux) popups match; macOS WebKit popups follow color-scheme.
- Give selects a themed focus-visible ring (--color-ring → --color-brand) to
match the buttons/checkboxes tokenized last phase, instead of the
non-theme-tracking --chrome-accent.
Borderless guardrail and :focus-visible rings intact. Covers every named
native select (DubTab/AudiobookTab language, DubLeftColumn engine, gallery,
ui/Input.jsx Select, VoicePreview, StoriesEditor, ExportModal, DubSegmentRow)
via the shared input-base/ui-select rules — no per-call-site edits needed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#857 zeroed the border TOKENS but left token-based border utilities
(border-t-[var(--chrome-border-strong,…)], border-b-[var(--chrome-border)]) in
the JSX — a fragile indirection that still renders a line if the token doesn't
resolve transparent (stale HMR / the pre-zero rgba base value). Per 'no borders
whatsoever', remove the utilities outright from the WorkspaceHistory (dub +
regular history) and WorkspaceVoices (active-voice) panel frames; the
active-voice card keeps its background tint as the selection cue.
Co-authored-by: mergetest <test@local>
* Revert "chore(version): main -> 0.3.9 after v0.3.8 release"
This reverts commit 7489bef085.
* chore(release): gate the post-release version-bump behind AUTO_VERSION_BUMP (owner controls bumps)
Owner decision (2026-07-01): keep main pinned to the released version and bump
only on explicit request. The version-bump job now runs only when the repo
variable AUTO_VERSION_BUMP == 'true' (default off), so releasing no longer
auto-rolls main to +1. Documented the override in CLAUDE.md's versioning rule.
---------
Co-authored-by: mergetest <test@local>
* fix(diagnostics): harden the bug-report scrubber against 5 audited leak/correctness gaps
Audit of the (already-on-main) diagnostics/bug-report feature found the opt-in/
no-telemetry contract clean but 5 real gaps in the redaction + URL assembly.
Fixed in both scrub twins (backend/core/scrub.py + frontend utils/bugReport.js):
- Windows home paths with lowercase 'users' now redact (case-insensitive) — a
spec-level PII leak: c:\users\john\… kept the username verbatim.
- Broadened credential shapes (JWT/Bearer, Google AIza, Slack xox, AWS AKIA) +
a URL query-secret pass (?token=/?api_key=… → value redacted, name kept) so a
secret propagated from a backend error into error.message/.stack can't reach a
public issue. The webview has no env backstop, so these shapes are its only
defense.
- Boundary-safe $HOME replace: a home of /Users/john no longer rewrites
/Users/johnny to '~ny' (fragment leak + path mangling).
- Bug-report URL now bounds the URL-ENCODED body length (~7k), not the raw
length — a dense 6k markdown body encoded to ~9k and blew past GitHub's ceiling
(silent truncation / failed open). Message body is capped too.
- 9 new scrub regressions (backend) + 9 (frontend); all green. No API/behavior
change beyond stricter redaction.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(changelog): note the bug-report scrubber hardening in [0.3.8] (#856)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Merge the two stacked dub-editor header rows into a single line to save
vertical space. The pipeline stepper (Upload → … → Export) is now inlined
onto the DubHeader row alongside the title, duration · N segs metadata, and
the primary action buttons (Generate Dub / QC / Export). All step
active/complete styling, data bindings, and button onClick/disabled/loading
props carry over unchanged.
- DubPipelineStepper gains an `inline` prop → `dub-stepper--inline` variant
(drops the standalone border-bottom/padding, tighter connectors).
- DubHeader renders the inline stepper as the leftmost element; the row is
flex-wrap so it wraps gracefully on narrow windows.
- DubTab only renders the standalone spine before the editor exists, so the
stepper is never duplicated once the editor (and inline spine) is shown.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(llm): multi-provider LLM registry + encrypted key storage + settings API (v0.3.8, phase 1)
Foundation for the LLM Providers settings page and timing-aware (Autofit)
translation. Every provider in the shipped .env is OpenAI-compatible, so one
client drives all of them via a registry instead of a class-per-provider.
- llm_providers.py: registry of 16 providers (OpenAI, OpenRouter, Groq,
Cerebras, Google AI, Mistral, Cohere, NVIDIA, GitHub Models, Cloudflare,
HuggingFace, SambaNova, SiliconFlow, + local Ollama/LM Studio + Custom).
Field resolution precedence env → encrypted store → default; active-provider
selection (LLM_DEFAULT_PROVIDER → stored → first keyed remote; local requires
explicit pick so we never assume a local server is up). Legacy TRANSLATE_*
maps to the Custom provider (keyless-with-base_url preserved).
- settings_store.py: generic ENCRYPTED secrets (get/set/clear_secret,
list_secret_names) reusing the HF-token Fernet path; get_text/set_text now
refuse the secret namespace (no ciphertext leak).
- llm_backend.py: OpenAICompatBackend resolves the active provider's
base_url/key/model from the registry. Backward-compatible.
- settings API: GET /llm-providers, PUT /llm-providers/{id} (encrypted key +
overrides), POST /llm-providers/active, POST /llm-providers/{id}/test.
Loopback-gated; never returns key material.
- 11 registry tests; existing llm-endpoint/openai-available tests still green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(settings): LLM Providers page — configure any provider's key/URL/model + Test + set active (v0.3.8, phase 2)
New Settings → System → LLM Providers pane (Brain icon, searchable). Lists all
16 registry providers; pick one to configure its encrypted API key, base URL,
model (and Cloudflare account id), Test the connection with one round-trip, and
'Save & use for translation' to make it the active provider for Cinematic/
Autofit. Keys are write-only from the UI (masked placeholder, never echoed);
env-set keys show as read-only. Local providers (Ollama/LM Studio) need no key.
- LLMProvidersPanel.jsx: provider selector + per-provider config + Test/activate,
following the LLMEndpointPanel pattern (apiJson/apiFetch/apiPost, SettingsSection
primitives).
- settingsCategories.jsx: new 'llm-providers' category under System + Brain icon.
- Settings.jsx: route the category to the panel.
- en.json: settings.llm_providers label.
- Frontend build passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(translate): Autofit quality style + one-click LLM setup from the dub menu (v0.3.8, phases 3-4)
Autofit = Cinematic + a strict 'never exceed the segment time' fit. The LLM
rewrites each translated line so its target-language reading time fits within
the slot, preserving the video timing without harsh audio time-stretch.
Backend:
- speech_rate.adjust_for_slot(strict=): strict caps the accepted upper ratio at
1.0 (fit within slot) vs Cinematic's 1.08; best-effort, degrades gracefully
with no LLM.
- dub_translate: quality='autofit' takes the LLM refine path and runs the fit
pass with strict=True; reports quality_used accurately.
- TranslateRequest.quality doc note.
Frontend:
- 'autofit' added to the quality control (Settings Translation + dub menu) and
the TranslateQuality type.
- Dub menu: picking Cinematic/Autofit with no LLM no longer dead-ends on a toast
— it offers a one-click 'Set up' that routes to Settings → LLM Providers,
with copy about fitting translations to segment time (#838).
- 4 strict-fit tests; frontend build green; i18n keys added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(translate): document Autofit quality + the LLM Providers page (v0.3.8, phase 5)
- CHANGELOG [0.3.8] Added: Autofit style + LLM Providers page.
- docs/dubbing/translation-engines.md: Fast/Autofit/Cinematic quality section
and an LLM Providers setup section (16 providers, encrypted keys, offline
Ollama/LM Studio, env overrides).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(api): add /api/settings/llm-providers routes to the route-inventory snapshot
Regenerated tests/fixtures/api_routes.txt for the 4 new LLM-providers endpoints
so test_route_inventory_matches_snapshot passes (keep-main-green).
* style(frontend): oxfmt the LLM Providers panel + dub quality control (format:check green)
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(settings): contain + tighten the whole Settings surface (measure cap, container-query stacking, wrap the shared rows)
Two systemic issues drove 'too spread out' + 'elements go out of view' across
many Settings pages:
1. Spread — .settings-content capped at 1280px, so on wide windows every
label-left/control-right row left a huge void. Introduce a --settings-measure
token (720px, macOS-like) + --settings-rail, and cap the content to it,
left-aligned under the nav. One token now controls the reading width.
2. Overflow + bad responsiveness — the row stack break was a *viewport* media
query (560px), but the 168px nav rail means a 760px-viewport window only has
~530px of content, so rows went side-by-side in a cramped box. Make
.settings-content a container (container-type: inline-size) and stack on the
CONTENT width via @container, keeping the viewport @media as a fallback for
the .st-row instances used outside Settings (Splash/FirstRun/Dub/SetupWizard).
3. The shared .perfpanel__row (button/badge row reused by 6+ panels:
RemoteBackend, HFMirror, LLMEndpoint, Pronunciation, MCPBindings, …) was an
inline-flex with no wrap and no max-width, so it ran off the right edge —
add flex-wrap + max-width:100% + min-width:0. Plus two rigid-width fixes that
escaped the row cap: ApiKeys input min-width:220→0, Appearance scale floor.
Frontend builds clean; tokens, @container query, and the wrap all verified in the
emitted CSS bundle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(settings): center the settings block + tighten measure (kill the lopsided right void)
The capped content was left-aligned, so on a wide window everything jammed to the
left with a dead empty third on the right (screenshot). Center the whole settings
block (nav rail + content) as a unit via max-width + margin-inline:auto, and drop
the measure 720→660 so label→control rows read denser. The cap is computed from
the tokens (rail + gap + measure + page padding) so the content track lands
exactly at --settings-measure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(device): fall back to CPU when the GPU arch is unsupported, instead of 500-ing every generate (#756)
get_best_device() called check_device_compatibility() and, on an unsupported
compute capability, only LOGGED a warning then still returned 'cuda' — so the
model loaded on a GPU whose kernels can't launch and every generate 500'd with
'CUDA error: no kernel image is available for execution'. Both a too-old card
(Pascal sm_61, GTX 10-series) and a too-new one (Blackwell sm_120 on pre-cu128
wheels) hit this.
Now an unsupported arch falls back to CPU (works, just slower) with a clear
warning; OMNIVOICE_FORCE_CUDA=1 overrides. Belt-and-suspenders: _oom_friendly_reraise
classifies a raw 'no kernel image is available' as an unsupported-GPU error
(switch to CPU / install matching torch) rather than the OOM/Flush message.
Tests: get_best_device → cpu on incompatible, stays cuda on compatible, honors
the force override; reraise gives the actionable GPU message, not OOM.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(device): patch detect_host_caps via string path so the #756 fallback test is full-suite robust
The first version aliased the import + inserted backend on sys.path, which patched
a module copy get_best_device's local 'from core.device_caps import detect_host_caps'
didn't resolve in the full suite (passed alone, failed in CI). Use the string-form
monkeypatch target; verified passing alongside the other device/model tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(changelog): fold #757 device-fallback entry into [0.3.8]; drop the merge's stale [Unreleased] dupe
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Renames [Unreleased] to [0.3.8] — 2026-07-01 and merges the settings-hub
redesign, translation/network/factory-reset panes, the GPU-pool generate-hang
fix (#851), and the translation-banner fix into the release section so
release.yml extracts a complete, house-style body at tag time.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(tts): bound + reset the GPU pool on a hung generate so it can't brick the backend (#730 class)
A GPU job that wedges on some Windows+CUDA setups occupies its worker
forever — run_in_executor can't cancel the thread — so on the 1–2 worker
pools we ship, one stuck job starves every other request and the next
action surfaces as the misleading "Can't reach the local backend" even
though the process is alive.
ASR/dub/model-load already bound+reset the pool on hang (#730). The TTS
**generate** paths (generation.py, tts_stream.py) were the last unguarded
GPU dispatch — and the residual on-main reports (#850#802#755#723#721,
plus the 0.3.7 generate cohort) all fail on generate:start (audio).
- model_manager: add run_on_gpu_pool_guarded() + GpuJobTimeoutError, a
generalized version of the ASR guard so every GPU dispatch shares one
bound+reset recovery path. Env-tunable via OMNIVOICE_GENERATE_TIMEOUT_S
(default 300s).
- generation.py: route both inference branches + the reference-clip
transcribe through the guard; map a timeout to an actionable 503.
- tts_stream.py: same guard on the streaming path (timeout → error frame).
- test_generate_timeout_730: fail-before/pass-after regression (timeout
resets pool + restores capacity, happy path, env override, no-reset exec).
- docs + CHANGELOG: extend troubleshooting §14 to cover generate; document
the new env var.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(tts): extend the GPU-pool hang guard to batch/dub/archetype/openai-compat generate (#730 class)
The generate-hang class wasn't only in Studio + streaming: batch generate,
the dub per-segment + preview generate, archetype preview render, and the
OpenAI-compat /v1/audio/speech path all dispatched the TTS model to the GPU
pool with no wall-clock bound either. Any one of them wedging on a
Windows+CUDA hang starves the pool and bricks the backend the same way.
Route all of them through run_on_gpu_pool_guarded so the whole class is
closed — a hung generate anywhere resets the pool and returns an actionable
timeout instead of a dead backend. Batch/dub recover per-segment on a fresh
worker; drop the now-dead loop/_gpu_pool/asyncio locals ruff flagged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The color themes (Midnight, Catppuccin, Nord, Solarized, Rose Pine) stopped
recoloring the app chrome — the Settings hub, header, footer and everything
else that reads var(--chrome-*) stayed the default dark on the real app.
Root cause: in the real app `data-theme` is set on <html>, and <html> IS
`:root` (documentElement === :root). The P5 tokens consolidation inlined the
default legacy/chrome `:root` block (--chrome-bg:#0f1011, …) AFTER all the
[data-theme] blocks. A plain `:root {…}` and a `[data-theme="x"] {…}` both
match that same element at EQUAL specificity (0,1,0), so source order is the
only tiebreaker — the later default `:root` won and clobbered every theme's
--chrome-*/--color-* overrides. The visual-regression suite kept passing
because its harness applies `data-theme` to a WRAPPER div (a closer ancestor
that wins by proximity, not source order), so it never exercised the <html>
path where the bug lives.
Fix (source order, not specificity): reorder index.css so every default
`:root` block precedes all `[data-theme]` blocks. The [data-theme] blocks
(+ the @media prefers-color-scheme:light theme block) now sit LAST, after the
default legacy/chrome `:root`. The `[data-theme="x"]` selectors are unchanged
(bumping to `:root[data-theme="x"]` would stop matching the wrapper-based
harness and break the 48 snapshots).
Crucially the Tailwind v4 region is left byte-for-byte intact: the @theme base
and the adjacent `@theme inline` shadcn bridge keep their exact positions.
Moving a `:root` between/across them changes the GENERATED CSS (`@theme inline`
stops inlining, so shadcn utilities lose their brand color) — so instead of
lifting the default :root above @theme, the [data-theme] blocks are lowered
below it. Verified: the compiled CSS is byte-identical to before (260686 B),
and every token value is preserved byte-for-byte (pure reordering).
Regression test: src/test/themeCascade.test.js replays the documentElement
cascade from index.css source order and asserts each theme's --chrome-bg/-fg
wins over the default :root. Fails-before / passes-after. Verified live in
Chromium too: getComputedStyle(documentElement)['--chrome-bg'] now resolves to
#0f1011 (default) / #1e293b (midnight) / #313244 (catppuccin) / #3b4252 (nord).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Several Settings panels were re-hosted in the redesign without converting
their raw <input>/<select>/<button> to the design system, so they rendered
as native UA controls (white input fields, light-gray buttons, system
fonts) that ignored the theme tokens — jarring on the dark chrome. Convert
every native control in the affected panels to the shared primitives
(SettingsInput / ui Button / ui Select / ui Badge) so all of Settings
themes coherently in every palette.
Panels fixed:
- LLMEndpointPanel: Ollama/LM Studio/vLLM/OpenAI preset chips -> Button
(preset); Base URL / Model / API key -> SettingsInput (mono); Save ->
Button (subtle/sm, loading); reachable/not-configured status -> Badge
(success/warn, dot).
- HFMirrorPanel: mirror preset chips -> Button (preset); Save -> Button
(subtle/sm, loading). (HF_ENDPOINT was already SettingsInput.)
- PronunciationPanel: add-entry term/replacement/language + test inputs ->
SettingsInput; type selector -> ui Select; per-row enable checkbox ->
SettingsToggle; type/scope pills -> Badge; Add + per-row delete ->
Button (subtle/sm, danger/sm).
- RemoteBackendPanel: Test connection + Save & reload -> Button
(subtle/sm, loading); probe result -> Badge (success/danger, dot).
- MCPBindingsPanel: client-id input -> SettingsInput; voice select ->
ui Select; Bind -> Button (subtle/sm); per-binding profile pill ->
Badge; delete -> Button (danger/sm).
Also dropped the perfpanel__row / perfpanel__badge / perfpanel__checkbox
class usages from these panels (replaced by primitives + token flex
utilities). The perfpanel CSS block lives in src/index.css (owned by an
in-flight theme-cascade change), so it was left in place; the remaining
perfpanel__error / perfpanel__help references are token-based themed
banners, not native controls.
Behavior, handlers, state, endpoints, and all data-testids are preserved.
No new user-facing strings (styling-only). Gates: vite build, oxlint (0 on
touched files), oxfmt --check clean, vitest 645 pass, 48 visual snapshots
unchanged, bun install --frozen-lockfile clean. Live eyeball across all 5
categories in default + catppuccin themes confirms no white fields / no
native buttons.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two related Dub-tab translation-flow fixes, one PR.
TASK 1 — proactive, highlighted Install affordance in the translate engine
selector (replaces "find out only via a translate-time 400"):
- FROM-SOURCE lane (activeEngineUnavailable && !enginesSandboxed): the muted
install chip is promoted to a HIGHLIGHTED brand-accent Install button, still
wired to handleInstallEngine(translateProvider) with the installing/disabled
state. Selecting any uninstalled engine surfaces it immediately.
- FROZEN lane (enginesSandboxed): pip install is impossible in the read-only,
signed packaged env, so the disabled "needs dev install" span becomes an
equally highlighted button opening a popover with (1) the exact install
command + copy-to-clipboard, (2) one-click "Switch to Argos (bundled,
offline)" — the guaranteed importable escape hatch, and (3) a Docs link via
the existing Tauri shell.open path. Gated on the existing `sandboxed` flag,
not platform.
- Single-source install command: new translation_engines.install_command()
is the one source of truth; list_engines() stamps `install_command` per
engine and BOTH the argos + deep_translator translate-time 400 messages build
their command from it, so the proactive button and the 400 can't drift.
engines.ts gains `install_command: string | null`.
TASK 2 — the translation error banner now dismisses and clears (class fix):
- Root cause: handleTranslateAll never cleared dubError, so a stale 400
survived even a successful retry. It now clears at the start of every
attempt.
- Corrective-action clears (whole class): changing the engine and installing
the package both clear dubError (wrapped setTranslateProvider +
handleInstallEngine in DubTab).
- DubFooter's banner gains a × dismiss and a guarded auto-timeout (skipped
while generating so live per-segment errors persist).
i18n: 8 new dub.* keys translated across all 21 locales. Docs: new
docs/dubbing/translation-engines.md (from-source vs packaged build) linked from
the popover Docs button + a troubleshooting cross-reference. Tests: FE
regression for both lanes + never-installs-when-sandboxed + banner
dismiss/auto-clear; BE regression that list_engines() install_command is
embedded verbatim in the dub_translate 400s.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Dub stepper could show Upload ✓ → Prepare ✓ → Transcribe (active) while the
main content pane still rendered the IDLE upload dropzone ("Drop video or audio
here" + paste-URL input + "Pull YouTube captions"). Contradictory: if the
pipeline is transcribing, the pane must reflect that stage, not the landing.
Root cause (frontend/src/components/dub/IdleSkeleton.jsx): the main-view branch
keys off the non-serialisable local File `dubVideoFile`. That File is only set
on the drag/drop + file-input path — never on the URL-ingest path (and not on a
restored job). The `dubVideoFile ?` branch correctly renders both the prepare
(PrepOverlay) and transcribe (TranscribeOverlay) overlays via the WaveformTimeline,
but the no-file branch only handled `dubStep === 'uploading'` (PrepOverlay large)
and otherwise fell straight through to the idle dropzone. So a URL-ingested job
in `dubStep === 'transcribing'` (no File) rendered the dropzone — the exact
desync in the screenshot.
Not a #818 regression: the no-file branch never handled `transcribing`. It was
identical before #818 (verified against 9d79bb8) — a pre-existing gap that only
bites the URL-ingest / restored-job paths.
Fix (whole class, recurrence-proof):
- Add a `dubStep === 'transcribing'` case to the no-file path that renders
TranscribeOverlay, symmetric to the existing `uploading` → PrepOverlay case.
This covers URL-ingest AND restored/resumed jobs that lack a local File.
- Gate the idle dropzone on `dubStep === 'idle'` so it can render ONLY when
genuinely idle; any other non-idle no-file step (e.g. `stopping`) shows a
neutral working indicator instead of falling back to the dropzone. This makes
it structurally impossible to show the dropzone during an active pipeline.
All existing behavior/handlers preserved (failure banner + retry still show in
the idle-after-failure state, since that sets dubStep back to 'idle').
Regression test: frontend/src/test/DubIdleSkeleton.test.jsx — asserts the
dropzone renders only when truly idle, is hidden (and the transcribe overlay
shown) while transcribing a URL-ingested job, is hidden while preparing, and
never falls back to the dropzone for a non-idle no-file step. Fails before /
passes after.
Verified live (Playwright, real backend): before → transcribe stage shows the
dropzone (transcribingHasDrop=1, overlay=0); after → shows the transcribe
overlay (transcribingHasDrop=0, overlay=1), idle still shows the dropzone,
reset returns to idle.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The full-width Settings hub (#843) left every right-aligned SettingRow
control capped at `max-w-[60%]`, so wide fields (text/URL/key inputs,
selects, textareas) sat cramped against the right edge with a big empty
gap to the label. Read-only mono values ("0.3.8", version strings) also
wrapped character-by-character because `[overflow-wrap:anywhere]` collapsed
the auto grid cell to a 1-char min-content, and removing the content
measure spread rows edge-to-edge on wide/ultrawide screens.
SettingRow.jsx:
- Widen the control grid track to `minmax(0,1fr) minmax(0,1.9fr)` only
when the row contains a real field (`has-[input:not(checkbox/radio/range)]`,
`has-[select]`, `has-[textarea]`), gated to `@min-[601px]/settings` so the
narrow-container stacking is untouched. Toggles (checkbox), Segmented /
Slider (Radix), and buttons don't match, so short controls keep the `auto`
track and stay compact, right-pinned.
- Lift the `max-w-[60%]` cap to `max-w-[85%]`; make the control cell `w-full`
(has-gated) so wide fields fill the widened track leftward to a clean right
edge. Existing `w-full` fields fill; short controls unaffected.
- Fix mono/read-only wrapping: `[overflow-wrap:anywhere]` -> `break-word` and
the percentage `max-w-[75%]` -> length-based `max-w-[42ch]`, so short values
render on one line (the percentage cap forced the auto track to min-content)
while long paths still wrap on boundaries.
Settings.jsx:
- Re-introduce a generous, centered content measure (`w-full max-w-[1100px]
mx-auto`) on the content column so rows fill from the middle instead of
spreading to the screen edges on wide/ultrawide displays; the rail stays
fixed. Wider than the old cramped 660px measure, capped for readability.
Verified visually with Playwright at 1400px (General, Translation, Network,
Credentials, Appearance, Dictation, About) and 700px (stacking intact). All
gates pass: vite build, oxlint, oxfmt, vitest (641), visual (48, no baseline
change needed), frozen lockfile.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sidebar nav items are native <button>s and the non-active state set no
background, so with Tailwind preflight disabled they fell back to the browser's
default `ButtonFace` (light gray) — washed-out pills in the dark themes, and the
active item paradoxically looked darker (it got the subtle --chrome-hover-bg
overlay while inactive items showed UA gray). Add explicit `bg-transparent` +
`appearance-none` so items are theme-adaptive; active/hover keep the overlay.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dropped the root max-width cap + mx-auto centering and the content pane's
reading-measure cap so Settings spans the full content area (rail + fluid
content) instead of sitting in a centered column with side gutters. The
`container-name:settings` inline-size container is preserved, so SettingRow's
narrow-width stacking still fires on the real content width.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CSS consolidation (#837) collapsed all component CSS into src/index.css, so
the "hard 500 lines per .css" cap and "co-locate Foo.css" rule no longer apply.
index.css is the single intentional styling foundation (exempt from the cap);
styling is utilities + shadcn, not per-component files.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CSS consolidation (#837) folded every per-component stylesheet into
src/index.css — the note still implied component-level .css files exist for
keyframes/glass/hooks. Now: all styling lives in src/index.css; don't create
new component .css files.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fold every remaining per-component stylesheet into src/index.css so the frontend
ships essentially ONE CSS file. index.css keeps its Tailwind v4 token foundation
(@layer order + @theme + [data-theme] + shadcn bridge) and now also carries, in a
clearly-commented "CONSOLIDATED COMPONENT STYLES" section, the former residual.css
plus all 28 component .css files — verbatim, unlayered, appended AFTER index.css's
own rules so the previous cross-file load order (index.css → residual.css →
component css) is preserved exactly. @keyframes move by name (all globally unique);
glass/backdrop-filter, cascade-override hooks, and library-DOM hooks (WaveSurfer
wfm-*, virtualized rows) keep winning over @layer utilities because they stay
unlayered. Zero visual/behaviour change — proven by the 48-snapshot visual suite
passing with no PNG diffs.
CSS files: 31 → 2 (src/index.css + src/test/visual/harness.css, test-only).
Deleted (29), each import removed from its component:
styles/residual.css, components/{Misc,firstrun,Sidebar,LogsFooter,CaptureWidget,
CompareModal,FloatingPill,DubSegmentRow,DubSegmentTable,SegmentTrack,VoicePreview,
WaveformErrorBoundary,WorkspaceHistory,WorkspaceVoices}.css,
components/dub/dub.css, components/donate/{DonateGoal,Postcard}.css,
components/settings/{AppearancePanel,PerformancePanel,VoicePanel}.css,
pages/{AudiobookTab,BatchQueue,Settings,VoiceGallery}.css,
ui/{Dialog,Menu,Table,Tooltip}.css
Kept: src/test/visual/harness.css (test-only harness chrome; not shipped).
Guard update: test/workspaceHistoryReflow.test.js now slices the WorkspaceHistory
block out of index.css by its provenance markers, so the #476 CTA-clipping
regression guard (no @media max-width, shell-class reflow, sticky action bar) still
holds on the relocated rules.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* wip(settings): partial sidebar-hub redesign (recovered from killed agent)
Shell (sidebar/search/categories/restart-badge) + new panes (Network/Translation/Storage/PerformanceDevice) + partial panel rewiring. Not yet verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(settings): finish + verify sidebar-hub Settings rebuild; changelog
Completes the partial sidebar-nav Settings redesign: confirmed all 16
categories are wired in Settings.jsx's renderCategory and render their real
panels with every store/pref/API binding preserved (theme→Appearance,
review-mode→General, proxy/ffmpeg→Network, provider keys→Translation — all
relocated, none dropped or duplicated). Verified search filtering, restart
badges, factory-reset dialog, narrow-width dropdown, and i18n key coverage.
Gates: vite build, oxlint (0), oxfmt --check, vitest (641 pass),
bun install --frozen-lockfile — all green. Adds the user-facing CHANGELOG
[Unreleased] entry required by the changelog hard rule.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(visual): refresh GeneralTab/AppearancePanel/StoragePanel baselines for the Settings redesign
The sidebar-hub rebuild changed three snapshotted panels: GeneralTab (lost
proxy/ffmpeg + theme, gained review mode), AppearancePanel (gained the
header-live-stats toggle), and StoragePanel (gained a RestartBadge header). The
recovery commit shipped stale baselines; regenerate all three across the default/
midnight/catppuccin themes so `bun run test:visual` is green against the new UI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* i18n: backfill all 20 locales for the Settings redesign (and pre-existing drift)
The Settings rebuild added ~42 new keys to en.json; ran scripts/translate_all.py
to translate them into all 20 non-English locales (masking {{vars}}/<n> tags),
which also caught up pre-existing key drift — every locale is now at full parity
with en.json (0 missing keys). Satisfies the all-21-locales hard rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CSS→Tailwind/shadcn migration is largely complete: every screen is on
shadcn/ui primitives + Tailwind utilities, and the design tokens were
consolidated into a single foundation file (`tokens.css`/`themes.css` folded
into `src/index.css`'s @theme/[data-theme]). The old note still pointed at the
deleted `src/ui/tokens.css` and said "migration in progress".
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FAST-mode shadcn migration of the tail components — the demos, the
transcriptions history, the batch queue, and the audiobook tab — onto the
shared src/ui primitives (Button/Panel/Badge/Tabs) + Tailwind token utilities
(bg-card/text-fg/border-border + standard spacing), dropping each component's
stylesheet where the residual rules reduce cleanly to utilities.
Fully deleted (residuals inlined as utilities):
- DictationDemo.css — status pills, scripts grid, result boxes (gruvbox
hues preserved as arbitrary utilities; em → not-italic;
.dictation-demo/.__scripts class hooks kept for tests)
- DubbingDemo.css — container/loading shell, 720px collapse → max-[720px]:,
checkbox accent, pane-label/video, active chip
- Transcriptions.css — search input (placeholder:/focus:), item hover/active,
seg-title h4 → div (escapes the unlayered global h1-h4
rule); list scrollbar dropped as redundant with the
global ::-webkit-scrollbar
Trimmed to genuinely-irreducible only (import kept):
- BatchQueue.css — only the progress-fill gradient + ::after shimmer +
@keyframes remain; the bar heading (h1 → div role=
heading) and per-status card borders are now utilities
- AudiobookTab.css — only the <textarea> override (beats the unlayered
textarea.input-base + custom 900px floor) remains;
title (h2 → div role=heading), field labels (utility
const), body/side collapse (max-[900px]:), and the
redundant select width are now utilities
Behavior preserved: test class hooks intact, headings keep heading semantics
via role/aria-level. Verified: vite build, oxlint (0), oxfmt clean, vitest
641/641, visual 48/48, bun install --frozen-lockfile clean. Eyeballed all three
pages + states in the dev app.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FAST-mode shadcn/Tailwind migration of the workspace + voice "tail"
components: the cleanly JSX-controlled chrome moves onto the JSX as
Tailwind v4 utilities (token utilities + arbitrary var()/px to preserve
exact pixels/colors), with irreducible CSS kept co-located.
- WaveformPlayer: all three render branches (player, native fallback,
missing notice) converted to Tailwind; WaveformPlayer.css deleted
(-87). The `wf-player__btn` class is retained as the focus-visible
hook for the shared a11y ring in index.css; the dead `wf-player__spin`
rule + `wf-spin` keyframe + its reduced-motion block were removed.
- VoicePreview: popover container/header/title/close/body/foot/hint
converted to Tailwind; VoicePreview.css trimmed 87->23 lines. Kept the
`voice-preview-in` entrance @keyframes (referenced via animate-[…]) and
the `.voice-preview__select`/`__text` rules — they layer on top of the
*unlayered* shared `.input-base`, and Tailwind utilities (in
@layer utilities) would lose that cascade, so they stay unlayered.
- WorkspaceHistory: finished the voice variant, which #781 left on the
now-deleted `.wh`/`.wh__head`/`.wh__title`/`.wh__scroll`/`.wh__empty`
classes (rendering unstyled). Converted them to the same Tailwind
utilities the dub variant already uses. Kept the studio-with-history/
studio-right/shell-narrow layout + the `.studio-action-bar` sticky
override (#476, guarded by workspaceHistoryReflow.test.js).
- WorkspaceVoices: already fully converted by #781; its `.wv*` chrome is
shared with the out-of-scope WorkspaceProjects.jsx, so the CSS stays.
Verified: vite build OK, oxlint exit 0, oxfmt --check clean, 641 vitest
pass (incl. workspaceHistoryReflow + waveform), 48 visual pass, bun
install --frozen-lockfile clean. Eyeballed the Voice workspace (history
rows + waveform players) and the VoicePreview popover in a live dev run.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrate the tail dialog/panel components onto the shadcn-backed `src/ui`
primitives + Tailwind utilities, removing their bespoke stylesheets.
- BatchAddDialog: rebuilt on the `Dialog` primitive (header/body/footer +
Radix overlay/animation/focus-trap replace the hand-rolled overlay/card),
drop zone + toggle + select moved to Tailwind / the `Select` primitive.
BatchAddDialog.css deleted.
- KeyboardCheatsheet: rebuilt on the `Dialog` primitive; kbd pills, section
grid, rows and footer are now Tailwind utilities. KeyboardCheatsheet.css
deleted.
- CompareModal: kept as the deliberate non-modal bottom drawer (preserves the
"app stays interactive behind" behavior — a shadcn modal Dialog would
regress it). Inner content already rode the shadcn primitives; migrated the
two remaining CSS-class deps (`.compare-textarea--noresize` -> `resize-none`,
`.ui-compare__grid` base -> Tailwind `grid grid-cols-2`). CompareModal.css
slimmed to just the irreducible drawer chrome + slide-up keyframe; the
responsive one-column collapse stays owned by index.css via the retained
`ui-compare__grid` class hook.
- GlossaryPanel: table styling moved to Tailwind (`[&_th]`/`[&_td]`
descendant utilities); the `.glossary-panel .ui-panel__body` max-height
override replaced by a `max-h-[35vh] overflow-y-auto` wrapper.
GlossaryPanel.css deleted.
- Misc.css: removed only the CompareModal-owned `.compare-textarea--noresize`
rule; the rest is shared by out-of-scope components (CheckpointBanner,
DirectionDialog, App startup/wizard, AudioTrimmer) and is kept intact.
Behavior preserved exactly (batch add flow, cheatsheet overlay, compare
A/B, glossary add/edit). Verified: vite build, oxlint (0), oxfmt --check
clean, vitest (641 pass), visual (48 pass), bun install --frozen-lockfile.
Eyeballed all four via a temporary Playwright harness.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrate the last reducible CSS chunk in Settings.css — the recommendation
banner, the models/engines toolbar chrome, and the role-tab/search controls —
to Tailwind utilities (chrome tokens kept) at the JSX, following the established
shadcn fast-mode convention. Behavior and palette unchanged.
What moved to Tailwind:
- RecoBanner (.reco-banner* → utilities on models/RecoBanner.jsx)
- Models/Engines toolbar (.models-toolbar* → ModelStoreTab.jsx + EnginesTab.jsx),
including the previously-unstyled HF-token inline chrome
- Role tabs + search (.models-controls/.models-search/.models-roletabs)
What was deleted as dead CSS (zero consumers, grep-verified):
- the entire .engines-* block (EnginesTab already on shadcn; no consumer)
- the .models-table__body > .models-row override (selector no longer matches
the body > virtual > row DOM the table renders)
What was KEPT as irreducible styling hooks (cannot be utilities):
- .models-table* + .models-row* — the virtualized table geometry. Rows are
absolutely positioned with an inline translateY from the virtualizer; the
table body/virtual spacer and per-cell hooks must stay class-based.
Settings.css: 386 → 225 lines (−161). Not deleted (virtualized hooks remain).
Verified: vite build ✓, oxlint 0, oxfmt clean, vitest 641/641, visual 48/48,
bun install --frozen-lockfile ✓. Live-eyeballed Settings → Models (store +
17-row virtualized table + reco banner) and Engines (matrix + toolbar) against
the live backend; rows render correctly and chrome is coherent.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fold src/ui/tokens.css (134 lines) and src/ui/themes.css (195 lines) into
src/index.css so the design-token foundation lives in ONE file, then delete
the two source files and repoint every import. Pure consolidation — zero
behavior/visual change.
Cascade is preserved EXACTLY. The previous cross-file load order was
tokens.css -> themes.css -> index.css (ui/index.js imported the first two,
main-app.jsx imported index.css after). The inlined content reproduces that
order inside index.css: the token :root first, then the [data-theme] blocks,
then index.css's @theme bridge + its own legacy/chrome :root + rules. The
[data-theme] blocks intentionally sit AFTER the token :root but BEFORE the
legacy/chrome :root so the --chrome-* tokens (declared in both a plain :root
and the [data-theme] blocks at equal specificity) keep resolving by source
order exactly as before.
Imports updated:
- src/ui/index.js: the two token-CSS side-effect imports -> import '../index.css'
(preserves "import a primitive, get the full token scale" for every consumer).
- src/test/visual/harness.jsx: drop the tokens/themes imports, keep index.css.
- src/test/tokenParity.test.js: read the token :root from index.css (located by
its --color-muted-mono signature) instead of the deleted ui/tokens.css.
Verified: vite build OK; oxlint 0; oxfmt --check clean; vitest 641 pass
(incl. tokenParity); visual suite 48 pass with NO baseline changes (default/
midnight/catppuccin render pixel-identical); bun install --frozen-lockfile
clean. Live full-app check (data-theme on <html>) confirms semantic tokens
recolor per theme while chrome tokens hold the :root value — identical to
pre-consolidation behavior.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FAST-mode shadcn migration of the shared Settings primitives and their ~8
consuming panels onto Tailwind utilities layered on the OmniVoice
`--chrome-*` / `--space-*` token bridge — palette and behavior preserved
exactly (every migrated snapshot is pixel-identical to its old-CSS baseline).
Primitives migrated off the `.st-*` CSS class family (all in JSX now):
- SettingsSection → token-bridge Card surface (exported SETTINGS_SECTION_SURFACE
+ `data-slot="settings-section"` so the raw EnginesTab / ModelStoreTab sections
and the Settings.css table hooks stay coupled without `.st-section`).
- SettingRow → Tailwind grid; new `stack` prop replaces the `st-row--stack`
className; control slot carries `data-slot="setting-row-control"`. Row-stacking
reproduced with the Tailwind v4 named-container variant `@max-[600px]/settings:`
plus the legacy `max-[560px]:` viewport fallback.
- SettingsToggle, SettingsInput, InfoHint, Collapsible → Tailwind utilities.
Consumers updated to the new API:
- GeneralTab, StoragePanel, CredentialsTab, AppearancePanel, HFMirrorPanel,
RemoteBackendPanel: `st-row--stack` → `stack` prop; raw `.st-input` inputs →
SettingsInput; raw `.st-section` (EnginesTab, ModelStoreTab) → token surface +
data-slot.
- AppearancePanel.css / VoicePanel.css `.st-row__control` hooks →
`[data-slot=setting-row-control]`; Settings.css `.st-section` hooks →
`[data-slot=settings-section]`; `.models-search.st-input` → `.models-search`.
Deleted primitives.css (368 lines) and removed its imports (primitives barrel +
visual harness). The `.models-*` / `.reco-*` / `.engines-*` table families in
Settings.css are intentionally LEFT intact (out of `.st-*` scope).
Verified: vite build ✓, oxlint 0, oxfmt clean, vitest 641 ✓, visual 48 ✓
(baselines pixel-identical — only the harness CSS import changed),
bun install --frozen-lockfile ✓, and a live Playwright eyeball of Settings →
General / Appearance / Models / Engines (incl. embedded Storage / Performance /
HF-mirror panels) confirms every tab is coherent on-palette.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrate a batch of MISC components to clean shadcn primitives + Tailwind
token utilities, deleting per-component CSS where the styling is fully
expressible as utilities. Palette and behavior are preserved exactly.
Fully migrated (CSS deleted):
- VoiceProfile (+ ProfileHeader / ProfileDetails / ProfileActivity): all
voice-profile__* layout/spacing classes → token utilities; the hero panel
body becomes an explicit flex wrapper inside <Panel> (drops the external
.ui-panel__body override). Deletes VoiceProfile.css (217 lines).
- Projects (OmniDrive): title / search input / view-toggle / filter rail /
card grid+list variants → utilities (list/grid driven by a `view` prop
instead of descendant-combinator CSS; per-card --card-accent kept via inline
style + arbitrary utilities for border-left and the color-mix hover).
Deletes Projects.css (181 lines).
- NotificationPanel: the .notif-* dropdown rules were already dead (the JSX
migrated to utilities in a prior wave; the dropdown now lives in LogsFooter).
Drops the dead import + deletes NotificationPanel.css (201 lines).
Trimmed (irreducible CSS kept):
- CaptureWidget: content / label / timer / dismiss / spinner moved to
utilities (spinner uses motion-safe:animate-spin). Kept the irreducible
glass always-on-top window shell, state borders, slide-in/dot-pulse
keyframes, reduced-motion, and the `body:has(.capture-pill)` standalone-
window transparency rule.
Kept as-is (with reason):
- FloatingPill: its remaining CSS is all irreducible — fixed+glass shell,
enter/exit + dot-pulse + indeterminate-sweep keyframes, and unlayered
--done/--error border/label overrides that must out-rank @layer utilities
(the file's own comments document this). Content/meta/progress/dismiss were
already utilities.
- PerformancePanel: already built on the shared SettingsSection/SettingRow
primitives; its CSS (.perfpanel__error/__row/__badge/__help) is a SHARED
stylesheet consumed by 7+ settings panels (MCPBindings, RemoteBackend,
Refinement, LLMEndpoint, Pronunciation, HFMirror, …), so it can't be deleted
without migrating out-of-scope panels.
Verify: vite build ✓, oxlint (0), oxfmt --check clean, vitest 641 pass,
visual 48 pass, bun install --frozen-lockfile ✓. Eyeballed Projects +
VoiceProfile + header bell via Playwright (real backend proxied through route
interception) — coherent, zero console errors.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrate the independent modal + segment/matrix components onto the
shadcn-backed `src/ui` primitive surface + Tailwind utilities, deleting
their bespoke component CSS. Palette kept, behaviour intact.
- SupertonicLicenseDialog: rebuilt on the shadcn Dialog primitive
(Radix focus-trap / scroll-lock / ESC); non-dismissable while the
license POST is in flight. Accept/Cancel via shadcn Button. Deletes
SupertonicLicenseDialog.css.
- ExportModal: kept as the non-blocking bottom drawer (background stays
interactive — Radix Dialog would break that), but folded the track
chips / tab strip / toggles / drawer shell into Tailwind utilities and
swapped the slide-up keyframe for tw-animate-css. Deletes
ExportModal.css.
- ErrorBoundary (WaveformErrorBoundary.css): fallback UI rebuilt on
Tailwind + shadcn Button. Removed the `errbnd-*` block from the shared
CSS; the `wfm-*` WaveformTimeline rules stay (file still imported by
WaveformTimeline).
- EngineCompatibilityMatrix: folded the GPU-chip color system,
`is-effective` highlight, `Why unavailable?` disclosure triangle, and
the horizontal-scroll table min-width into Tailwind. Kept the
`is-effective` marker class (matrix test asserts it), roles, testids,
and aria-labels. Deletes EngineCompatibilityMatrix.css.
DubSegmentRow / SegmentTrack were already migrated in a prior wave and
already use the shadcn-backed Button/Badge/Menu; their remaining CSS is
the deliberate irreducible remainder (cascade-fighting `!important` state
rules that must stay unlayered to beat index.css, `font:inherit` focus
rings, `input-base`/range overrides), so it stays co-located. The shared
`segment-*` contract in index.css is left untouched.
Verified: vite build, oxlint (0), oxfmt --check clean, full vitest
(641 pass incl. ExportModal/SegmentTrack/EngineCompatibilityMatrix/
ErrorBoundary), visual suite (48 pass), and a real-browser eyeball of all
four rewritten components via the visual harness.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shell (app-container grid) — KEPT as-is, by design. The outer `.app-container`
grid family is the canonical cross-cutting positioning hook and is deliberately
left in index.css:
- `appShellScale.test.js` parses the literal `.app-container { … }` block and
asserts the `zoom`/`calc(100vw/--ui-scale)` scale pattern + the
`[data-zoom-layout=off]` 100vw/100vh fallback — migrating the base rule away
would break that regression guard.
- `LogsFooter.css` hooks `.app-container .logs-footer` and
`.app-container.rail-right .logs-footer` (+ a ≤600px media query) via ancestor
combinators that Tailwind utilities can't express.
- Child placement (nav-rail / history-panel / main-content) comes from
`.app-container > .child` descendant combinators that reflow `grid-column`
across six dynamic state classes (sidebar-collapsed / sidebar-hidden /
rail-right / shell-narrow / shell-mini); reproducing that as utilities would
require editing out-of-scope child components. Net index.css delta: 0.
Sidebar.css — safe, contained migrations + dead-rule removal:
- Moved the two collapsed combinators whose base is already utilities to
conditional utilities in Sidebar.jsx: `.sidebar.is-collapsed .sidebar__tabs`
and `.sidebar__scroll.is-collapsed` (mutually-exclusive conditional classes,
so no Tailwind same-property ordering trap).
- Removed dead/redundant rules: `.sidebar.is-collapsed .sidebar__tab svg`
(icon size already set by the JSX `size` prop) and the
`.sidebar.is-collapsed .sidebar__subtitle` / `__search` hides (both blocks are
already gated out of the JSX when collapsed).
Kept (reported): `.sidebar__tab` base + :hover/.is-active/:focus-visible
(is-active must beat :hover via source order — not reproducible cleanly in
layered utilities), `.sidebar.is-collapsed .sidebar__tab` (its base is still
unlayered CSS, so the override must stay unlayered too), `.sidebar__search-input`
(overrides the unlayered `.input-base` primitive), `.sidebar__search-clear`
(!important Button overrides), `.sidebar__save-btn*` (consumed by out-of-scope
WorkspaceProjects.jsx), `.sidebar__section-title:hover` + `.sidebar__icon-tile`
states (prior-wave unlayered-by-design), `.sidebar.is-collapsed .sidebar__empty`
(shared EmptyState has no collapsed prop), and all `history-*` rules (consumed by
the out-of-scope Workspace* feature panels).
Verified: vite build, oxlint (0), oxfmt --check clean, vitest 641/641 (incl.
appShellScale guard), visual 48/48, bun install --frozen-lockfile. Eyeballed
Launchpad + responsive widths (1280/1000/560/1366) + a forced-render of the
collapsed Sidebar: rail/header/main/footer placement intact, footer reclaims
full width at ≤600px, 0 console errors.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrites the static marketing/info surfaces on shadcn primitives (Card / Button /
Badge) + Tailwind token utilities, dropping the three legacy page stylesheets.
FAST mode: clean shadcn + Tailwind defaults, palette kept (via the existing
color-mix + var(--chrome-*) arbitrary utilities), behavior intact, not
pixel-perfect.
- SupportPage.jsx (SupportView=donate + LicenseView=enterprise): hero, segmented
Support/License toggle, Fund-Claude-Max goal Card, amount picker, Ko-fi/PayPal
link cards, benefit Cards, and the per-deployment quote panel — all on
Card/Button/Badge + Tailwind. All i18n keys, URLs, openExternal, amount state,
and view toggling preserved.
- ContactPage.jsx: hero + Discord/Email/Issues/Website channel cards rebuilt as
hue-tinted Tailwind link rows.
- Deleted DonatePage.css (282), EnterprisePage.css (233), SupportPage.css (140)
= 655 lines removed; no JS imports them anymore.
Kept (shared, untouched): index.css `.lp-aurora*` + `.lp-hero__sweep` (also used
by Launchpad). Left the donate widgets (GoalBar/Pip/Postcard) and their
DonateGoal.css/Postcard.css in place — already Tailwind-based with genuinely
irreducible keyframes (goal-fill grow, Pip bob/wave, postcard stamp/perforation),
the sanctioned "small co-located keyframe CSS" exception. The dead no-op
`lp-glow-card` class (never defined in CSS) was dropped.
Verified: vite build ✓, oxlint 0, oxfmt clean, vitest 641 pass, visual 48 pass,
bun install --frozen-lockfile ✓. Eyeballed Donate/Enterprise/Support + Contact in
chromium against a stubbed backend — all coherent and on-palette.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FAST-mode shadcn migration of three independent areas — Voice Gallery,
Stories editor, and the logs/status footer — onto shadcn primitives
(src/ui barrel over src/components/ui/*) + Tailwind token utilities. Palette
kept; behavior preserved; ~1000 lines of bespoke CSS removed.
Voice Gallery (VoiceGallery.jsx + gallery/{ArchetypeCard,ArchetypesZone,
CommunityZone,ImportsZone}.jsx):
- Zone toggle → <Segmented>; category chips → Button variant="chip"; facet
dropdowns → <Select>; grid/list view toggle → <Segmented>; cards/chips/
buttons/empty/loading → Tailwind token utilities.
- VoiceGallery.css 427 → 70 lines: kept only the now-playing equalizer
@keyframes, the .arch-avatar/.accent-flag/.flag-globe classes rendered by
the out-of-scope utils/archetypeIcons.jsx, and the app-wide .spin helper
(it lived here, NOT in index.css — kept to avoid breaking ~30 consumers).
Stories editor (StoriesEditor.jsx):
- Track grid, chapter bar, cast/projects/split panels, tone/speed drawer,
and native textarea/select/range chrome → Tailwind utilities; reusable
class-string consts hoisted. Drag-reorder, preview chain, generate/stems,
global speed, refs, i18n keys and aria-labels all unchanged.
- StoriesEditor.css 349 → 0 lines (file deleted; import removed). The dead
.stories-track__voice-dot[data-char] palette (no data-char ever set) and
cosmetic webkit scrollbars were dropped.
- Native <select>s get [color-scheme:dark] so the cast/voice pickers render
on dark chrome across WebKit/WebView2/WebKitGTK (matches the old
.facet-select intent; the original cast select was unstyled/light).
Logs/status footer (LogsFooter.jsx):
- Icon buttons, source pills + severity badges, version badge + pulse dot,
discord/contact/donate, log lines and notification severity → Tailwind
utilities. Spinner → motion-safe:animate-spin; reduced-motion via
motion-reduce: variants.
- LogsFooter.css 376 → 78 lines: kept the position:fixed shell + the
.app-container/.rail-right/≤600px ancestor-combinator insets (can't be
element-local), the body ::-webkit-scrollbar, the collapsed/open heights,
and the version-dot-pulse/heart-glow @keyframes.
- The shared --chrome-* token vars and index.css are untouched; Header is
unaffected (no logs-footer__* class is referenced outside this component).
Verified: vite build, oxlint (0), oxfmt --check (clean), vitest (641
passed), bun run test:visual (48 passed), bun install --frozen-lockfile.
Eyeballed all three areas in a stubbed dev build — coherent and on-palette.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FAST-mode shadcn pass over the Settings *page chrome*. The settings tab
components already render on shadcn — the live `src/ui/*` primitives
(Button/Badge/Tabs/Segmented/Slider/Input) are thin wrappers over the
`src/components/ui/*` shadcn primitives via the index.css token bridge — so
the only non-shadcn layer left here that is *safe to migrate* is the page
layout itself.
What changed:
- Settings.jsx: `.settings-page` / `.settings-content` are now Tailwind on the
token bridge — the centered, scrollable column that becomes a
[rail | content] grid at ≥760px, and the content column that establishes the
`settings` container query primitives.css relies on. No behavior change: tab
nav, deep-link tab, and every panel render exactly as before.
- Settings.css: removed the page-chrome rules now living in Tailwind
(`.settings-page` + grid, `.settings-content`) and the dead ones
(`.settings-row__mono`, `.settings-section__head-*`). Kept what can't migrate:
the tab-rail look (must stay UNLAYERED to win over the shared shadcn Tabs
primitive), `.settings-prose strong`, and the Models/Engines/recommendation
rules consumed by their sub-components.
- index.css: removed the duplicate base `.settings-page` block.
Deliberately NOT deleted (verified by cross-file grep, per "delete once
unused"): primitives.css + the `.st-*` class contract (out-of-scope StoragePanel
passes `st-row--stack`; AppearancePanel.css/VoicePanel.css/PronunciationPanel
test reach into `.st-row__control`), and Settings.css's `.models-*`/`.engines-*`/
`.reco-*` (consumed by out-of-scope ModelsTable / RecoBanner /
EngineCompatibilityMatrix). Deleting either would break out-of-scope code and
main CI.
Net: 3 files, ~51 fewer CSS lines. Verified: vite build, oxlint (0), oxfmt,
vitest (641), visual (48, no baseline change — snapshotted components untouched),
bun install --frozen-lockfile. Eyeballed Settings (General + Logs) via the visual
harness: rail + content grid + centered max-width + active-tab accent + tab
switching all coherent.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FAST-mode shadcn migration of the Dub Studio feature area. The dub
components now style with Tailwind utilities on the OmniVoice palette
tokens (bg/text/border via chrome-* + space/text vars) plus the src/ui
shadcn primitives (Button/Badge/Progress/Segmented/Table), and the
800-line page stylesheet is gone.
What changed
- Deleted frontend/src/pages/DubTab.css (800 lines). The irreducible
pieces that can't be utilities — keyframe motion (stepper spin,
idle-drop pulse, skeleton shimmer), ::before stepper connectors, and a
handful of rules that must override other *global* design-system
classes (.studio-panel / .label-row / .override-toggle / .segment-del)
— moved to a small co-located frontend/src/components/dub/dub.css.
- Converted the dub-* presentational classes to inline utilities across
DubFooter (footer panel, export-track chips, compression warn),
DubLeftColumn (generating overlay, cast strip, the whole translation
settings bar + fields), DubRightColumn (output-options rows, transcript
body, glossary chip, bulk-select row), IdleSkeleton (speakers input,
ingest opt-in, landing advanced, ghost footer + buttons), and
TranscribeOverlay (stats row).
- Rewrote FooterBtn off the global .btn-primary / .dub-footer-btn
subsystem onto a Tailwind tone map (idle/danger/green/pink/amber/…),
preserving the flat tinted-outline look.
- Removed the dub-* fragments from src/index.css (tabular-nums group,
focus-visible group, and the dub-split-grid / dub-settings-bar /
dub-footer-btns responsive media queries — now inline max-[…] utils).
Kept .btn-primary base (still used by ErrorBoundary) and all shared
design-system classes.
Left intact (reported): the segment-* subsystem (DubSegmentTable.jsx/css,
DubSegmentRow.jsx/css, segment-* in index.css). It's the lowest-risk
option for the core, most test-covered segment table; ModelsTable and
EngineCompatibilityMatrix were verified NOT to consume segment-* (they
use models-*/engine-matrix-*), so nothing else breaks.
Behavior preserved exactly — every onClick/state/prop/hook untouched.
Verified: vite build ✓, oxlint 0 ✓, oxfmt --check clean ✓,
vitest 641/641 ✓, bun install --frozen-lockfile ✓. Eyeballed the idle
dropzone and the loaded skeleton (stepper, settings bar, skeleton
segment table, footer) in Chromium — palette + layout coherent.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rebuild the first-run / setup feature area on standard shadcn primitives
(Button/Input/Select/Progress/Badge from src/ui) + Tailwind utility classes
themed by the OmniVoice palette tokens, replacing the 954-line bespoke
"studio console" stylesheet wholesale (FAST mode: clean shadcn look, not a
per-pixel reproduction of the old design).
Components rewritten:
- FirstRunSetup.jsx (install-plan screen: mode/storage/compute/channel,
live disk gate, mirrors, Start)
- BootstrapSplash.jsx (install progress, steps, activity log, failure
hints + retry, awaiting_setup → FirstRunSetup handoff)
- WizardLibrary.jsx (unified model/engine list + SSE download progress)
- HfTokenCard.jsx (inline HF token bar)
- SetupWizard.jsx (preflight + models + dictation acts, stepper nav)
All behavior preserved: every onClick/state/prop, the radio-group keyboard
nav, the disk-space blocker logic, the SSE progress aggregation, retry /
clean-retry, the launch flow, and all exported pure helpers (kept the
unit-tested fmtBytes/fmtRate/isPlatformPick/aggregate/progressFromAgg/
radioGroupNav exports).
CSS deleted: FirstRunSetup.css (954) + SetupWizard.css (184) + the dead
swiz-check* block in Misc.css (~21). The only bespoke CSS kept is a new
63-line firstrun.css holding the three irreducible keyframes (breathing
waveform, rise-in stagger, active-step LED pulse) that Tailwind utilities
can't express — net ~1075 lines of bespoke CSS removed. index.css had no
frs-* rules (0 line delta there).
Verified: vite build, oxlint (0), oxfmt clean, vitest (641 pass),
bun install --frozen-lockfile. Live-eyeballed all four screens
(FirstRunSetup, install splash, failed state, wizard) via Playwright —
palette correct, layout coherent, no UA button-chrome leaks.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the waveform-* global class family off index.css onto Tailwind v4
utilities on WaveformTimeline.jsx, then delete the now-dead rules.
Migrated to utilities (rules deleted): waveform-timeline (mb), waveform-controls
+ -left/-right (flex/items/justify/gap), waveform-btn + :hover/:disabled and
waveform-btn-play + :hover (shared WF_BTN/WF_BTN_PLAY consts; UA <button>
padding/font preserved since the app ships no preflight), waveform-time
(text/border/bg/mono/tabular-nums), waveform-zoom-slider (important w/h/mt).
States -> hover:/disabled: variants; no-preflight borders -> explicit
[border:1px_solid_...]; exact px via arbitrary values.
Deleted as dead (zero usages anywhere): waveform-video-preview,
waveform-track-bg (+ nth-child + the 800px media-query track rows).
Kept (irreducible): .waveform-container and its
.waveform-container [data-id^="wavesurfer-region"] descendant rules (+ the
800px container/region media query) — those style WaveSurfer-generated DOM
we don't render in JSX, so they can't be utilities. The class stays as a hook.
index.css net -55 lines (+7/-62).
Cascade-correctness verified live (Playwright getComputedStyle, both
stylesheets loaded): new utilities reproduce the pre-migration computed styles
exactly. Caught two subtleties: (1) controls margin-top is 3px (unlayered
wfm-controls already wins over the old 4px), so no mt utility is added;
(2) referencing var(--chrome-font-mono) in a class string tripped the global
[class*="chrome-font-mono"] selector (adds slashed-zero + ss02) — switched the
time font to var(--font-mono) (identical stack) to avoid the substring match.
Screenshot pixel-diff old vs new = 0 (AE). Updated record_promo.js's fallback
selector (.waveform-controls -> [aria-label="Playback controls"]).
Gates: oxlint 0, oxfmt clean, vite build, vitest 641 pass, test:visual 48 pass,
bun install --frozen-lockfile no change.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the searchable-select (`ss-*`) and file-dropzone (`file-drag`) global
class families out of `src/index.css` into inline Tailwind utilities, then
delete the now-dead rules (-131 lines net in index.css).
- SearchableSelect.jsx: trigger/label/chevron/popup/search/list/group-label/
option (incl. the highlight + selected + selected-highlighted cascade)/
kind-icon/check/empty/more all rendered with token utilities + arbitrary
var()/px values; no-preflight borders made explicit; `:focus`/`:hover` and
the `::-webkit-scrollbar` pseudo-elements moved to Tailwind variants. The
`.ss-sm/.ss-md .ss-trigger` descendant rules collapse to a size-conditional
class on the trigger. `ss-wrap` keeps its class *name* only (its style is now
utilities) because residual.css targets `.voice-selector > .ss-wrap` via a
cross-file child combinator — deleting the name would break VoiceSelector
layout.
- AudioMethodPanel.jsx: `.file-drag` (+ `:hover`/`.is-dragging`/`p`) → utilities;
`is-dragging` stays a JS-toggled marker matched via `[&.is-dragging]:`. The
out-of-scope, unlayered `.clone-drop-zone` padding override still wins.
- index.css: removed the `.ss-*` block, the dead `.ss-popover/.ss-menu/
.ss-dropdown/.ss-item/.ss-highlighted` rules (zero JSX usages), and both
`.file-drag` blocks, leaving migration breadcrumbs.
Verified live (Vite + Playwright/chromium): the Clone screen's dropzone and an
open SearchableSelect popup (search box, POPULAR group label, highlighted
option) render coherently. Gates: oxlint 0, oxfmt clean, vite build OK,
vitest 641 pass, visual suite 48 pass, `bun install --frozen-lockfile` no-op.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P4 of the shadcn/Tailwind migration for the `history-*` global class
family. The family is a shared, cross-file-composed component system
used across WorkspaceHistory, Sidebar, WorkspaceProjects and
WorkspaceVoices, so most of it is irreducible to per-usage utilities.
Migrated the one cleanly-isolable class:
- `.history-row-head` -> `flex items-center justify-between gap-2 min-w-0`
(pure flex layout; no variants, pseudo-elements, descendant selectors,
or cross-file/selector coupling). Converted all 9 usages, deleted the
index.css rule (now zero usages). Verified in the running app that the
utilities compute byte-for-byte identically to the old rule
(display:flex / center / space-between / gap 8px / min-width 0).
Kept (composed cross-file / irreducible) and documented for later:
- `.history-item` (::before accent bar, descendant hover-reveal,
`.project-active` compound, `--row-accent` set inline + `--dub`
variant in Sidebar.css, duplicate !important defs)
- `.history-panel` (selector target of out-of-scope
`.app-container > .history-panel` / `.glass-panel.history-panel`)
- `.history-kind` / `.history-meta` / `.history-title` / `.history-subtitle`
(each has `--audio` / `--locked` / `--clamp`/`--expanded` / `--italic`/`--seed`
variants defined in Sidebar.css)
- `.history-actions` (revealed via `.history-item:hover/:focus-within`
descendant selector)
- `.history-action-btn` / `.history-action-icon` (compound `.accent`/`.danger`
hover modifiers; ~30 usages; kept whole as a cohesive subsystem)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P4 shadcn/Tailwind migration of the chip/preset/tag global class families out
of src/index.css and onto their components as Tailwind utilities.
- personality-chip (+ __icon, + .active): -> token utilities inline in
clone/DesignMethodPanel.jsx (PCHIP_* consts). Active stays chrome-accent
(pink); icon span -> inline-flex items-center. The cross-file
`.starting-points__strip .personality-chip { flex:0 0 auto }` in
CloneDesignTab.css moved onto the chip as the `flex-none` utility and the
dead rule was removed.
- chip-group .chip (+ :hover/.active) and the chip-group container: chips ->
token utilities (CHIP_* consts) in DesignMethodPanel.jsx; the container's
flex layout -> `flex flex-wrap gap-1` utilities. The `chip-group` class name
is KEPT on the container purely as a JS hook (CloneDesignTab's roving-tabindex
keyboard nav does `closest('.chip-group')`).
- tag-btn (Insert-menu token chips): -> token utilities in clone/ScriptPanel.jsx
(TAG_BTN const), preserving the mono face. Removing tag-btn's `!important`
un-masks the intended `.clone-auto-extract-btn` green on the [CMU] button
(author intent restored; palette-coherent).
- preset-btn: had ZERO usages -> both rule blocks deleted.
- The shared 10x a11y focus ring is reproduced on the migrated chips via a
`focus-visible:[outline:2px_solid_var(--chrome-accent)]` utility, on top of
the app's global `:focus-visible` ring.
Kept (irreducible): the shared `.personality-chip:focus-visible, .chip:focus-visible,
...` a11y rule (groups out-of-scope selectors); `.chip-auto`, `.preset-grid`,
`.tags-container`, `.personality-strip` (out of scope, still used).
index.css: +13 / -126 (net -113). Verified live (Clone "By design": personality
chips, identity chip-groups, Insert tag popover) before/after — pixel-coherent.
Gates: oxlint 0, oxfmt clean, vite build, vitest 641 pass, test:visual 48 pass,
bun --frozen-lockfile no change.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the header "quick" chrome (hq-*) global class families out of
src/index.css onto Tailwind utilities on their sole consumer, Header.jsx,
then delete the now-dead rules. No visual change (verified live below).
Migrated families: hq-col-* (layout columns), hq-logo-*, hq-breadcrumb-sep,
hq-view-* (breadcrumb title/dot/kicker/label/project + icon), hq-stats*
(readout + status badge override), hq-flush-btn/reload-btn, hq-flush-dropdown*
(portalled memory dropdown), hq-wave/hq-wave-bar (mini waveform). The three
@keyframes (flush-slide, hqPulse, hqBounce) are kept in index.css and driven
via [animation:...] arbitrary utilities.
- no-preflight: borders set explicitly with [border:...] arbitrary props.
- Badge override (hq-stats__status-badge) uses important modifiers (foo!) to
beat the primitive's own utilities.
- @media responsive rules become max-[Npx]: variants on the elements. Tailwind
v4's max-[N] compiles to `not all and (width>=N)` = strictly `< N`, whereas
the original `@media (max-width: N)` is `<= N`; bumped each breakpoint +1px
(e.g. 820 -> max-[821px]) so the boundary pixel matches exactly.
- The dead `.hq-scale` rule (zero usages) is dropped; the surviving non-hq
@media rules (.header-area reload/wordmark hide) stay in index.css.
index.css: 224 lines removed, 2 added (net -220).
Verified live (vite :3922, Playwright chromium, backend :3900 stubbed):
header at 1600/1000/820px + flush dropdown open, before vs after pixel-diff —
820px identical (0px); residual sub-1% diffs at other widths are purely the
live pulse-dot / wave-bar animation phase (the only red regions in the diff).
Gates green: oxlint 0, oxfmt clean, vite build, vitest 641 passed,
bun install --frozen-lockfile no change, test:visual 48 passed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the `nav-rail` + `rail-btn` global class families out of
src/index.css into Tailwind utilities on NavRail.jsx, deleting the
entire 120-line nav-rail CSS block.
- `.rail-btn` / `:hover` / `.active` (+ accent `::before` indicator bar)
→ utilities on the shared RailBtn button; active state and the
edge-indicator side are driven by props (`active`, `side`) instead of
the `.nav-rail.rail-right` descendant selectors.
- `.rail-label` tooltip → group-hover utilities; flips edge by `side`.
- `.rail-flip` and `.donate-pill` (+ `donate-pill__heart`, reduced-motion)
→ utilities, incl. `motion-reduce:` for the heart.
- `.nav-rail .rail-top` / `.rail-bottom` → flex utilities.
The `nav-rail` CLASS is retained on the <aside> purely as the layout
hook the out-of-scope `.app-container > .nav-rail` grid rules position
by (those selectors are unlayered, so they still win over the layered
utilities); only its visual rules are deleted.
No-preflight safe: borders use explicit per-side `[border-*:1px_solid_…]`
shorthands (the flip button uses four independent side shorthands so the
top hairline can't be reset by a `border` shorthand override).
Verified: live before/after pixel-diff of the rail on Launchpad +
Gallery is pixel-identical (AE=0). oxlint/oxfmt/vite build clean,
vitest 641 passed, frozen lockfile unchanged.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Part 4 of the shadcn/Tailwind migration. Moves the Launchpad's static
layout/typography lp-* global classes from src/index.css onto the
component as Tailwind utilities (token-referencing arbitrary var()
values to preserve exact spacing/colour, explicit border shorthand for
the no-preflight setup, max-[900px]/max-[640px] variants for the former
@media rules), then deletes the now-unused rules from index.css.
Migrated + deleted: lp-hero (+__row/__col/__kicker-row/__wave-group),
lp-kicker, lp-hero__title (+em), lp-hero p / .lp-pill, the dead
.lp-underline rule, lp-actions (grid container), lp-section,
lp-section-title (+::after divider via after:), lp-section__grid,
lp-col, lp-proj-icon--* tints, lp-proj-meta--italic, lp-files__head/
__grid + lp-view-all + lp-file-card, lp-locked-badge, lp-empty (+__inner/
__bars/__hint), lp-dub-thumb, lp-demo-callout (+__icon/__btn),
lp-project-card (+ .proj-icon/info/name/meta/action), lp-ab-compare, and
the unused lp-action-card__emoji.
Kept (reported, not forced):
- Cross-file shared, reused by ContactPage/SupportPage/DonatePage.css/
EnterprisePage.css: .lp-aurora, .lp-aurora__blob(+--pink/green/amber),
.lp-hero__sweep (+ their @keyframes).
- ::pseudo / structural-selector / cursor-tracking component that can't be
flat utilities: the .lp-action-card family + .lp-glow-layer
(::before spotlight, ::after breath ring, nth-child stagger), .lp-animate.
- @keyframes-driven: .lp-wave-bar, .lp-hero__halo, and all @keyframes
(lpDrift1-3, lpHeroHalo, lpHeroSweep, lpBreath, lpFadeUp, lpWaveBeat) +
the prefers-reduced-motion block.
The bare `h1,h2,h3,h4` rule is unlayered, so the hero title's serif
font-family + letter-spacing utilities use `!` to win the cascade over it.
Verified: Launchpad landing screenshot is pixel-identical before/after
(Playwright chromium, animations disabled). oxlint 0, oxfmt clean, vite
build, vitest 641 passed, test:visual 48 passed, frozen lockfile unchanged.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P4 of the shadcn/Tailwind migration. Targets the settings/form/row LAYOUT
globals in src/index.css:
- .settings-log: converted its sole usage (LogsTab.jsx) to Tailwind utilities
(bg/border/rounded/padding/max-h/overflow/font-mono/whitespace), then deleted
the rule. --chrome-font-mono is an alias of --font-mono, so `font-mono` is
exact parity; no visual change (live-verified on the Logs tab).
- .settings-section + .settings-section h2 and .settings-row(.label/.value/
:last-child): zero remaining usages — superseded by the st-section primitive
(components/settings/primitives/SettingsSection.jsx) in an earlier wave.
Deleted as dead code.
Left BLOCKED (cross-file/cross-wave contracts, not forced):
- .settings-page / .settings-page h1 / .settings-page .settings-subtitle —
extended by pages/Settings.css via descendant selectors and a media-query
grid override that depend on the class living in the DOM.
- .label-row / .label-icon — owned by the clone/dub workspaces (out of scope),
extended in CloneDesignTab.css and DubTab.css.
Verified: oxlint (0), oxfmt clean, vite build, vitest (641 passed),
bun install --frozen-lockfile (no change), test:visual (48 passed), and live
Settings screenshots (General/Appearance/Models/Engines/Credentials/Logs)
before-vs-after coherent.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P4 of the shadcn/Tailwind migration — eliminate small, self-contained MISC
global helper classes from src/index.css by converting their raw-className
usages to Tailwind utilities, then deleting the dead rules.
Migrated + deleted:
- .grid-2 (1 usage, AudioMethodPanel.jsx) → grid grid-cols-2 gap-[6px]
max-[700px]:grid-cols-1, preserving the responsive single-column collapse.
- .grid-4 (1 usage, clone/ActionBar.jsx) → grid + arbitrary
[grid-template-columns:repeat(auto-fit,minmax(120px,1fr))] gap-[6px]
max-[500px]:grid-cols-2, preserving the responsive collapse.
- .val-bubble (7 usages, clone/ActionBar.jsx) → text-[0.65rem] bg-black/35
px-[5px] py-px rounded-[3px] explicit border (preflight is disabled) +
[font-variant-numeric:tabular-nums].
- .grid-3 was already dead (no base rule, no usages — only stray media-query
overrides) and is dropped alongside the grid-2/grid-4 collapse block.
index.css net -10 lines. The other class families in this file are
component-scoped (hq-*, lp-*, ss-*, segment-*, waveform-*, settings-*, etc.)
or owned by other waves/agents, so they were left untouched.
Verified: Clone tab (base + Production Overrides expanded) screenshots are
pixel-identical before/after. Gates: oxlint 0, oxfmt clean, vite build,
vitest 641 pass, visual suite 48 pass, bun install --frozen-lockfile no-change.
Part of a HELD batch — do not merge standalone.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P4 of the shadcn migration. Removes the global `.ui-btn*` button
design-system family (the last raw-className button class set still
applied directly in JSX) by routing every consumer through the
shadcn-backed Button component / `buttonVariants()` helper, then deletes
the now-dead stylesheet.
Migrated — AudiobookTab.jsx (9 raw `.ui-btn*` sites):
- `<button>` actions (Preview plan / Create / cover-remove / lex-remove /
Add word / chapter-preview) → `<Button variant={subtle|primary|icon}>`.
- non-<button> elements that can't be the component (file-picker `<label>`s,
the download `<a>`) → shadcn `buttonVariants({ variant:'subtle' })`
className, preserving label/anchor semantics + href/download/file input.
- onClick / disabled / aria-label / inline style all preserved verbatim.
Deleted:
- `src/ui/Button.css` (177 lines) — the entire `.ui-btn*` family; it had no
remaining consumers (the Button component stopped emitting these classes
in the earlier shadcn wrap). Dropped its import from `ui/Button.jsx` and
refreshed the stale comment in `index.css` that referenced it.
Left for a later wave (blocked — see step 4):
- `.btn-primary` (index.css) — composed/extended by DubTab.css
(`.dub-footer-btn` tone family, `.dub-change-row__cta`, `.dub-skel-gen-btn`
all "sit on .btn-primary") + index.css media queries; deleting needs a
refactor of the whole dub footer button subsystem. Risky, left intact.
- `.frs-btn` (FirstRunSetup.css) — custom LED indicator (`.frs-btn__led`) +
`.is-armed` animated state with no Button-variant equivalent, spanning the
entire first-run/setup flow (the project's Core Value). Left intact.
Part of a held batch — do not merge standalone.
Verified: live screenshots of Launchpad + AudiobookTab before/after (buttons
render on-palette — brand-pink primary, bordered subtle pills, correct
sizes); oxlint 0; oxfmt clean; vite build ok; vitest 641 pass; test:visual
48 pass; bun install --frozen-lockfile no change.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrate the five overlay/nav primitives in src/ui to compose the shadcn/ui
layer in src/components/ui, while keeping their existing prop surfaces and
exports byte-for-byte so no call site changes.
shadcn wraps the SAME @radix-ui primitives these already used (dialog, tooltip,
tabs, dropdown-menu) plus Card for Panel, so the swap is structural, not a
behavior change. No new dependencies — every required @radix package was
already pinned; package.json and bun.lock are unchanged.
- Added src/components/ui/{dialog,tooltip,tabs,dropdown-menu,card}.tsx
(new-york style, themed through the existing index.css token bridge;
DialogContent gains showCloseButton, TooltipContent gains showArrow, Card
gains asChild so the wrappers can preserve their exact look/markup).
- Wrappers now delegate positioning + open/close animation to shadcn
(Radix data-[state]/data-[side] + tw-animate-css animate-in/out). The GLASS
look that utilities can't express in this Tailwind v4 build (backdrop-filter +
layered gradients) stays in CSS, now keyed off shadcn data-slots / passed via
the .ui-* classes — unlayered, so it wins over shadcn's bg-popover/bg-card.
- Dialog.css/Menu.css/Tooltip.css trimmed to surface-only (obsolete position +
@keyframes removed); residual.css .ui-panel--glass unchanged.
- Tabs active/inactive state moved to data-[state] variants so it has the right
specificity to override shadcn's TabsTrigger defaults; .ui-tabs/.is-active and
all other cross-file hooks preserved.
Verified: oxlint (0), oxfmt clean, vite build, vitest (641 pass), bun
install --frozen-lockfile clean, and the visual suite (48 pass) — Panel + Tabs
render pixel-identical to existing baselines, so no baseline updates were
needed. Dialog/Menu/Tooltip are Radix-portal and not snapshot-harness-coverable;
verified via build + vitest + review.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrate four OmniVoice UI primitives onto shadcn/ui foundations while keeping
their exact legacy prop APIs, so no call site changes.
- Button: thin wrapper over src/components/ui/button.tsx. Extends the shadcn
CVA with the OmniVoice variants (primary/subtle/softGhost/danger/chip[+Active]/
preset[+Active]/iconBtn[+Active]) + sizes (omniSm/omniMd/chip/preset/iconSm/
iconMd), styled via palette token utilities. Maps variant/size/iconSize/active/
loading/leading/trailing/block/ref. Each variant sets an explicit border
(transparent where needed) since the app ships Tailwind without Preflight.
- Badge: new src/components/ui/badge.tsx; CVA carries the tones (neutral/brand/
success/warn/danger/info/violet) + xs/sm sizes. Wrapper maps tone->variant and
keeps the ui-badge / ui-badge__dot hooks so the Header --pulse animation works.
- Progress: new src/components/ui/progress.tsx (on @radix-ui/react-progress) with
indicatorClassName + indeterminate support. Wrapper keeps per-tone gradients,
sizes, shimmer overlay, and the ui-progress / has-shimmer / is-indeterminate
hooks (residual.css keyframes unchanged).
- Segmented: new toggle.tsx + toggle-group.tsx (adds @radix-ui/react-toggle). The
`seg` toggle variant reproduces the segmented look; wrapper preserves the
items/value/onChange/size API.
residual.css: drop the obsolete .ui-seg__opt:focus-visible rule (focus now falls
through to the global ring). Badge pulse + Progress shimmer/indeterminate rules
kept (still needed).
Visual baselines: Badge/Progress/Segmented render byte-identical to before;
only Button baselines updated (shadcn markup differs in padding/radius, palette-
coherent across default/midnight/catppuccin). All gates pass: oxlint, oxfmt, tsc,
vite build, vitest (641), test:visual (48), bun install --frozen-lockfile.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`bun desktop`/`bun dev` assumed node_modules was current, so after pulling a
branch that adds a frontend dep (e.g. the shadcn migration's tw-animate-css /
@radix-* packages) vite failed with "Can't resolve '<pkg>'" until the user
manually ran bun install. CI never caught it (CI does a frozen install).
predev/predesktop now run `bun install` first (a no-op ~25ms when up-to-date),
so a fresh pull just works.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P1 of the shadcn/ui primitive migration (docs/shadcn-migration.md): route the
OmniVoice form/data primitives through the shadcn components in
src/components/ui/* while keeping their exact exports and prop APIs, so no call
site changes.
- input.tsx: export `inputBaseClass` (the shell) with no behaviour change —
ShadcnInput baseline stays byte-identical.
- New shadcn components: textarea.tsx, select.tsx (+@radix-ui/react-select),
slider.tsx, table.tsx.
- src/ui/Input.jsx (Input/Textarea/Select/Field): Input/Textarea now render the
shadcn components; a small `fieldSizeVariants` cva (named palette utilities,
tailwind-merge-clean) restores the OmniVoice padding-based sm/md/lg scale +
filled bg-bg-elev-2 over the shell. Select stays a NATIVE <select> wearing the
same shell — DubSegmentTable/CompareModal/GeneralTab depend on
onChange={(e) => …e.target.value}, which Radix's value-only Select would break;
the Radix select.tsx is added for new call sites only.
- src/ui/Slider.jsx: wraps the shadcn Slider, keeping the number-based onChange +
label/value-bubble chrome; track/thumb sized via the data-slot selectors.
- Table deliberately NOT rerouted: ui/Table.jsx is a flex-<div> chrome wrapper
whose .ui-table*/.segment-table global classes are a SHARED CONTRACT used
directly by ModelsTable/DubSegmentTable/EngineCompatibilityMatrix (virtualised
lists needing the div/flex layout, not a semantic <table>). table.tsx is
provided for new tabular data; Table.jsx + its globals are untouched. Its
toolbar inherits the shadcn-backed Input/Button for free.
Verification: only the 3 Input-* visual baselines moved (palette-coherent across
default/midnight/catppuccin); Slider/Table stayed within tolerance. vitest 641
green, oxlint 0 errors, oxfmt --check clean, vite build green, root bun.lock
regenerated and bun install --frozen-lockfile in sync (Docker).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lay the foundation for migrating OmniVoice's UI to clean Tailwind v4 + shadcn/ui
WITHOUT changing the look: shadcn primitives inherit the existing OmniVoice
palette (Gruvbox-pink default + every [data-theme] variant) through a semantic
token bridge. Foundation only — no existing component is replaced.
What landed:
- shadcn init for Tailwind v4 + Vite + React 19: frontend/components.json
(new-york, rsc:false, tsx:true), src/lib/utils.ts (cn = clsx + tailwind-merge),
and a @/* -> src/* alias in vite.config.js + tsconfig.json so future
`npx shadcn add` resolves.
- Token bridge in src/index.css: a single `@theme inline` block maps shadcn's
semantic vocab (--color-background/-foreground/-card/-popover/-primary/
-secondary/-muted/-muted-foreground/-accent-foreground/-destructive/-input/
-ring + --radius) onto the existing OmniVoice --color-* tokens. Because those
tokens are re-declared per theme in ui/themes.css, theme switching recolors
shadcn components automatically — no per-theme shadcn block. Existing
--color-accent/--color-border and the --radius-* scale are left intact.
- Two proof components: src/components/ui/button.tsx + input.tsx (verbatim
shadcn new-york), rendered across default/midnight/catppuccin in the visual
harness with committed baselines (brand-pink / purple / lavender confirmed).
- New deps: class-variance-authority, clsx, tailwind-merge, tw-animate-css,
@radix-ui/react-slot. Root bun.lock regenerated; `bun install
--frozen-lockfile` verified in sync (Docker-green).
- Migration plan at docs/shadcn-migration.md (bridge table, primitive->shadcn
mapping, prop-compat wrapper strategy, staged waves, honest risk/effort).
Verified: vite build, typecheck:ci, oxlint (0 errors), oxfmt --check, vitest
(641 pass), test:visual (48 pass incl. 6 new baselines), frozen lockfile in sync.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After the Tailwind v4 migration, ~15 component .css files were reduced to tiny
stubs holding only the few irreducible rules that can't be layered utilities
(@keyframes animations, focus-visible rings, a glass surface, a <select> caret,
::before/::after pseudos, attribute-selector overrides). Each still lived as its
own file + its own per-component import. They are all plain GLOBAL class
selectors, so the file boundary bought nothing.
This collapses them into one shared, intentionally-UNLAYERED stylesheet
(src/styles/residual.css), loaded once at app root (main-app.jsx, right after
index.css to preserve cascade order) and once in the visual harness
(harness.jsx, which previously got these rules transitively via the component
imports). Rules are moved verbatim — byte-identical selectors/keyframes/values —
with a "from <Component>" provenance header above each block. No @layer wrapping,
so they keep beating Tailwind's @layer utilities exactly as before. Zero visual
change: all 42 visual-regression snapshots pass unchanged.
Net -14 .css files (68 -> 54): 15 stubs removed, 1 consolidated file added.
Deleted stub stylesheets (import removed from each component .jsx):
- ui/Badge.css (.ui-badge--pulse dot animation)
- ui/Input.css (.ui-select native caret)
- ui/Panel.css (.ui-panel--glass backdrop surface + ::before)
- ui/Progress.css (shimmer ::after + indeterminate keyframes)
- ui/Segmented.css (.ui-seg__opt:focus-visible ring)
- components/AudioTrimmer.css (.audio-trimmer layout)
- components/DemoPresetGrid.css ([aria-pressed] active preview)
- components/MultiLangPicker.css (.multi-lang__drop + mlp-in keyframes)
- components/ReadinessChecklist.css (glass panel + rc-spin keyframes)
- components/TranscriptionPicker.css (row hover/focus-visible combinators)
- components/UpdatesPanel.css (updates panel chrome)
- components/VoiceSelector.css (combinators + spin keyframes)
- components/settings/ApiKeysPanel.css(.apikeys-row/badge test contract)
- pages/ToolsPage.css (h1 + code/pre typography overrides)
- components/BootstrapSplash.css (comment-only, no rules; import dropped)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second-wave CSS->Tailwind conversion of DubTab, building on wave 1 (#788).
Removes 119 more lines from DubTab.css (919 -> 800) by moving the
stateless/standalone idle-skeleton rules into utilities in IdleSkeleton.jsx.
Every conversion was proven pixel-identical against the LIVE app (real Dub
screen on a dev server, not the isolated component harness). A throwaway
Playwright spec captured baselines of three reachable Dub states, the rules
were converted, and the same states were re-shot and pixel-diffed with
maxDiffPixels:0 (exact). States verified:
- idle drop-zone (drop-zone leaves, URL ingest row, landing options)
- idle + Advanced expanded (landing-adv field row)
- file-loaded skeleton via setInputFiles, no backend upload (skel settings,
skel table cells/headers/hint, cast strip, stepper)
Converted (base/standalone rules -> utilities): dub-idle-drop__lines/__title/
__sub, dub-ingest-row + __input, dub-idle-upload-label, dub-hidden-file,
dub-landing-opts + __label, dub-landing-opts__lang base, dub-landing-adv +
__field base, dub-cast base + __row + __kicker/__label base + --muted__chip,
dub-skel-settings, dub-skel-field/--sm, dub-skel-translate-btn,
dub-skel-transcript-toggle, dub-inline-icon, dub-skel-cell-*/header-* cells,
dub-skel-hint, dub-skel-gen-row.
Deliberately LEFT as CSS (would regress, per the diff oracle / wave-1 doctrine):
anything with @keyframes/animation (dub-skel-bar shimmer, dub-idle-drop pulse),
:hover/state interplay (dub-ingest-row__cta.is-ready, dub-landing-opts__adv,
dub-cast__pair), and cross-file unlayered overrides that a layered utility
would lose to (dub-skel-table on .segment-table, dub-skel-row on .segment-row,
dub-skel-gen-btn / dub-change-row__cta on .btn-primary,
dub-skel-transcript-toggle__inner on .override-toggle,
dub-skel-cell-acts__icon on .segment-del, dub-speakers-input on .input-base,
dub-ghost-footer on .studio-panel). Class hooks were kept on elements whose
.dub-cast--muted / --grow / select descendant rules still need them.
Gates: oxlint (0), oxfmt --check (clean), vite build, vitest (641 pass),
bun install --frozen-lockfile (no change), bun run test:visual (42 pass).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The visual-regression harness could only snapshot pure leaf components.
Pages and settings panels couldn't render because they depend on the
Zustand store, react-i18next, react-query, and direct api/* fetches — so
the CSS→Tailwind migration had no pixel safety net for them.
Add an OPT-IN provider wrapper (providers.jsx): a spec declaring a
`providers` block gets a seeded Zustand store, forced-English i18n, a
snapshot-tuned QueryClient pre-filled via setQueryData, and an optional
window.fetch stub for components that call api/* directly. Nothing runs
for pure leaf specs, so existing leaf baselines are byte-for-byte
unaffected (verified: 0 leaf PNGs changed on regenerate).
Prove it on three CSS-heavy settings panels, each x3 themes:
- AppearancePanel — store + i18n only
- GeneralTab — store + i18n + seeded useSystemInfo query
- StoragePanel — fetch-stubbed GET on mount
ModelStoreTab is documented as not-harness-able yet (live EventSource SSE
+ virtualized react-table + required props). No new deps. Suite stays
local-only (bun run test:visual), not a CI gate.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the mechanical, self-contained layout/spacing/typography/simple-color CSS
of six leaf/misc components to Tailwind v4 utilities in their JSX, deleting the
now-redundant rules from each component .css. No intended visual change.
Conversion rules followed (matching the prior ui/ migration PRs):
- No preflight reliance: borders use `[border:1px_solid_…]`, button resets are
replicated (border/background/padding) rather than assuming a base.
- Only @theme tokens become named utilities (font-sans/serif/mono, rounded-lg,
text-fg…); --chrome-*/--space-*/--text-*/shadows use arbitrary `var()`/exact px.
- Component .css is unlayered and outranks @layer utilities, so a class is only
converted when its rule is removed; classes still governed by an unlayered
global rule (.input-base) or a remaining state rule keep their CSS.
- Kept in CSS: @keyframes, ::before/::after, :has()/child/sibling combinators,
:hover/:focus-visible/.is-active states, gradients/box-shadow/glass, animation,
!important, and @media. Class names are retained on the elements so those
rules (and the test selectors) keep matching.
- Shared/other-owned classes left alone: Sidebar's history-*/save-btn (rendered
by Workspace*), EngineMatrix's chip block (tested `.is-effective`, color-mix
variants) and __table (Table primitive), all Pip animation classes.
Files (rules removed vs kept):
- Sidebar: tabs/badge/search/empty/section-title/icon-tile/subtitle/scroll/tile
bases → utilities; kept .sidebar__tab (interactive), search-input (.input-base
override), search-clear (!important), save-btn (shared), is-collapsed
combinators, hovers. 286→182.
- EngineCompatibilityMatrix: matrix/head/title/body/row/cells/name/id/reason/
hint/last-error/why/why-body/chips/result/tabs/empty → utilities; kept table,
why-summary pseudo triangle, chip color system + tested .is-effective. 289→104.
- donate/Postcard: close/body/title/lead/goal-link/actions/cta/later/minor/star/
optout bases → utilities; kept the animated card, ::before perforation, grain,
stamp, hovers, keyframes, reduced-motion @media. 229→134.
- donate/DonateGoal (GoalBar): goal root/head/title/pct/track/caption/remaining/
caption-met → utilities; kept fill/shimmer/pip animations, --met/--mini
overrides, amounts-strong combinator, all Pip classes. 179→128.
- VoiceSelector: container/adornments/btn base → utilities; kept > .ss-wrap
combinator, btn hover/disabled, spin animation. 45→23.
- TranscriptionPicker: search/list/row/text/meta/empty base → utilities; kept
search>* and meta-span combinators, row hover/focus-visible. 29→10.
Verified: oxlint (0 errors), oxfmt --check clean, vite build, vitest (641
passed), bun install --frozen-lockfile (no change).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrate the safe, mechanical layout/spacing/typography CSS of two components to
Tailwind v4 utilities, leaving the hard-to-express rules in their .css files.
Conservative + partial by design (per the migration plan §8): no preflight is
assumed, so borders/transitions/chrome tokens stay as arbitrary properties
referencing the exact original vars (`[border:1px_solid_var(--color-border)]`,
`[color:var(--chrome-fg-muted)]`), @theme tokens use named utilities
(text-fg, bg-bg-elev-2, rounded-sm/md, text-accent/brand, bg-border), and every
non-@theme value (--chrome-*, --space-*, --text-*) is exact px or `var()`.
StoriesEditor.css 525 -> 349 (-176): converted the editor shell, header,
subtitle, toolbar groups/divider, stats/footer, empty state, cast/split panels,
the panel title, voice/cast dot, and the tone/drawer containers. KEPT: the h2
title (global `h1..h4` element rule is unlayered and would beat a `font-serif`
utility), the `.stories-track` grid + its hover/active/drag combinators, all
native controls (textarea/select/range + their focus states), every button
(UA reset + hover/disabled/`--on`/`--delete` states), the chapter bar (hover
combinators), the `::-webkit-scrollbar` pseudos, and the `[data-char]` color
palette attribute selectors.
LogsFooter.css 507 -> 376 (-131): converted the resize handle, top bar,
left/right clusters, the LOGS title, the count-badge base, the log-line base +
icon + line-text base, and the notification panel (body/item/icon/content/msg/
action). KEPT: the `.logs-footer` fixed shell (anchor for the
`.app-container .logs-footer` inset combinators + the <=600px media query),
every button (toggle/pill/version/discord/donate/icon-btn with hover/disabled/
animations), the severity color modifiers + their descendant overrides
(`.logs-footer__line--error .logs-footer__line-text`, badge/item variants,
clickable hover), the body scrollbar pseudos, the `notif-content strong` rule,
and all @keyframes + the reduced-motion block.
Classes that remain referenced by kept CSS (combinators/pseudos/attrs) keep
their BEM class in the JSX alongside the new utilities; fully-removed rules drop
the class entirely. No class used elsewhere in the tree was removed (grep-checked
across frontend/src).
Verified: npx oxlint (0 errors), oxfmt --write src + --check . (clean),
vite build (ok), vitest run (641 passed), bun install --frozen-lockfile
(no changes).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Converts only the clearly-mechanical, low-risk layout rules of the shared
"studio console" sheet (FirstRunSetup.css) to Tailwind v4 utilities in the
JSX consumers (FirstRunSetup, BootstrapSplash, SetupWizard). Most of the
1020-line sheet stays in CSS by design.
Converted (15 static layout-only rules, full property sets):
- containers: .frs__deck, .frs__col, .frs-panel, .frs__grid
- masthead: .frs__mast, .frs__mast-row, .frs__mast-meta, .frs__mast-selects, .frs-wsteps
- misc layout: .frs-opt__head, .frs__hw, .frs-row__gauge, .frs__foot-row,
.frs-log__bar, .frs-banner__actions
Approach honoring the no-preflight setup (only theme.css + utilities.css
are imported): exact rem/px preserved via arbitrary values
(gap-[1.1rem], grid-cols-[minmax(0,7fr)_minmax(0,5fr)], etc.); each base
rule is removed from CSS (component CSS is unlayered and would otherwise
beat @layer utilities) and replaced with a one-line breadcrumb. Every
remaining override stays in CSS and still wins because it is unlayered:
responsive media queries (.frs__grid/.frs__mast-row/.frs__foot-row/
.frs-row__gauge), modifier classes (.frs__deck--focus,
.frs-banner__actions--end, .frs-wsteps--journey), and descendant rules
(.frs-row__gauge .frs-meter).
Kept in CSS (unchanged): all @keyframes/animations (rise, breathe, alarm,
hw-pulse, meter), ::before/::after, glass/masks, color-mix backgrounds,
hover/focus/state (.is-active/.is-armed/etc.), typography, and media
queries. Cross-file/combinator-bound classes (.frs-wnav, .frs-embed,
.frs-row*, .frs-check*) left as CSS.
Verification: oxlint 0, oxfmt --check clean, vite build OK, vitest 641
passing, bun install --frozen-lockfile no change.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move mechanical layout/typography from five component CSS files onto JSX as
Tailwind v4 utilities. Conservative: rules that are shared across files, use
!important/color-mix/compound or descendant selectors, focus rings, font:inherit,
@media, or that would lose to unlayered index.css rules in the cascade are kept in
CSS. Verified visual equivalence, oxlint (0), oxfmt, vite build, and 641 vitest
tests; bun.lock unchanged.
DemoPresetGrid: fully converted grid/cards/buttons; CSS trimmed to only the
.demo-preset-card__preview[aria-pressed="true"] state (attribute selector kept
unlayered so it wins over the button's hover utilities).
DubbingDemo: converted head/title/dismiss/pane/caption/picker/chip/cta; kept the
shared container base (reused by the loading state), the max-width:720px media
query, and the input/pane-label-span/pane-video descendant + chip.is-active
compound rules.
DictationDemo: converted head/title/lede/card/lang/script/actions/result-base;
kept .dictation-demo and .dictation-demo__scripts (queried by
DictationDemo.test.jsx), plus status/result variants with their descendants.
DubSegmentRow: converted the local cell badges/labels/time-spans/restore-button/
checkbox; kept the shared .segment-* row/state classes (used by
DubSegmentTable.css, index.css, IdleSkeleton.jsx), the text inputs (font:inherit
+ focus), and the select/range/actions cells whose unlayered input-base /
input[type=range] siblings would otherwise beat utilities.
SegmentTrack: converted container/onsets/viewport-base/lane/label/handle-base/
actions/action-btn/playhead; kept the box and its JS-toggled state variants,
handle edges with hover/selected compounds, the self-scroll viewport modifier,
the disabled compound, and the visually-hidden announce region.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Converts the clearly-mechanical CSS (layout/sizing/typography/simple
color+border+radius, simple hover/focus/disabled) in the settings panels
to Tailwind v4 utility classes on the JSX, mapping to @theme token
utilities + arbitrary var()/px values for exact-pixel parity. The app
ships without preflight, so borders use the `[border:1px_solid_…]`
arbitrary-property form (matching ui/Badge.jsx) to keep border-style.
No behavior change; verified by strict 1:1 mapping + build + full tests.
StoragePanel: fully converted → StoragePanel.css DELETED (import removed).
field/input/buttons/restart/error all utilities; placeholder + focus-ring
via placeholder:/focus-visible: variants.
SharingPanel: fully converted → SharingPanel.css DELETED (import removed).
section/row/addr/btn(+ghost)/iconbtn/tailscale-*/qr/note/envname/portinput.
AppearancePanel: converted scale slider+readout, theme/font containers, and
the range input (accent-color). KEPT in CSS: `.appearance-panel__row--fonts
.st-row__control` (reaches into the SettingRow primitive), and the
theme-dot + font-tile rules (stateful transitions, multi-layer box-shadow
rings, is-active state) — not 1:1 utility-safe.
ApiKeysPanel: converted error/rows/head/name/meta/set/unset/whoami/masked/
actions/input/buttons/clear-dialog/checkbox. KEPT in CSS:
`.apikeys-row`, `.apikeys-row--active`, `.apikeys-badge`,
`.apikeys-badge--active` — ApiKeysPanel.test.jsx selects these by class
name (cross-file contract; STOP rule).
VoicePanel: converted the warn banner + most of the speech-model dropdown
(dropdown/trigger/name/list/item/itembtn/check/body/itemtop/itemname/
size/itemdesc/progresstext/action/iconbtn). KEPT in CSS:
`.voicepanel__row--model .st-row__control` (primitive descendant),
`.voicepanel__dd-chev`/`.is-open` (transform transition — Tailwind
`rotate-*` targets the `rotate` property, not `transform`, so it wouldn't
animate), `.voicepanel__dd-progress` + `> :first-child` (child combinator),
and `.voicepanel__spin` + `@keyframes` (animation).
PerformancePanel: UNTOUCHED. PerformancePanel.css is a de-facto shared
stylesheet — `.perfpanel`, `.perfpanel__error`, `.perfpanel__row`,
`.perfpanel__badge`, `.perfpanel__help` are used by 6 other panels
(MCPBindings, LLMEndpoint, Refinement, RemoteBackend, HFMirror,
Pronunciation), so the STOP rule leaves the whole file as-is.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the clearly-mechanical CSS (flex/grid, spacing, sizing, typography,
simple colors/borders/radii, and simple hover/disabled states) for seven
standalone widgets onto their JSX as Tailwind v4 utilities. Animation
(@keyframes), glass/backdrop-filter, pseudo-element/compound/sibling
selectors, !important, media queries, and any class referenced from
another file are left in CSS verbatim. Exact pixels/colors are preserved
via @theme token utilities plus arbitrary var()/px values; transitions use
arbitrary-property syntax so the timing function stays identical (Tailwind's
transition utilities inject a different default ease). No preflight is loaded,
so every converted border pairs an explicit border-solid/border-dashed + color.
- NetworkToggle: fully converted; NetworkToggle.css deleted and its import
removed (all classes were local).
- FloatingPill: converted the static content/label/meta/timer/error/progress
track + dismiss button; kept the pill base (animation+glass+fixed pos), dot,
progress-fill (base + indeterminate !important/animation), keyframes, the
prefers-reduced-motion block, and the --done/--error descendant overrides.
- AudioTrimmer: converted all audio-trimmer__* parts + trim-field*; kept the
.audio-trimmer base rule (also targeted by unlayered overrides in index.css).
- ReadinessChecklist: converted title/list/item/status-layout/label/detail/
fix/all-pass; kept the glass base, the rc-spin keyframe, and the dynamic
status--pass/warn/fail/loading color+animation modifiers.
- MultiLangPicker: converted chips/add/summary/search/list/section/option;
kept the .multi-lang__drop dropdown (animation + shadow) and mlp-in keyframe.
- WorkspaceVoices: converted only the local wv__active*/wv__empty-cta active-
voice card; kept wv/wv__head/wv__title/wv__search*/wv__scroll/wv__empty/
wv--collapsed/wv__rename-input (shared with WorkspaceProjects.jsx).
- WorkspaceHistory: converted the local wh/wh__* panel chrome (active chip
state expressed as a mutually-exclusive ternary since utilities are equal
specificity); kept the studio-with-history/studio-right/shell-narrow/
shell-mini layout rules (referenced by App.jsx, index.css, and tests).
Verified: oxlint exit 0, oxfmt --check clean, vite build OK, 641 vitest pass,
bun install --frozen-lockfile clean.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrate three UI leaf primitives from component .css to Tailwind v4 utilities,
each verified pixel-identical against the visual-regression harness across all
three baselined themes (default / midnight / catppuccin).
Because the app ships Tailwind v4 WITHOUT Preflight and themes override the
design tokens, colors/shadows/borders/transitions are expressed as arbitrary
*properties* (`[prop:value]`) referencing the exact original CSS variables
(avoiding `--tw-*` composition and color/length type ambiguity), while
@theme-mapped tokens use named utilities (text-fg, bg-bg-elev-2, rounded-lg,
text-danger…) which resolve to the same `var(--…)` and track themes. The
harness renders resting state, so hover/focus/active are converted faithfully
but not pixel-gated.
- Button: component is now fully utility-driven and no longer emits `.ui-btn*`
classes. Button.css is RETAINED unchanged because AudiobookTab.jsx consumes
`.ui-btn--{subtle,primary,icon}` as raw classNames (out of scope to refactor);
keeping the component class-free avoids double-application.
- Panel: layout/border/radius/padding/header/title/actions + solid & flat
variants → utilities. Panel.css trimmed to the glass variant only
(backdrop-filter + layered gradient surface + ::before highlight, which
utilities can't express). The header+body top-padding sibling rule is
reproduced via a conditional `pt-` when a header is present.
- Input: shared input/textarea/select shell, sizes, states, and the Field
wrapper → utilities. The `:has(.ui-field__icon)` padding rule is reproduced by
cloning the control with `pl-` when an icon is present. Input.css trimmed to
the native <select> caret (SVG data-URI background) only. Added Input to the
visual harness with a representative spread; baselines committed.
- Menu: left as CSS. It is a Radix dropdown whose content renders through a
Portal to document.body (outside the harness snapshot root #visual-root) and
only renders when open + collision-positioned, so it cannot be captured in
isolation here; its surface is also dominated by keep-as-CSS features
(backdrop-filter glass, gradient, @keyframes pop-in, box-shadow token).
Verified: bun run test:visual (18 passed), npx oxlint (0 errors),
oxfmt --check (clean), vite build (ok), vitest (641 passed),
bun install --frozen-lockfile (no changes).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Continue the component CSS -> Tailwind v4 utility migration for UI group 3.
Added Slider, Table, and Tabs to the visual-regression harness (specs.jsx +
manifest.ts) and committed machine-local baselines, then converted each
component, proving the result pixel-identical with `bun run test:visual`.
- Slider: fully converted; Slider.css deleted (no keyframes / complex
selectors). Token + arbitrary-value utilities preserve exact pixels; thumb
hover/active/focus-visible and the multi-easing transition are kept faithful
via arbitrary-property utilities. Visual-verified across all 3 themes.
- Tabs: fully converted; Tabs.css deleted. pill/underline variants, size,
active and hover:not(active) states mapped to conditional utility sets. The
`ui-tabs* / is-active / ui-tabs__icon` class names are retained as inert
hooks so Settings.css's unlayered overrides (`.ui-tabs.settings-tabs-ui …`)
keep winning over the layered utilities — Settings tab rail unchanged.
Visual-verified across all 3 themes.
- Dialog: partial conversion. Header / title / body / footer box-model +
typography and per-size max-width converted to utilities; the glass
gradient surface, backdrop-filter, fixed centering, and open/close
@keyframes remain in Dialog.css (cannot be reduced to utilities). NOT
visually verified: Radix Portal + position:fixed render the dialog outside
the harness's #visual-root, so it can't be snapshotted in isolation;
verified instead by 1:1 token equivalence + build + unit tests.
- Table: LEFT AS CSS (STOP rule). Its classes are a shared CSS contract, not
a private leaf — ModelsTable.jsx renders `ui-table-header`/`ui-table-header__cell`
directly without the component, and DubSegmentTable.css,
EngineCompatibilityMatrix.css, Settings.css, and index.css all hook those
global classes. Removing Table.css would break them, so converting yields no
safe net benefit. Added to the harness with a baseline for a future pass.
Verified: oxlint (0 errors), oxfmt --check (clean), vite build, vitest
(641 passed), bun install --frozen-lockfile (no changes), test:visual (24
passed).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrate three UI leaf primitives from component .css to Tailwind v4 utility
classes, mapping colors/radii/fonts to the @theme token utilities and using
arbitrary values (px / var() / color-mix / gradients) to preserve exact pixels.
Each conversion is proven pixel-identical to its pre-conversion baseline by the
Playwright visual-regression harness across all three themes.
- Badge: base, sizes, tones, and dot moved to utilities. Kept the
`.ui-badge--pulse .ui-badge__dot` rule in CSS — it is driven by an
externally-applied parent class (Header status badge) + global `pulse`
keyframes, which a utility on the component can't express.
- Segmented: container, options, sizes, hover (Radix data-state=off) and
active (data-state=on) moved to utilities. Kept `.ui-seg__opt:focus-visible`
in CSS: the global `:focus-visible` rule is unlayered and would otherwise win
over a layered utility, so the component override must stay unlayered too.
- Progress: track, sizes, fill, and per-tone gradient fills moved to utilities.
Kept the shimmer `::after` + indeterminate descendant rule + both `@keyframes`
in CSS (pseudo-elements / keyframes are not expressible as utilities).
- Tooltip: left as CSS. Its content renders through a Radix Portal into
document.body, outside `#visual-root` (the only element the harness snapshots),
so a conversion can't be visually verified — left untouched per the rule to
not force an unverifiable change.
Added Segmented + Progress to the visual harness (specs.jsx + manifest.ts) with
representative variants/states and committed their baselines. Badge was already
in the suite; its baseline is unchanged (byte-identical).
Verified: oxlint (0 errors), oxfmt --check (clean), vite build, vitest
(641 passed), bun install --frozen-lockfile (no change), test:visual (21 green).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gating prerequisite for the CSS -> Tailwind v4 migration: a pixel-for-pixel
safety net so each utility conversion can be verified against a known-good
baseline. There were previously no visual tests.
Approach: a lightweight Vite-served harness (NOT @playwright/experimental-ct-react)
that renders one presentational leaf component in isolation, with no Python
backend. Chosen because it adds zero new deps (root bun.lock untouched -> no
Docker frozen-lockfile risk), reuses the existing @playwright/test + bundled
chromium, and renders through the project's real Vite 8 + Tailwind v4 + token
pipeline so snapshots reflect the actual build output. CT's experimental React
runner on Vite 8 + React 19 was an avoidable compatibility risk.
- harness.html / harness.jsx: isolated render target driven by ?component=&theme=
URL params; applies themes via [data-theme] (default = bare :root Gruvbox),
loads the same fonts + token layers as the app, signals font-ready for stable
shots.
- specs.jsx: registry of pure variant spreads for Badge, Button, Panel,
SettingRow, SettingsToggle.
- manifest.ts: COMPONENTS x THEMES (default, midnight, catppuccin) the spec
iterates -> 15 committed baselines in __screenshots__/.
- playwright.visual.config.ts: dedicated config (separate from e2e), own Vite
server on port 3902, animations disabled, caret hidden.
- scripts: test:visual / test:visual:update.
- README: how to add a component, how to update baselines after an intentional
change, and why this stays local/manual (font/anti-alias differences across
OSes) rather than a blocking CI gate for now.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Tailwind v4 `@theme` block in src/index.css and the unlayered `:root`
in src/ui/tokens.css both declared the same `--color-*`, `--radius-*`, and
`--font-*` tokens. Because `@theme` lands in `@layer theme` (low priority)
while tokens.css's `:root` is unlayered, the tokens.css copy silently won —
the `@theme` literals were dead, losing duplicates. The two copies had
already drifted: the font stacks in `@theme` were the short variants while
tokens.css carried the full stacks (with 'Söhne', 'Cascadia Code', etc.),
so the resolved font-family came from tokens.css.
Make `@theme` the single home for the overlapping color/radius/font tokens
and delete the duplicates from tokens.css. To keep every resolved value
byte-identical (this is a pure de-dup, not a restyle), `@theme` adopts the
full font stacks that were actually winning at runtime. Tokens unique to
tokens.css (--color-muted-mono, --radius-pill, --font-display, --font-ui,
spacing, shadows, motion, z-index, etc.) are left untouched.
Theme switching is preserved: themes.css's `[data-theme=...]` overrides are
unlayered, so they still beat the now-@theme-sourced base (unlayered always
wins over @layer theme, regardless of source order).
Verification: a before/after `vite build` shows all 31 effective
`--color/--radius/--font` values identical; full vitest suite (641 tests)
green. Adds src/test/tokenParity.test.js, which fails if any
color/radius/font token is ever re-declared in both @theme and tokens.css
(catching the drift before it can recur).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Vanilla CSS … no Tailwind" rule contradicted the (already-wired) Tailwind
v4 setup and the CSS→Tailwind migration plan (#772). Replace it with the
utilities-first standard: Tailwind utilities (bridged to the design tokens via
index.css @theme) for layout/spacing/typography; keep .css files only for the
hard parts (glass, keyframes, pseudo-elements, :has(), theme rules).
Required by the docs-sync rule as P0 of the migration.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds oxfmt (Rust formatter, Prettier-conformant) — the repo had no formatter, so
this is a one-time normalization of the JS/TS/JSX code (257 files; purely
cosmetic — full suite stays 638/638).
Scope is deliberately narrowed in .oxfmtrc.json to JS/TS/JSX only:
- singleQuote:true + jsxSingleQuote:false — preserve the project's existing
style (single-quoted JS, double-quoted JSX attrs), not oxfmt's double-quote
default. (Flipping quotes globally also broke a source-string-parsing test;
preserving them keeps featureCoverage green.)
- Excludes **/*.css (the CSS→Tailwind migration will rewrite those — formatting
them now is wasted churn), **/*.json (avoids reformatting 20 i18n locale
files + config), **/*.toml, and src-tauri/** (Rust/Tauri config — out of scope
for a frontend JS formatter; oxfmt was reformatting Cargo.toml/tauri.conf.json).
Tooling:
- `bun run format` (write) / `bun run format:check` (verify).
- ci.yml: new "Frontend format check (oxfmt)" gate after the oxlint gate.
Verified: format:check clean; oxlint 0 errors; vite build; full suite 638/638;
bun install --frozen-lockfile in sync.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds taze (root devDep) + `bun run deps:check` = `taze -r --maturity-period 7`:
recurses the bun workspace (root + frontend), lists available updates, and is
READ-ONLY (never writes package.json without -w). The 7-day maturity window
skips just-published versions as a supply-chain precaution.
Manual tool by design — no auto-update, no Renovate infra, nothing added to CI.
Run `bun run deps:check` when you want a refresh overview; `taze major` for major
bumps; add `-w` to apply.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phased, bounded migration plan (not a big-bang): convert the mechanical ~80%
(flex/grid/gap/padding/typography/simple color) to Tailwind v4 utilities,
deliberately keep ~15-25% as CSS (glass/backdrop-filter, @keyframes,
::before/::after, :has(), !important). Realistic end state ~10-12k of 16.6k CSS
lines removed across ~5-7 weeks of small PRs.
Key gates the plan establishes before any conversion starts (P0):
- A Playwright screenshot baseline (default + dark + light) — the className-diff
trick used for the page refactors is useless here since class names change.
- Fix the @theme ↔ tokens.css token drift (single source + a parity test).
- Rewrite the CONTRIBUTING.md "no Tailwind" line (docs-sync rule).
Companion to docs/maintenance-pages-modularization.md.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add knip (dead-code/dep finder) for the bun workspace, then act on what it
found. Complements the oxlint gate: oxlint flags per-file unused symbols; knip
finds whole dead files/exports/deps across the project.
Tooling:
- frontend/knip.json + `bun run knip` script. Ignores the legitimate false
positives: public/aec-worklet.js (loaded via a dynamic AudioWorklet URL),
/@react-refresh (Vite dev inject), and tailwindcss + the Rust-side
@tauri-apps/plugin-updater / plugin-window-state JS packages (used by the
native plugin, not imported in JS).
Removed (all verified — build + tests + tsc + oxlint green):
- Dead files: CastingView.{jsx,css}, UpdateStatusChip.{jsx,css} (no refs; the
latter only survived in a stale comment, now reworded), and ui/motion.js.
- Unused deps: @radix-ui/react-popover, @radix-ui/react-select, @eslint/js,
eslint-plugin-react-refresh (the last two orphaned when eslint.config.js was
stripped for the oxlint adoption).
- 44 dead exports + 56 dead exported types across api/*, store/*, ui/*, utils/*:
deleted where used nowhere; dropped just the `export` keyword where still
referenced in-file.
Kept (justified): Slider primitive (keeps @radix-ui/react-slider meaningful),
AppMode export (a test string-parses its source), tailwindcss (CSS @import +
vite plugin).
Verified: oxlint 0 errors; tsc clean; vite build; full suite 638/638;
bun install --frozen-lockfile in sync (Docker rule).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The floating dictation pill (the "widget" window) was positioned bottom-center
by three near-duplicate blocks in lib.rs that each read primary_monitor(),
divided size by scale_factor, and called set_position(LogicalPosition...), with
a win.center() fallback. Replace all three with the official
tauri-plugin-positioner: window.move_window(Position::BottomCenter), preserving
the center() fallback on error.
- Add tauri-plugin-positioner = { version = "2", features = ["tray-icon"] }
(tray-icon enabled because the app ships a system tray).
- Register .plugin(tauri_plugin_positioner::init()) after single-instance.
- Collapse the global-shortcut, tray "dictate", and pill-mode pre-position
blocks to the plugin API. Behavior-preserving: same window, same trigger
points, still bottom-center.
Verified with cargo check (passes; the one warning is pre-existing in setup.rs).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The enable/disable/is_pill_autostart commands (and pill_autostart_path) were
defined in commands.rs and registered in lib.rs but NEVER invoked — no JS
caller, no internal Rust call, and no Settings toggle. ~155 lines of unwired,
hand-rolled cross-platform code (macOS plist / Windows registry / Linux
.desktop) maintained for a feature that was never shipped.
Investigated adopting tauri-plugin-autostart instead, but since nothing exposes
the feature, replacing dead code with a plugin (+ a new toggle) would be
building an unrequested feature. Removing the scaffolding is the honest cleanup;
if the "launch dictation pill at login" feature is ever wanted, wire it then via
tauri-plugin-autostart (init(LaunchAgent, Some(vec!["--pill"]))).
Kept: dirs-next (still used by config.rs/setup.rs — comment updated) and the
launch_as_widget config commands (those ARE used). cargo check passes.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Under LAN-share / remote-backend (a PIN/API key is set), ~28 raw fetch() calls
to the backend 401'd because they skipped the X-OmniVoice-Pin / Authorization
headers that apiFetch injects. Route them through apiFetch — fixing the auth
gap and adding the same transport-retry robustness (backend-restart windows
become invisible) the rest of the app already has.
Since apiFetch throws ApiError on !ok (and fires ov:pin-required on 401), the
now-dead `if (!res.ok) {…}` blocks were removed; surrounding try/catch handles
the ApiError. Streaming (.body.getReader), FormData, cache, and signal opts are
all preserved (apiFetch passes opts through; apiUrl is idempotent for absolute
URLs).
Deliberately left as raw fetch (documented): the auth-exempt /health liveness
probe (custom timeout/backoff), the RemoteBackendPanel pre-save connectivity
test (uses a user-typed target+key), WaveformTimeline (branches on 404 + may be
a blob: URL), VoiceGallery playUrl (also serves external community-CDN URLs),
and bugReport's fetchJsonWithTimeout (hard 2.5s bound, no retry by design).
Updated the #532 in-app-playback regression test to assert via apiFetch.
Verified: oxlint 0 errors; vite build passes; full suite 638/638.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ESLint was misconfigured (only globals.browser → 47 false no-undef) and run
NOWHERE in CI, so 259 errors had accumulated unnoticed. Replace it with oxlint
(Rust, ~50-100x faster) as the primary linter AND a real CI gate so lint debt
can't silently pile up again.
Tooling:
- frontend/.oxlintrc.json — correctness=error; no-unused-vars with the existing
^[A-Z_] convention; node/vitest env overrides + AudioWorklet/__APP_VERSION__
globals (kills the false no-undef class); max-lines:500 (warn).
- package.json: `lint` → oxlint, `lint:fix`, `lint:hooks` (advisory eslint).
- eslint.config.js stripped to ONLY the React-Compiler rule family oxlint can't
do yet (set-state-in-effect etc.), run via `lint:hooks`, NOT gated. Drop once
oxlint's JS-plugin support leaves alpha.
- ci.yml: new "Frontend lint (oxlint)" step in the Tests job — the gate.
Real bugs oxlint caught (were buried in ESLint's noise):
- GlossaryPanel: <X/> close-icon used but never imported → the edit-row cancel
button threw ReferenceError at render. Imported X.
- Two use*-named NON-hooks (useEngine action, useArchetypeAsProfile API call)
tripped rules-of-hooks; suppressed with documented disables (renaming these
misleading names is a worthwhile follow-up).
Cleanup to reach a 0-error gate: removed 52 genuinely-dead vars/imports across
18 files (heavy in App.jsx — stale useState left over from prior refactors) and
4 behavior-preserving autofixes (no-useless-fallback-in-spread / no-useless-escape).
Verified: oxlint 0 errors; bun install --frozen-lockfile in sync (Docker rule);
vite build passes; full suite 638/638.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 3 — same standard as #758/#759, applied to the last three over-cap pages.
Pure-mechanical, no behavior change.
- VoiceGallery.jsx 768 → 205: relocate the already-separate zone components
(ArchetypesZone, ArchetypeCard, CommunityZone, ImportsZone) + shared helpers
into components/gallery/.
- CloneDesignTab.jsx 837 → 395: split the ~540-line JSX return into section
components (ScriptPanel, AudioMethodPanel, DesignMethodPanel, ActionBar) +
MicButton, under components/clone/. State stays in the page.
- VoiceProfile.jsx 515 → 287: split the main return into ProfileHeader /
ProfileDetails / ProfileActivity under components/profile/.
Safety contract for the JSX splits (no render tests): explicit NAMED props on
every section so eslint no-undef verifies completeness on both ends; JSX moved
verbatim. Verified: 0 no-undef across all changed files; every original
className preserved (diffed main vs new set); every file <500 lines.
Verified: vite build passes; FULL frontend suite 638/638 pass.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(dub): extract DubTab sibling sub-components into components/dub (1593→1361)
Phase 2 (partial). Move the 5 self-contained presentational sub-components out
of the oversized DubTab.jsx into a new components/dub/ folder, matching the
components/settings/ pattern. Pure-mechanical, logic byte-for-byte identical.
Extracted (each with its own private helpers/constants):
- DubFailureNotice, DubPipelineStepper (+DUB_PIPELINE/DUB_PHASE_BY_STEP),
PrepOverlay (+PREP_FULL/PREP_CACHED/fmtBytesRate/fmtEta), TranscribeOverlay,
FooterBtn. fmtDur stays — it's used by the main component.
Pruned imports orphaned by the moves (copyText, errorDocsMap, a few icons).
Verified: vite build passes; dub tests (dubExpiredJobError + DubbingDemo)
11/11 pass; no new lint errors.
NOTE: DubTab.jsx is still 1361 lines — the main component is one ~1000-line
stateful JSX return over 28 hooks. Getting it under the 500 cap needs that JSX
split into section components, a higher-risk change deferred for a careful,
test-backed pass (see docs/maintenance-pages-modularization.md).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(dub): split DubTab JSX into section components (1361→380, all files <500)
Completes Phase 2. The DubTab component was one ~1000-line stateful JSX return.
Split that markup into five section components under components/dub/, keeping
ALL state/hooks/handlers/effects inside DubTab — only the JSX moved (verbatim,
by line-slicing).
Safety contract (this is behavior-critical and has no render test):
- Explicit NAMED props on every section (no bag/context object), so eslint
no-undef verifies prop completeness on BOTH ends — a dropped value becomes a
build error, not a silent runtime undefined. Verified: 0 no-undef across all files.
- All 137 classNames from the original are preserved (diffed main vs new set).
New sections: IdleSkeleton (368), DubLeftColumn (336), DubRightColumn (172),
DubFooter (78), DubHeader (63). DubTab.jsx is now a thin composition (380).
Verified: vite build passes; FULL frontend suite 638/638 pass; every settings &
dub file now under the 500-line cap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(settings): extract Settings.jsx tabs into components/settings (1969→602 lines)
Settings.jsx had grown to 1969 lines — every edit reloaded the whole file
into context and risked unrelated breakage. This finishes the migration the
existing components/settings/*Panel.jsx pattern started: the page is now a
thin orchestrator and each heavy tab lives in its own file.
Extracted (logic byte-for-byte identical; only import paths adjusted + the
shared isTauri/askConfirm moved to components/settings/native.js):
- GeneralTab, ModelStoreTab, EnginesTab, HotkeyTab, CredentialsTab
- native.js — shared isTauri() wrapper + askConfirm() Tauri-dialog helper
Also establishes the standard so files can't silently regrow:
- CONTRIBUTING.md: frontend file-structure & size limits (soft 300 / hard 500)
- eslint.config.js: warn-only max-lines:500 guardrail (CI stays green)
- docs/maintenance-pages-modularization.md: the phased refactor plan
Verified: vite build passes (all imports resolve); 18/18 settings tests pass;
no new lint errors introduced (the pruned imports were the only regressions).
Follow-ups (tracked in the plan doc): ModelStoreTab.jsx is 836 lines and
Settings.jsx 602 — both still over the 500 cap (warn-only); split next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(settings): split ModelStoreTab + Settings.jsx under the 500-line cap
Follow-up to the tab extraction: bring the two remaining over-cap files into
compliance with the new standard. Pure-mechanical, no behavior change.
Settings.jsx 602 → 399:
- Extract AboutTab, PrivacyTab, LogsTab into components/settings/
- Move the shared Row helper to components/settings/Row.jsx
- LogsTab keeps its state in Settings() (lower-risk); About/Privacy take props
ModelStoreTab.jsx 836 → 439, split into components/settings/models/:
- format.js (fmtBytes/orgColor), runtime.js (computeRowRuntime)
- columns.jsx exposes makeModelColumns(...) — a factory so the TanStack cell
closures keep working; called with the same useMemo dep array as before
- ModelsTable.jsx (virtualized table view), RecoBanner.jsx
Every settings file is now under 500 lines. Verified: vite build passes;
18/18 settings tests pass; no new lint errors (the 4 remaining in Settings.jsx
are pre-existing — refreshInfo no-op, a catch(e), two set-state-in-effect).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(settings): contain + tighten the whole Settings surface (measure cap, container-query stacking, wrap the shared rows)
Two systemic issues drove 'too spread out' + 'elements go out of view' across
many Settings pages:
1. Spread — .settings-content capped at 1280px, so on wide windows every
label-left/control-right row left a huge void. Introduce a --settings-measure
token (720px, macOS-like) + --settings-rail, and cap the content to it,
left-aligned under the nav. One token now controls the reading width.
2. Overflow + bad responsiveness — the row stack break was a *viewport* media
query (560px), but the 168px nav rail means a 760px-viewport window only has
~530px of content, so rows went side-by-side in a cramped box. Make
.settings-content a container (container-type: inline-size) and stack on the
CONTENT width via @container, keeping the viewport @media as a fallback for
the .st-row instances used outside Settings (Splash/FirstRun/Dub/SetupWizard).
3. The shared .perfpanel__row (button/badge row reused by 6+ panels:
RemoteBackend, HFMirror, LLMEndpoint, Pronunciation, MCPBindings, …) was an
inline-flex with no wrap and no max-width, so it ran off the right edge —
add flex-wrap + max-width:100% + min-width:0. Plus two rigid-width fixes that
escaped the row cap: ApiKeys input min-width:220→0, Appearance scale floor.
Frontend builds clean; tokens, @container query, and the wrap all verified in the
emitted CSS bundle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(settings): center the settings block + tighten measure (kill the lopsided right void)
The capped content was left-aligned, so on a wide window everything jammed to the
left with a dead empty third on the right (screenshot). Center the whole settings
block (nav rail + content) as a unit via max-width + margin-inline:auto, and drop
the measure 720→660 so label→control rows read denser. The cap is computed from
the tokens (rail + gap + measure + page padding) so the content track lands
exactly at --settings-measure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Set the release date to 2026-06-29, extend the #730 entry to note the chunked
dub-stream path is bounded + pool-reset too (#742), and add the bun desktop
dev-launch fix (#745) under CI. release.yml extracts this section verbatim as
the GitHub Release body, so it's now tag-ready.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`bun desktop` runs concurrently[dev:api, dev:desktop] with --kill-others-on-fail.
dev:api is a uvicorn backend on :3900, but the Tauri app launched by dev:desktop
ALSO manages a backend — on boot it sees :3900 in use (and not yet healthy,
because the dev backend is still importing torch + loading 32 models) and
'takes ownership', killing the dev:api process. That exits 137, which trips
--kill-others-on-fail and tears the whole session down.
The Tauri app already supports TAURI_SKIP_BACKEND to skip backend management
(lib.rs:654) — it just wasn't wired for the concurrently-managed dev flow. Set
it on dev:desktop so the dev app attaches to concurrently's backend instead of
fighting it. Set only on dev:desktop (not dev:api, and not the standalone
`frontend` desktop script, which legitimately self-manages the backend).
bun's script shell evaluates the inline VAR=val cross-platform (verified), so no
cross-env dep / lockfile churn. Prod (desktop-prod) is unaffected — there the
Tauri app is the sole backend manager and orphan-kill is correct.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
snapshot_download's resume trusts an existing file by size, so a present-but-
corrupt blob is never re-fetched: the resume-repair 'succeeds' yet the reload
still raises the truncated-cache OSError, and the user was sent to a manual
delete-and-reinstall. Add a force=True path (force_download) and wire it as a
last resort — on the post-resume reload failure, force a full re-download once
(replacing corrupt blobs) and retry the load before falling back to the
actionable message. Force is reached only after a plain resume-repair didn't
fix it, so the common missing-file case still avoids re-downloading everything.
Tests: corrupt cache force-repairs on the 2nd failure (resume then force),
force_download is set only when force=True, and an unfixable cache still
surfaces the 'could not be auto-repaired' message.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The whole-file transcribe paths recover from a wedged worker via
run_transcribe_guarded's pool reset (#731), but the chunked dub transcribe-stream
only recorded a per-chunk timeout error and moved on — leaving the stuck thread
holding its GPU-pool worker, so subsequent chunks / a concurrent TTS generate
could still starve into 'can't reach backend'. Reset the pool on the per-chunk
TimeoutError via a small _reset_pool_on_wedge() helper (best-effort, no-op for a
plain executor). Closes the residual on #730.
Tests: helper resets a reset-capable pool and no-ops a plain ThreadPoolExecutor.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_repair_model_cache attempted snapshot_download exactly once; a single transient
failure (the very cause of an interrupted download) returned False and sent the
user back to a manual delete-and-reinstall. Wrap the re-fetch in a bounded retry
loop (3 attempts default, linear backoff) — snapshot_download resumes between
attempts so retries are cheap and idempotent. Counts/backoff are env-tunable
(OMNIVOICE_MODEL_REPAIR_RETRIES / _BACKOFF_S) for restricted networks and set to
zero-backoff in tests. Offline mode + the actionable fallback message are
unchanged.
Tests: retry-then-succeed self-heals, exhausted-retries returns False after N
attempts, single-attempt tunable, backoff disabled so the suite stays fast.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds Rust/Cargo as a from-source build prerequisite across the linux/macos/windows install docs. Thanks @Deepakv2104.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A whisperx/CTranslate2 transcribe can hang hard on some Windows+CUDA setups and
never return. ASR shares the small (1-2 worker) _gpu_pool with TTS, so one stuck
worker starved every other request — the next TTS generate then surfaced as
"Can't reach the local backend" though the process was alive (#720/#721/#723).
Two parts:
- Bound the three remaining unguarded whole-file transcribe paths (dub
whole-file dub_core.py, batch.py, live-dictation capture_ws.py) with
run_transcribe_guarded, matching the dub-QC/dictation/OpenAI paths that were
already bounded by #656.
- On timeout, run_transcribe_guarded now calls executor.reset() when the pool
supports it (_ResilientGpuPool, already built for the model-load-timeout case
in #589/#599): the wedged worker is abandoned and the next submit gets a fresh
one, restoring capacity without an app restart. Best-effort — a plain
ThreadPoolExecutor (tests) just gets the bound + actionable error.
Regression tests: pool.reset() is invoked on timeout; a non-reset pool still
bounds cleanly.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A BrokenPipeError surfacing from generation means the backend's stdout/stderr
pipe to the desktop shell that launched it closed mid-render (an orphaned or
relaunched backend) — not out of memory. _oom_friendly_reraise mislabeled it
"ran out of memory — try Flush," which never helps. Add a BrokenPipeError /
[Errno 32] branch (same pattern as the #705 WinError-193 and #437 permission
branches) that tells the user to restart the app instead. main.py already wraps
sys.stdout/stderr to swallow EPIPE; this catches the C-level writes inside the
native engine/torch that escape that guard.
Regression test covers both the typed BrokenPipeError and a string-wrapped
"[Errno 32] Broken pipe".
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the responsive-containment fix (#713). Make Settings control inputs
unable to overflow the available width regardless of inline widths a panel sets:
- RemoteBackend's Backend URL + API key inputs hard-coded style={flex:1,
minWidth:220} in a right-aligned 60%-max control — on a narrow control that
220px floor overflows. They're long-value fields, so lay them out as full-width
stacked rows (st-row--stack) with the shrinkable .st-input class instead.
- Add a universal guard: any text-ish input/select/textarea inside .st-row__control
gets min-width:0 / max-width:100% / box-sizing, so no panel's raw input can
spill past the row. Pairs with the page/row minmax(0,1fr) grids.
Pure presentation; 638 frontend tests pass; build clean.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dubbing a video URL on Windows (v0.3.8) failed with 'You have requested merging
of multiple formats but ffmpeg is not installed.' The download format selector
pulls separate video+audio streams, so yt-dlp muxes them via ffmpeg
(merge_output_format=mp4) — but yt-dlp only checks PATH, while OmniVoice's ffmpeg
is typically a bundled Tauri sidecar / imageio-ffmpeg binary that isn't on PATH.
yt_download_sync now sets ydl_opts['ffmpeg_location'] = find_ffmpeg() (the same
resolver the rest of the dub pipeline uses) when ffmpeg is resolvable; if it
isn't, the key is omitted so yt-dlp falls back to PATH as before (no regression).
Tests assert the location is passed when resolved and omitted when not.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A synth that already produced and saved its audio could still return a 500:
'no such table: generation_history' — a DB that somehow missed schema init
(init_db's executescript never took) made the history INSERT raise after the
clip was done, losing the user's generation to a logging side-effect.
- Add db.ensure_schema(): idempotent CREATE ... IF NOT EXISTS + additive column
reconcile (no _migrate/alembic), safe to call from a write path.
- Generation history write now self-heals: on a sqlite OperationalError it runs
ensure_schema() and retries once; if it still fails it logs and returns the
audio anyway. A history-logging failure can never fail the generation.
Regression test: the write raises 'no such table: generation_history' before the
heal and succeeds after (fail-before/pass-after), plus ensure_schema idempotency.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Right-side control values/pills (e.g. Privacy's LOCAL SQLITE / OFFLINE
TRANSLATION / NONE — NO TRACKING, and long stored-at paths) clipped off the
right edge on wide windows.
Root cause: both settings grids used a bare '1fr' track (= minmax(auto,1fr)),
whose 'auto' minimum is the content's min-size. A non-shrinking child — a nowrap
status pill or an unbreakable path — forces the track wider than the viewport,
and .settings-content's max-width can't claw that back, so it clips at the
window edge.
Fix: minmax(0, 1fr) on both grids so the tracks can shrink below content
min-size:
- .settings-page → 168px minmax(0, 1fr) (the content column)
- .st-row → minmax(0, 1fr) auto (a long title can't shove the control
off-screen; the label shrinks/wraps)
Shared layout primitives, so this contains EVERY settings page responsively.
Pure presentation; 638 frontend tests pass; build clean.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Takes over and completes #639 (original work by @trungthanh1288). Dub generation
held every segment's audio in RAM until final mix, so long/feature-length dubs
and big batches could exhaust memory. Segments now stream to disk as rendered;
the final track assembles from those files via a 30s-chunk memmap writer, so
peak memory stays flat regardless of length.
Completed on top of the original PR:
- Watermarking: keep the project's 'every OmniVoice audio carries the signature'
guarantee without double-marking. Since seg_<id>.wav is BOTH the downloadable
file AND the assembly input, mark each fresh segment once at synthesis and drop
the per-chunk embed in the memmap writer (the final mix inherits the mark) —
main's proven policy. Verified with real AudioSeal: 0.9999 detect confidence on
the final track and on seg WAVs; cached/silence not re-marked.
- Fix a crash regression: zero/negative-duration segments returned an in-memory
zero-length entry instead of writing empty audio (which raised). Regression test
added.
- Perf: drop per-segment gc.collect(); throttle empty_cache() to every 16th call
(the replaced code batched I/O to keep this off the hot path).
- Clean up the mix_<id> temp WAVs after assembly.
- Rewrite the watermark test for the multi-chunk (>30s) path; assert both the
final track and the seg WAV are marked, with no double-mark.
212 passed / 1 skipped; route inventory clean.
Co-authored-by: mergetest <test@local>
Co-authored-by: trungthanh1288 <trungthanh1288@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The headline predated the later 0.3.8 work. Bring it current — Settings
redesign, macOS native drag-drop (incl. macOS 26), the ASR CTranslate2-load
fallback, the pronunciation dictionary, and the more-honest error messages
(corrupt binary != OOM, model-id self-heal, stale-dub reset). release.yml
publishes this section verbatim as the GitHub Release body, so the headline is
the first thing users read on the v0.3.8 release.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up from independent verification of #693/#705.
#693 (whole-class): the resolver only guarded the model-load site. A leaked
engine id in OMNIVOICE_MODEL still hit four other raw reads — most importantly
preload_model()'s model_info() probe, which failed on the bad value and
SILENTLY disabled warm-up (first /generate then ate the full load). Plus the
Settings 'model_checkpoint' display, the loaded-models list, and the engine_id
baked into exported persona bundles. Route all of them through
resolve_omnivoice_checkpoint() (personas keeps its '' unset marker, sanitizing
only a set value). Add a source-level recurrence guard so a future raw read
can't reintroduce the class.
#705: tighten 'winerror 193' -> '[winerror 193]' so the substring can't also
match WinError 1930-1939 (the portable 'is not a valid win32 application'
clause still covers non-Windows formatting).
48 tests pass (resolver + guard + audio-guard + route inventory); edited
routers/services import clean.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A synth failure from a corrupt or wrong-architecture native binary on Windows
([WinError 193] %1 is not a valid Win32 application — torch, ffmpeg, or a
bundled engine binary) fell through to the generic OOM message ('ran out of
memory — try Flush'), sending the user down a path that can't help.
_oom_friendly_reraise() now detects the WinError 193 / 'is not a valid Win32
application' signature (before the OOM fallback, joining the existing
torch.compile / decode-glitch / bad-instruct cases) and surfaces an actionable
'reinstall or repair that component; Flush won't help' message. Regression test
added.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The app's drop zones (clone reference, dub video, stories, batch) all use HTML5
dataTransfer.files, but tauri.conf.json never set dragDropEnabled, so it defaulted
to true — Tauri intercepts the OS file-drop and the webview's HTML5 drop never
receives the files. Most visible on macOS WKWebView and fully broken on macOS 26
(Tahoe). Set dragDropEnabled: false on the main window so the webview handles
native HTML5 drops uniformly across platforms.
(The Clone/Design textarea-resize half of #700 was already fixed for 0.3.8 by
#595/#607; the reporter is on v0.3.7.)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the user-facing entries that landed after the initial [0.3.8] draft:
- Changed: the full Settings redesign (#686/#690/#696) and the inline first-run
HF-token input (#687/#688).
- Fixed: OMNIVOICE_MODEL self-heal (#693), ASR CTranslate2 .so-load fallback
(#692), and the stale-dub recovery extended to initial upload/ingest (#695).
Bump the section date to the expected cut date (set authoritatively at tag time).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On hardened kernels / newer glibc (e.g. WSL2 glibc 2.43) CTranslate2's shared
object is rejected at load with 'libctranslate2…cannot enable executable stack'
— an OSError, not ImportError. The WhisperX/faster-whisper is_available() probes
only caught ImportError, so the OSError escaped and crashed the ASR/dub
preflight ('ASR backend initialization failed: …').
- Both probes now also catch the non-ImportError load failure and REPORT
(False, 'failed to load …') instead of raising — a probe must never raise.
- _auto_detect() routes every probe through a never-raising _probe_available()
so no exploding probe can crash engine selection; it falls through to
pytorch-whisper (transformers, no CTranslate2), which works on CUDA/CPU.
Regression tests cover the raising probe, the .so-load OSError surfacing as
unavailable, and auto-detect falling back to pytorch-whisper.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A stale/misconfigured OMNIVOICE_MODEL holding a bare TTS *engine id* (e.g.
"omnivoice") was passed straight to OmniVoice.from_pretrained(), which 500s
with "omnivoice is not a local folder and is not a valid model identifier
listed on huggingface.co/models".
Add resolve_omnivoice_checkpoint(): honor only a HF repo id (org/repo) or an
explicit local path (absolute / contains a separator); any bare token self-heals
to k2-fsa/OmniVoice with a logged warning — so a bad value can't brick model
load (and can't be faked by a cwd-relative folder of the same name). Regression
tests cover the leak, valid repo ids, absolute local dirs, and blanks.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The #660 fix wired the stale-job recovery (isExpiredDubJobError → reset) into the
retry and SRT-import handlers, but NOT the two INITIAL handlers (handleDubUpload,
handleDubIngestUrl). So a job that went missing during the first upload→prep→
transcribe flow (backend reload, cache eviction, manual cleanup) surfaced the
scary "Job not found … report a bug" toast instead of quietly resetting the
stale session — exactly the reported error.
Route stale-job errors through isExpiredDubJobError() in both initial handlers
too (before the reportable fallback), matching retry/import. Add a source-level
regression guard so no dub handler can silently drop the stale-job check again
(the #660→#695 regression class).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Owner review of the live pages: the 760px content cap left a dead empty right
half on simple tabs (Appearance), while wide tabs (Models) showed mid-word path
breaks, an overflowing HF_ENDPOINT input, and controls flush to the border.
- Content fills full width (removed the 760px cap + redundant models opt-out);
1280px ceiling only on ultra-wide. Comfortable side padding both sides.
- Read-only mono path values wrap only at boundaries (no `…cach/e…` mid-word).
- Inputs capped (min(360px,100%)) + box-sizing so HF_ENDPOINT/cache never overflow.
- Right padding on .st-row__control so controls aren't flush to the edge.
- Input-heavy rows (mirror preset, HF_ENDPOINT, cache location) go full-width
below their label instead of a crushed right slot.
Pure presentation; 636 tests pass; build clean.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A design-council-driven overhaul of the Settings UI for a clean, professional,
compact-yet-gorgeous feel (Notion/Obsidian quality), addressing "looks amateur,
too tall, doesn't make sense":
- Typography: section titles move from mono-uppercase ("debug log" look) to
sans sentence-case 600; mono reserved strictly for data values. Three clear
type levels.
- Density: single-line ~32-40px rows (grid 1fr auto), hairline dividers instead
of card-per-row, one muted description max per row (SettingRow hardened so the
old double-description line is structurally impossible).
- Navigation: kill the rainbow per-tab accents → one --chrome-accent; ≥760px a
sticky vertical nav rail + a calm 760px content column (no stretch to the rail
height — the empty-void fix); <760px a no-wrap horizontal scroll strip.
- Controls: full-width horizontal grids for the font + theme pickers (were a
squeezed vertical stack); unified tile/toggle/input styling via a new
SettingsInput primitive; tokenized off-token literals.
- No tacky wrapping: descriptions wrap at a comfortable measure (text-wrap:
pretty, no orphans); short control values never break mid-word.
Pure presentation — no behavior, handler, prop, testid, role, or i18n-string
changes. 636 frontend tests pass; build clean; --chrome-* tokens only.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the bulky HF-token card (icon + title + paragraph + input row + link)
with a single-line input bar — paste a token, Save — pinned right by the
'Waiting for required models…' / Continue button. Takes only the HF token; the
explanation collapses to a one-line prompt (hidden on narrow widths) plus a
'Get one free →' link, and a slim '✓ saved' confirmation. Same save path and
i18n keys; cleaner and lighter on the page.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 'Add a free Hugging Face token for faster downloads' card sat at the bottom
of the scrolling model library, so users had to scroll past every model to find
it. Extract it into a standalone HfTokenCard and pin it in the wizard's
always-visible action area, right above the 'Waiting for required models…' /
Continue button — visible at a glance, click and paste a token without scrolling.
Compact hint so it doesn't crowd the button. No behavior change to saving.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(settings): shared design-system primitives + shell restyle (unit 0)
Foundation of the Settings redesign. Adds reusable primitives
(SettingsSection, SettingRow, InfoHint, SettingsToggle, Collapsible) styled
purely with --chrome-* tokens, and restyles the Settings shell: reordered
icon tab-nav, inline tabs (General/Hotkey/Credentials/Logs/Updates/About/
Privacy) migrated to the primitives, Proxy/FFmpeg/advanced rows tucked into
Collapsible, long prose moved into InfoHint popovers. Row() delegates to
SettingRow. No behavior changes; ModelStore table/SSE untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(settings): restyle all panels onto the design system (units A/B/C)
Migrate the 13 settings panels to the shared primitives — pure presentation,
no behavior change:
- Bucket A (Aec/Performance/Refinement/HFMirror/LLMEndpoint/MCP/Pronunciation):
long prose (torch.compile OOM, refinement examples, etc.) moved into InfoHint
popovers; custom checkboxes → SettingsToggle.
- Bucket B (ApiKeys/RemoteBackend/Sharing): Tailscale/help prose → InfoHint +
Collapsible 'Advanced'; ApiKeys/Sharing CSS converted off hardcoded colors to
--chrome-* tokens (they mis-themed on 5 of 6 themes).
- Bucket C (Appearance/Storage/Voice): VoicePanel switch → SettingsToggle;
Appearance/Storage CSS tokenized; prose → InfoHint.
- SettingsToggle now forwards arbitrary props (data-testid/aria) to the input.
All 636 frontend tests pass; build clean; no new deps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(settings): query VoicePanel/Appearance switches by role after SettingsToggle migration
The VoicePanel enable switch moved from a testid'd checkbox to the SettingsToggle
primitive (role=switch); update the assertion accordingly. Was missed in the
panel-restyle commit because this test lives under src/test/, not components/settings/.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(tts): user pronunciation dictionary (expressive-tts slice 1)
Per-term, per-language pronunciation overrides applied to text before synthesis,
so names, brands, and acronyms come out right across generate, longform, and dub.
Closes part of the #1 perceived-quality gap vs ElevenLabs (pronunciation
dictionaries). First slice of docs/specs/01-expressive-tts.md.
- Schema: additive `pronunciation_entries` table (alembic 0008, mirrored into
_BASE_SCHEMA; tested upgrade — idempotent, downgrade, converge, back-compat).
- Service: extend pronunciation.py to load enabled entries (cached) and apply
longest-first, word-boundary-aware, per-language (global '*' + lang match,
lang overrides global), reusing the existing ReDoS-safe matcher.
- Inline one-off `[[term|replacement]]` overrides that don't persist and don't
collide with [voice:]/[pause]/[Name]/SSML-lite (resolved pre-chunking).
- API: /pronunciation CRUD + /test dry-run + import/export (loopback-guarded).
- Apply point: generation.py after language resolves, before chunking — covers
native + pluggable engines.
- UI: PronunciationPanel in Settings → General; all strings via i18n.
- Tests: migration lifecycle, CRUD, per-language, precedence, inline override,
apply-at-synth. Route snapshot regenerated (+7).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(security): bound inline-override regex (ReDoS) + annotate parameterized UPDATE
CodeQL flagged py/polynomial-redos on the [[...]] inline-override regex: [^\]]
also matches [, so an unterminated run of [ allowed O(n) rescans from O(n)
positions. Bound the inner class to {0,256} (linear; an inline override is a
short respelling). Annotate the dynamic UPDATE (B608) — its column fragments are
fixed literals and every value is a bound parameter; not an injection vector.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(dictation): live local dictation via sherpa-onnx + Voice settings panel
Add a sherpa-onnx ASR engine alongside the existing Whisper/NeMo dictation
path, powering a genuinely live experience: as you speak, words type straight
into the focused field (streaming partials via a new simulate_type command,
self-correcting with backspaces) and commit per pause.
Backend:
- SherpaDictationBackend + sherpa_dictation registry of the 7 models (Parakeet
TDT v3/v2, streaming Zipformer EN/ZH/bilingual, Paraformer bilingual, Whisper
Tiny) from csukuangfj/* int8 HF repos; CPU provider for cross-platform parity.
- /dictation/models + /dictation/prefs router; get_capture_asr_backend() honors
the selected dictation model. get_active_asr_backend() (dub transcription) and
the legacy WebM/Opus capture path are untouched.
- True streaming over /ws/transcribe (OnlineRecognizer: live partials +
per-endpoint finals); offline models surface partials via short re-decode.
Frontend:
- New "Voice" settings panel (enable, Toggle/Hold mode, model picker with
offline/streaming/recommended badges + per-model download/delete).
- Live word-by-word typing via simulate_type (enigo) with prefix-diff delta and
backspace correction; paste fallback retained, no double-insertion.
Deps: sherpa-onnx>=1.13.3 (+ sherpa-onnx-core); uv.lock regenerated, Docker
frozen-install verified. API route-inventory snapshot updated. 40+ new tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(dictation): register sherpa-onnx-asr engine in README + features inventory
Fixes the docs-drift CI guard: the new sherpa-onnx-asr ASR engine existed in
the registry but not in docs/features.yaml or README. Adds the live-dictation
engine row to the ASR Engines table, bumps the engine counts (8→9), and adds
the inventory entry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(changelog): fold live-dictation into the [0.3.8] section
main is 0.3.8 (untagged), so the dictation feature belongs in that release, not
a separate [Unreleased] block. Merge the two Added lists under one [0.3.8],
refresh the headline to lead with live dictation, and correct the capture
description to reflect live word-by-word typing (not paste-on-pause).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In Voice Design, choosing "Male" (or any gender) could have no audible effect.
Root cause: the design synthesize branch forwarded the selected `profile_id`
alongside the design instruct. If that profile is a CLONE (reference audio, no
instruct) — e.g. the demo voice selected by default — the backend clones it, and
the reference voice's gender/timbre overrides the "male" attribute, so the design
slider appears to do nothing.
Fix: a pure `designModeProfileId(selectedProfile, profiles)` decides what to send
in design mode — it suppresses a KNOWN clone (no instruct) so the design
attributes drive the voice, while a design profile (carries an instruct) still
passes through to re-render a designed voice. Conservative: an unknown id
(profiles not loaded) or a design profile is unchanged, so this only removes the
gender-hijacking case. Threaded `profiles` into useTTS.
Test: voiceInstruct.test.js — clone (no/empty instruct) → null, design profile →
its id, empty/null → null, unknown id → passthrough.
Closes#674
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A model load failed with `[Errno 2] No such file or directory:
'…/site-packages/transformers/models/qwen3/modeling_qwen3.py'` — the user's
transformers install was incomplete (the file is missing while a correct 5.3.0
install has it; an interrupted `uv sync` / antivirus / partial update drops it).
The System Check showed the raw path + "Check logs and try restarting", which is
useless — restarting can't restore a missing file.
Two fixes:
1. core.failure.classify(): recognize this corrupted-install variant. It's a
FileNotFoundError, not an ImportError, so the existing TRANSFORMERS_IMPORT
match ("could not import module"/"AutoFeatureExtractor") missed it. Now also
matches a "no such file"/"errno 2" + "transformers" + "site-packages" signal
(substrings checked separately so it works on POSIX `/` and Windows `\`
paths). An unrelated package's missing file is NOT mislabelled.
2. model_manager._load(): build the /model/status error via build_failure so it
carries the classified hint AND strips the home dir, instead of storing the
raw str(exc). The System Check now shows "Your transformers install is
incomplete. Reinstall it (uv pip install --reinstall transformers) or switch
ASR to faster-whisper" — the existing TRANSFORMERS_IMPORT hint.
Docs: troubleshooting §1a documents the error + the reinstall fix.
Test: test_failure_classify.py pins the POSIX + Windows path forms classify as
TRANSFORMERS_IMPORT with a "reinstall" hint, and that an unrelated package's
missing file does not.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first-run download line showed wrong numbers — e.g. "8% · 1 KB/s · 0.0 MB
left" on a 2.4 GB model that was barely started. The #657 display summed the
PER-FILE tqdm SSE events on the frontend, but under parallel/segmented fetch the
big weight shards report total/rate as 0, so the sum was garbage (tiny total →
"0.0 MB left", a couple small files → "1 KB/s").
The backend already solves this: download_aggregator emits a throttled
`phase:"aggregate"` event with one windowed rate + ETA + bytes_done/total_bytes
(+ files done/total), seeded by the dry-run preflight totals — precisely because
summing per-file on the client is unreliable. But WizardLibrary dropped that
event (`if (!ev.filename) return prev`) and never used it.
Fix: capture the `aggregate` event into per-repo state and render from it
(new pure `progressFromAgg`), falling back to the per-file sum only until the
first aggregate arrives. Now the line shows real, live values, e.g.
"8% · 5.2 MB/s · 2.2 GB left · ~7m", updating in real time and landing on 100%.
Test: wizardLibraryAggregate.test.js — progressFromAgg yields correct
pct/remaining/rate/ETA from real totals (2.2 GB left, 5.2 MB/s — not 0.0 MB /
1 KB/s), returns null until totals are known, and caps pct at 100.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Renames [Unreleased] → [0.3.8] — 2026-06-24 with a one-paragraph headline in the
house style, and adds the entries merged since v0.3.7 that weren't yet logged:
faster default downloads + the surfaced HF-token card (#669/#657), the auto-play
toggle (#666), the status-bar version badge (#671), and the Windows/stability
fixes — WhisperX-on-Windows (#630), transcribe timeout (#656), preview playback
(#653/#659), stale dub session (#660), bad-instruct 400 (#664/#612), Insert
popover clipping (#672), and the M1 startup-hang bound (#632). A fresh empty
[Unreleased] is left above it for the next cycle.
This makes cutting v0.3.8 a single `git tag` away: release.yml extracts this
section verbatim as the GitHub Release body, so the tag ships real notes instead
of the auto-generated fallback. (Owner adjusts the date if tagged on another day;
no version files touched — this is docs only.)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On the Voice Clone tab, the ⊕ Insert popover (15 expression-token chips) opens
upward from the lifted button (`bottom: 60px`) but had NO max-height — so the
wrapping chip grid grew unbounded and, when the button sat high in a tall script
panel, the popover shot past the top of the app window and the first rows were
clipped behind the title bar (reported with the tokens overflowing above the
OmniVoice header).
Cap it: `max-height: min(280px, calc(100vh - 120px))` + `overflow-y: auto`
(+ `overscroll-behavior: contain`). The popover is now a compact, scrollable box
that sits just above the button and always stays within the viewport, regardless
of how tall the script is or where the button lands. Horizontal guard (#481) and
the upward anchor are unchanged.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bottom status bar showed no version and had no quick path to updates. Add a
small `v<version>` badge next to the network/share icon; clicking it opens
Settings → Updates. When an update is available (or downloaded and ready), the
badge highlights and shows a pulsing notification dot, and its tooltip names the
new version — so users can see at a glance that an update is waiting and one
click takes them to install it.
Mechanism: a one-shot `pendingSettingsTab` hand-off in the UI store (mirrors the
existing `pendingProfileId` pattern) + an `openSettingsTab(tab)` convenience that
sets the tab and navigates in one call. Settings consumes it as its initial tab
and clears it (an effect covers the already-open case). The indicator reads the
existing `updateStatus`/`updateVersion` from the updater slice — no new update
plumbing. Version from the shared APP_VERSION constant; new strings via i18n.
Test: openSettingsTab.test.js — the convenience sets mode=settings + the pending
tab, and the value can be cleared after consumption.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two changes that make first-run downloads faster and easier to speed up further.
1. Segmented (multi-connection) downloader is now ON by default. The app forces
the legacy-LFS path (HF_HUB_DISABLE_XET=1) for clear progress, but that path
is single-stream and slow — which is why downloads felt sluggish. The built-in
IDM/uGet-style segmented accelerator (parallel byte-ranges, live speed/ETA)
was already implemented but defaulted OFF. Flip it ON: it only engages when
Xet is inactive (the default), and ANY failure falls back to snapshot_download
("can never compromise a correct install"). Pure-httpx, cross-platform,
auth-safe (token never forwarded to a CDN). Override with
OMNIVOICE_SEGMENTED_DOWNLOAD=0.
2. The Hugging Face token field is now a prominent, always-visible card right
above Continue — was a collapsed "advanced" fold almost nobody opened. A free
token gives authenticated downloads (higher rate limits, fewer stalls), so it
pairs with change #1 to keep the parallel fetch from getting throttled. The
card leads with the speed benefit, shows a saved-state, and adds a one-click
"Get one free →" link to huggingface.co/settings/tokens.
Docs: downloading-models.md updated — the legacy-LFS section now documents the
default-on segmented accelerator + the HF-token speed tip, and the tuning table
reflects OMNIVOICE_SEGMENTED_DOWNLOAD=0 as the disable knob (docs-sync).
Test: test_segmented_download_default.py pins the new default ON and that the
env override still disables it; existing FDL-08 behavior tests stay green.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After a render finishes in Voice Clone / Design / a profile's try-it box, the
output preview auto-plays unconditionally — `autoPlay` was hardcoded on the
WaveformPlayer. A user batch-generating Korean clone segments asked to turn it
off so each finished clip doesn't start playing on its own.
Add a persisted `autoPlayPreview` pref (default ON — preserves current behavior)
with a Settings → Appearance toggle, and thread it into the two preview call
sites (VoicePreview.jsx, VoiceProfile.jsx) so `autoPlay={autoPlayPreview}`.
WaveformPlayer already gates playback on the prop, so off = no auto-play; the
manual Play button is unaffected. Cross-platform-parity safe: it's a pure UI
preference that behaves identically on macOS/Windows/Linux, default unchanged.
New strings go through i18n.
Test: AppearancePanel.test.jsx — the toggle defaults checked (ON) and flipping
it sets the store to false.
Closes#666
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A user typed free-form prose ("Speak with high energy … like a podcast host")
into the voice-design instruct field and got a **500** whose message read "TTS
engine stopped mid-generation. This usually means it ran out of memory. Try the
Flush button …" — with the real cause ("Unsupported instruct items found …")
buried as the underlying error. The user is told to Flush for an OOM that never
happened; the actual problem is a rejected instruct.
Root cause: `_resolve_instruct` raises on unknown/conflicting instruct items, but
by the time the error reaches `_oom_friendly_reraise` it's no longer a bare
`ValueError` (a lower layer wraps it), so the route's `except ValueError -> 400`
guard misses it and it falls through to the generic OOM `RuntimeError`. v0.3.7
has had that guard since v0.3.6 yet still produced the OOM message — proving the
error arrives wrapped, so type-based detection is insufficient.
Fix: in `_oom_friendly_reraise`, detect the instruct-validation **message
signature** ("unsupported instruct items" / "conflicting instruct items" / "in a
single instruct") regardless of exception type and re-raise a clean `ValueError`,
so the route returns a **400 with the instruct guidance** instead of a 500 OOM.
This is version-independent and complements the client-side guard (#658/#612):
it also covers API/MCP callers and stored profiles whose instruct slips through.
Test: two cases in test_generation_audio_guard.py — a bare instruct `ValueError`
and one wrapped in a `RuntimeError` both reclassify to a ValueError without the
"ran out of memory" text; the generic OOM path is unchanged.
Closes#664
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A persisted `dubJobId` outlives the backend's in-memory job store — after a
backend restart (or once a job is cleaned up), resuming/retrying a dub returns
404 "Job not found. It may have been cleaned up or was never created." The UI
surfaced this expected stale-session state as a hard error toast *with a "report
a bug" prompt* (toastErrorWithReport), so a user who just reopened the Dub tab
(#660: only action was view:dub) got a scary, un-actionable error for what is
really "your old session is gone — start a new one."
Fix the class: add a pure `isExpiredDubJobError(err)` predicate (matches the
dub_core preflight message, the dub_generate expired-session message, and a bare
404 "Job not found") and a `_resetStaleDubSession()` helper that clears the dead
job id/state, drops any pill, and shows a calm info toast inviting a fresh
upload. Wired into the two handlers that operate on a pre-existing job —
retry-transcribe (the #660 path) and SRT import. The fresh upload/ingest paths
are intentionally left reporting real errors: a just-created job going missing
*is* a bug worth reporting.
Test: dubExpiredJobError.test.js pins the predicate against both backend
messages + a bare 404, and asserts unrelated failures (stream dropped, CUDA OOM,
abort) stay reportable.
Closes#660
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two coupled Windows fixes for the preview/blob audio path (the "playBlobAudio
decode error: EncodingError: Unable to decode audio data" users see in
Logs → Frontend on Windows).
1. apiBase 127.0.0.1, not localhost (Tauri context). The backend binds IPv4
127.0.0.1 only; on Windows "localhost" often resolves to ::1 (IPv6) first, so
requests miss the backend. The main client (api/client.ts) already did this
since #174, but utils/apiBase.ts lagged on "localhost" — and its one consumer
is utils/media.js's preview upload, the #653 fallback. So #653's streamed
fallback fetched http://localhost:3900/preview/upload and FAILED on Windows,
leaving preview playback broken even after #653. Align the two resolvers.
2. Quieter, accurate logging in playBlobAudio. The Web Audio decodeAudioData
path is EXPECTED to fail for long-form / AAC renders on WebView2 and is
recovered by the streamed fallback — yet it logged at error level, so users
saw a red "decode error" even when playback succeeded. Downgrade that branch
to console.warn ("falling back to streamed playback"); reserve error level for
the real failure (both decode AND fallback failed). With fix#1 the fallback
now actually reaches the backend on Windows, so the recovery completes.
Tests: apiBase.test.ts asserts Tauri → http://127.0.0.1:3900; the existing
playBlobAudioFallback.test.js (#653) still passes (fetch hits /preview/upload,
plays the HTTP URL, never a blob:).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Vietnamese user typed a free-form Vietnamese description into the voice style
(instruct) field and got "400 Bad Request: Unsupported instruct items found in
quảng cáo, sôi nổi và thu hút". The instruct field is a fixed EN/ZH style-tag
vocabulary (the model's trained tokens: gender/age/pitch/accent/dialect/whisper);
the backend _resolve_instruct deliberately *raises* on unknown items.
The design path already guarded this: it runs the free-text through
buildDesignInstruct(), keeping valid tags, dropping the rest, and surfacing a
localized warning toast (#115/#114). But the *clone* path
(defineMethod === 'audio') appended the raw `instruct` string straight to the
request — so a clone + free-text style in any non-EN/ZH language round-tripped to
a 400 instead of being handled locally.
Fix (localized client-side guard, the chosen approach): route the clone path's
free-text through the same buildDesignInstruct({}, instruct) guard. Valid style
tags survive (a clone can still ask for "whisper"); unsupported items drop with
the existing localized `tts_errors.ignored_unsupported` toast; synthesis proceeds
in the user's language without style control instead of failing outright. No
backend/engine change — the model genuinely can't honor non-EN/ZH instructs, so
this makes the failure graceful and understandable rather than a raw 400.
Test: two cases in voiceInstruct.test.js pin the clone scenario — a fully
Vietnamese instruct yields "" + all items in the unsupported bucket, and a mixed
"whisper, sôi nổi" keeps "whisper" while flagging the prose.
Closes#612
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first-run Models & Engines page showed only "downloading…" (and, once totals
arrived, a bare percent + ETA). Users asked to see the actual download rate, the
size remaining, and a way to speed downloads up.
The backend already streams per-file byte counts and a windowed rate over SSE —
the UI just wasn't surfacing it. Changes (frontend-only):
- aggregate() now also returns live rate + bytes-remaining (was pct + ETA only),
and is exported so the speed/remaining math is unit-tested.
- The download line now reads e.g. "38% · 5.2 MB/s · 1.2 GB left · ~3m", each
part shown only once the stream has it (still degrades to "downloading…" early).
- New fmtBytes()/fmtRate() helpers (MB/GB, MB/s↔KB/s).
The Hugging Face token field already existed but was buried in an "advanced"
fold and framed only as "unlocks gated models" — so users hunting for a faster
download never found it. Reframed the title/hint to lead with what they want:
authenticated downloads are faster, have higher rate limits, and stall less
(and still unlock gated models like pyannote diarization). Token persistence and
the segmented/faster downloader (segmented_download.py) are unchanged — this just
makes the existing speed levers visible.
Test: frontend/src/test/wizardLibraryAggregate.test.js — aggregate sums bytes,
ignores completed-file rate, returns nulls before totals; fmtBytes/fmtRate
formatting + idle blanks.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Windows/CUDA user (Vietnam) hit "Can't reach the local backend" only when
dubbing/transcribing. Their log proves the backend started fine — model loaded,
preload complete, 25 models — and the log ends right after
`whisperx transcribing …tmp.wav`. The backend was alive; the *transcription*
stalled (large-v3 ASR contending with the resident TTS model for VRAM on an
8 GB-class GPU), which the UI surfaces as an unreachable backend.
Root cause (class, not instance): the chunked dub pipeline already bounds each
chunk (OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S), but the *whole-file* transcribe
paths ran unbounded:
- dub QC re-transcribe (dub_export)
- dictation (capture)
- OpenAI-compat /audio/transcriptions
A slow/stuck transcribe on any of these hung the request AND held a GPU-pool
worker — indistinguishable from a dead backend.
Fix: add run_transcribe_guarded() in services/asr_backend.py — a shared
asyncio.wait_for wrapper (ASRTimeoutError, a TimeoutError subclass) with a
generous env-tunable bound (OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S, default 300 s).
On timeout the request returns 504 with actionable guidance (backend is alive;
free VRAM / pick a smaller ASR model / use CPU; restart to clear the stuck
worker) instead of hanging forever. Wired into all three whole-file paths.
Docs: new troubleshooting §14 — "Can't reach the local backend during
transcription/dubbing" — explains it's ASR weight/VRAM pressure, not a network/
mirror problem, and corrects the misconception that a "Network → Restricted/Global
mirror" Settings toggle exists (the Network control is LAN sharing). Serves the
#602/#585/#567 "can't reach backend" cluster.
Test: backend/tests/test_asr_transcribe_timeout.py — slow fn raises ASRTimeoutError
with the actionable message, fast fn passes through, subclass-of-TimeoutError so
the openai_compat broad catch still maps to 504.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WhisperX (the default ASR) aborts transcription with zero segments on Windows
only, surfacing "Lazy import of LazyModule(...speechbrain.integrations.k2_fsa...)
failed" (#630) or its generic wrapper "Transcribe stream dropped..." (#611, #647).
Root cause is in speechbrain 1.x. It exposes optional integrations (k2_fsa,
numba losses, spacy/flair nlp) as LazyModule redirects in sys.modules. Stray
introspection during whisperx.load_model (pyannote -> speechbrain) — PyTorch's
op-registration machinery, pickling, a dir()/hasattr walk — touches one of these
redirects. speechbrain suppresses such inspect-triggered imports via a guard, but
the guard checks filename.endswith("/inspect.py") with a hardcoded POSIX
separator. On Windows the frame filename uses backslashes, the guard misses, the
redirect actually imports k2_fsa -> import k2 -> k2 not installed -> ImportError
that bubbles out and kills ASR. macOS/Linux use forward slashes, so the guard
fires and the feature works — a Windows-only break of a cross-platform default
(P0 parity).
Fix the whole class (every optional-integration redirect, not just k2) by
re-implementing LazyModule.ensure_module with a separator-agnostic basename check
(normalise both "\\" and "/"), applied right before whisperx loads. Idempotent;
a no-op on macOS/Linux and when speechbrain is absent; genuine missing-dep
accesses from real user code still raise ImportError — only inspect-triggered
spurious imports are suppressed, now on every platform.
Regression test fakes the importer frame with Windows- and POSIX-style inspect.py
paths plus a real-caller path, so it pins the behaviour on any CI host (fails
before the fix on the Windows-path case, passes after).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In-app preview of a finished audiobook/story did nothing on Windows. playBlobAudio
(Tauri path) decodes the whole render into one PCM AudioBuffer via Web Audio
decodeAudioData, which throws "EncodingError: Unable to decode audio data" on a
long-form .m4b/AAC under WebView2. The catch-block fallback used a blob: URL,
which the file's own fileToMediaUrl notes does NOT play in a Tauri <audio>
element — so it silently played nothing.
The fallback now uploads the blob to /preview/upload (ffmpeg-extracts a
streamable WAV server-side) and plays the returned HTTP URL via <audio> — the
exact pattern video previews already use. Streams instead of whole-file-decode,
so it also fixes hour-long renders regardless of platform. Short WAV TTS previews
keep the fast decodeAudioData path. Regression test pins that the fallback hits
/preview/upload and plays an HTTP URL (never blob:). No version bump.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`bun desktop-prod` (clean) wipes everything including the HF model cache, so
every fresh-install emulation re-downloads multi-GB weights — slow and bandwidth
-heavy, and the exact pain users on flaky networks hit. --keep-models wipes
app/backend data, logs, and webview state for an honest first-run, but KEEPS the
model cache so the weights aren't re-pulled. Ignored under --keep-data (which
keeps everything). Adds the `desktop-prod:keep-models` convenience script.
Scripts-only package.json change — no deps, bun.lock unaffected.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two first-run setup polish items:
1. Descriptions dimmed + tightened (opacity 0.72->0.55 / 0.68->0.5, smaller
line-height/reserve) and shortened (subtitle, compute, channel, mode copy).
2. "Models & engines" (WizardLibrary) now surfaces optional models tuned for the
detected platform — those whose catalog "platforms" tag matches the host
(MLX mac-ARM on Apple Silicon, CUDA variants on NVIDIA) — up-front with a
green "recommended" chip + their note, folding only the universal long tail.
Generic across platforms; graceful when none match. No backend change (the
/models API already ships "platforms" + the host "platform_tags").
isPlatformPick extracted as a pure exported helper; 6 vitest cases. en.json +
JSX fallbacks synced; orphan check clean; vite build green. No version bump.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Windows user's log showed 'Demo audio not found … demo_voice.wav — skipping
onboarding seed'. The local bundle DOES ship the clip (verified), so that user
just has a pre-#633 build — but the existing test only checks the file exists in
the repo. It misses the two ways the clip could silently drop from ALL builds
while still sitting in the repo: (1) the .gitignore un-ignore allowlist
(!backend/assets/samples/*.wav) being weakened — gitignore-aware build walkers
would then skip it; (2) backend/ being removed from tauri.conf.json bundle
resources. Pin both. test-only.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The locale orphan-key judge flagged 20 non-en locales carrying keys absent from
en. Two distinct causes:
1. bootstrap.lines (used at BootstrapSplash.jsx:467, t('bootstrap.lines',{count}))
existed in de/es/fr/ja but NOT en — so English (and 16 locales falling back to
it) rendered the literal key instead of '{{count}} lines'. Added to en.
2. gallery.cat_* (anime/books/celebs/disney/gaming/marvel/news/politicians) were
renamed to archetypes.use_* long ago (VoiceGallery.jsx:309) but left orphaned
in 20 locales — 160 dead keys. Removed.
Zero orphans remain. Flipped the probe test to assert the judge now PASSES
(regression guard). Locale files edited losslessly (json indent=2, ensure_ascii
=False, trailing-newline preserved). No version bump.
Co-authored-by: mergetest <test@local>
* fix(startup): timeout-bound MCP session-manager start to stop M1 hang (#632)
A reporter's faulthandler thread dump showed the asyncio loop alive but the
lifespan suspended at an await with an idle pool worker + a leaked semaphore —
the MCP Streamable-HTTP session manager hanging on its anyio task group during
startup (Apple-Silicon M1). Because `enter_async_context(_sm.run())` is awaited
before yield, the hang meant 'Application startup complete' never fired and the
backend was unreachable with no error — a P0 (default feature dead on a platform).
The MCP layer is explicitly best-effort, but the old guard only caught
exceptions, not hangs. Bound the start with asyncio.wait_for
(OMNIVOICE_MCP_START_TIMEOUT_S, default 30s): a hang → logged warning + backend
serves without MCP. Extracted _enter_mcp_session_manager + _mcp_start_timeout_s;
4 regression tests (hang→False fast, healthy→True, None→noop, env override). No
version bump.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(startup): run MCP in its own task (anyio task-affinity) — fix CI cancel-scope error
The first attempt wrapped enter_async_context in wait_for, which entered the MCP
anyio task group in a throwaway sub-task while the AsyncExitStack exited it on the
lifespan task → 'Attempted to exit cancel scope in a different task' (caught by
test_coverage_critic's real backend boot). Correct fix: _serve_mcp owns the full
enter→exit in ONE task; _start_mcp_session_manager only waits (with timeout) on a
ready Event. A hang still can't block startup, and enter/exit share a task.
Shutdown signals stop + bounded-awaits the task. Tests updated (5; incl broken-
manager case). test_coverage_critic now boots+shuts down clean.
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dubbing a URL could fail with 'Unable to download video: [Errno 22] Invalid
argument' on Windows: yt-dlp stamps the downloaded file's mtime with the video's
upload date, and an out-of-range/invalid timestamp makes os.utime raise
[Errno 22], aborting the ingest. We download to a throwaway original.* and never
use its mtime, so set updatetime=False (yt-dlp --no-mtime). Regression test
asserts the opt is set. No version bump.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 'stuck on the download page, model folder has only refs/ no weights' case
(a connection dropping/blocking mid-pull) is a recurring support report but
wasn't in the install troubleshooting guide. Add section 13 with the recovery
steps + antivirus/VPN/mirror escalation + a huggingface-cli manual fallback.
Docs-only; no version bump.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two independent fixes from issue triage; no version bump.
#625 — yt-dlp 403 on the media download (some videos serve formats
signature-protected to the default player client) is not transient, so the
existing broken-pipe retry (#579) kept 403ing. The URL download now escalates
the YouTube player client (tv → android → web_safari) on a 403 before giving up;
a 403 no longer counts against the transient-retry budget.
#629 — a numerical glitch in the model (seen on MPS) could leave NaN/inf samples
that write an unreadable WAV; a downstream decode then failed with an opaque
"ffmpeg returned error code: 183 / Invalid data", surfaced to the user as a
misleading "ran out of memory". Sanitize non-finite samples to silence in
_apply_effect_chain (single chokepoint, covers the raw path too) so the WAV is
always decodable, and classify a decode/ffmpeg failure as unreadable-audio
rather than OOM in _oom_friendly_reraise.
Tests: 403 escalation order + success-on-alternate-client; NaN/inf sanitize +
finite-passthrough + decode-error classification. Full suite 1851 passed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A silent hang during the FastAPI lifespan startup (reported as a Mac M1 hang
after 'Loading weights: 527/527') leaves the app unusable with no error: weights
load, then 'Application startup complete' never fires. Without a thread dump the
deadlock is invisible.
Arm faulthandler.dump_traceback_later at the top of the lifespan and cancel it
the instant startup completes (just before the yield). If startup stalls past
the window (default 300s, OMNIVOICE_STARTUP_WATCHDOG_S to tune, 0 to disable),
every thread's stack is dumped to stderr → backend_err.log, capturing the hang
point for #632 and any future startup deadlock. Best-effort + exit=False, so the
diagnostic can never itself break or kill startup; a normal (even slow-download)
boot disarms it first and never trips.
No version bump.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
backend/assets/samples/demo_voice.wav is a build artifact (generated by
scripts/build_demos.sh) that was never committed, so it shipped absent from
installs: onboarding logged 'Demo audio not found', seeded nothing, and the
Launchpad was empty on first run + the /demo_audio route was unavailable.
The file is already un-ignored in .gitignore and bundled via the Tauri
'backend' resource — it just needed to exist in git. Commit it (regenerated
via the script's say/Samantha path, 24kHz mono 16-bit, content matching
DEMO_REF_TEXT) so first-run works on every platform. Onboarding keeps its
graceful skip (now with a regenerate hint) for a partial checkout.
Regression test guards the asset is present + valid and that onboarding seeds
the demo profile from it (and is a no-op on a non-empty DB). No version bump.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Segmentation groups words into sentences BEFORE diarization, so a two-speaker
exchange can land in one segment; assign_speakers_* then only relabels it with
the majority speaker, losing the turn boundary (the second half of #486 — the
per-speaker voice auto-assign was fixed in #490).
Add a post-diarization pass that re-splits any segment whose words span >1
speaker at the word-level boundary, assigning each piece its speaker:
- backend/services/segmentation.py: resplit_segments_by_diarization /
resplit_segments_by_turns + a pure _resplit_core. Single-speaker segments are
returned BYTE-FOR-BYTE UNCHANGED (same dict/id/text/start/end) — the
no-single-speaker-regression guarantee. Pieces keep the segment's outer
start/end (preserving onset-snap) and use word times for interior splits, so
they exactly cover the original span. A lone mis-attributed word is smoothed,
not split (diarization noise).
- backend/api/routers/dub_core.py: accumulate global-timeline words alongside
segments; apply the re-split after both the pyannote and FunASR-turns assign.
Heuristic fallback (no word-speaker data) is untouched.
8 regression tests pin the invariant + the split/3-way/noise-smoothing/label
behaviour. Full suite: 1836 passed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A first-run user whose model download was interrupted after the config/
tokenizer files landed but before the weight shard got stranded on the
Models & Engines page: GET /models computed "installed" purely from cache
size on disk, so a size-positive-but-weight-less cache reported installed=true,
the wizard hid the re-download button, and the model manager (Settings → Models)
that could repair it was unreachable behind the wizard gate.
Make install-state weight-aware. The boolean weight-floor scan now lives in
models.py (the lowest module in the setup import graph) as snapshot_has_weights()
+ cache_is_complete(); list_models() and recommendations() downgrade a truncated
cache to installed=false (+ an explicit incomplete=true on /models), so the
existing "install" action re-appears and the user can re-download in-wizard.
Fixes the whole class, not just /models: download.py's install-time validator
now delegates to the same shared scan (one source of the floors, can't drift),
matching the load-time repair in model_manager.py (#581/#606).
config_only repos (pyannote/speaker-diarization-3.1 — a pipeline whose real
weights live in referenced sub-repos and whose own cache is legitimately tiny)
carry a new config_only:true hint in models.yaml and are exempt, so they're not
false-flagged as incomplete.
Tests: tests/test_mm2_lifecycle.py — snapshot_has_weights truncated-vs-complete,
cache_is_complete on a truncated weight repo + config-only exemption, and
list_models downgrading a size-positive truncated cache to installed=false /
incomplete=true. Full backend suite green (1832 passed).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Paste a `[Alice] … [Bob] …` podcast/audiobook script into Stories and Auto-cast
now builds the cast and assigns a voice per character — no manual setup. This
sits entirely on the existing Stories pipeline (autoCast → storyToSpans →
/longform/render); the only missing piece was recognizing the `[Name]` tag
format, which parseScript now auto-detects and routes through a new
parseTaggedScript (alongside `NAME:` screenplay + quoted prose).
- parseTaggedScript: `[Name] dialogue`, multi-line blocks join until the next
tag, prose before the first tag → Narrator. Inline synthesis markers
([pause], [pause 500ms], [voice:ID], [fast], [spell]) are NOT treated as
speakers (no colon + reserved-keyword guard), so they stay in the text.
- parseScript auto-routes tagged scripts so the existing Auto-cast button works
unchanged; single-line re-render is already covered by the content-addressed
chapter cache.
- autocastHint advertises all three formats. 16 parseScript tests pass.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: backend route-inventory snapshot + webUI feature-coverage guards
A reusable testing system that verifies every feature surface is present:
- tests/test_api_route_inventory.py: boots the app, diffs all 213 routes vs a
committed snapshot (tests/fixtures/api_routes.txt), guards a critical-endpoint
set, and floors the route count — any endpoint drift fails CI.
- scripts/dump_api_routes.py: regenerates the snapshot.
- frontend featureCoverage.test.js: every AppMode has a render branch, every
lazy-imported page file exists, every feature has an i18n namespace.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(changelog): note the feature-coverage test system
* test(api-inventory): isolate via subprocess + exclude env-dependent mounts
CI surfaced two flaws in the first cut:
- the in-process app import + sys.modules purge polluted later DB-touching
tests (a cascade of 404s in test_dub_subtitles_309 etc.);
- the snapshot included StaticFiles mounts (/demo_audio) and a conditional
GET / root that register based on filesystem state, so a macOS-generated
snapshot didn't match a fresh Linux CI runner.
Compute routes in an isolated subprocess (scripts/dump_api_routes.py --print)
and cover only the deterministic router surface (drop Mounts + root). 209
routes; inventory + previously-polluted tests now pass together.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(design): heal validator-rejecting instruct on design voices (#594/#571/#596)
A designed voice could persist an `instruct` the engine validator rejects —
either the literal "[object Object]" from a pre-fix build (#550) or freeform
prose typed into the style field — so every Generate/Dub that used the voice
failed with `Unsupported instruct items found in …` (400/500, and "Can't reach
the local backend" when it tore down mid-render). Migration 0006 only *blanked*
"[object Object]", which silently discarded the design — an Indonesian female
voice then rendered male (#594).
Fix the whole class by healing at every seam and rebuilding from the
authoritative source (the design's saved `vd_states` category picks):
- omnivoice/utils/voice_design.py: add sanitize_instruct / instruct_from_vd_states
/ heal_design_instruct — forgiving (never raise), drop poison/prose to valid
tags, and rebuild tags from vd_states when the stored value is unusable.
- profiles.py: sanitize + rebuild at save (POST) and sanitize at edit (PUT), so
no poisoned instruct can ever be persisted again.
- generation.py + dub_generate.py: heal whenever a profile drives synthesis, so
legacy poisoned rows resolve to valid tags instead of 400-ing.
- migration 0007: heal existing profiles in place (recovers gender/age/pitch
from vd_states), self-contained (frozen vocab snapshot) so it never drags
torch into startup; supersedes 0006's blanking. Backward-compatible.
Tests: unit coverage for the healer, a migration test driving 0006->0007 on the
real schema, a parity guard so the frozen snapshot can't drift, and two API
guards. Corrected one existing test that had encoded the #594 behaviour.
Resolves#571, #594, #596; removes a major driver of the "Can't reach backend"
reports.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(cjk): allowlist migration 0007's frozen dialect-tag snapshot (#564)
The 0007 instruct-heal migration carries a frozen copy of the design-tag
whitelist (incl. Chinese dialect tags) so it stays self-contained; add it to
the hardcoded-CJK allowlist like omnivoice/utils/voice_design.py.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When WhisperX (or any ASR backend) failed to load its model, the transcribe
SSE stream dead-ended on a generic "Transcribe stream dropped … Likely ASR
backend failed to load" message with no actionable cause.
Two root causes, both fixed:
1. WhisperX loads lazily inside transcribe(), so a load failure (faster-whisper
weights, CTranslate2/cuDNN mismatch, torch-2.6 weights-only VAD regression)
was buried in per-chunk errors and retried on every chunk. Added
ASRBackend.ensure_loaded() (no-op default; WhisperX triggers its lazy
loader) and call it in the transcribe pre-flight so the genuine cause
surfaces once, up front, as a structured error event.
2. The pre-flight and audio-load error paths closed the SSE stream with a bare
`error` and no terminal `done`, so the browser's native EventSource
connection-drop could race and win against the structured error — discarding
the real cause. Every terminal error now emits `done`, and the frontend
latches the structured cause so a connection drop can't overwrite it with
the generic message.
Adds a fail-before/pass-after regression test driving the stream's async
generator through the ASR-load-failure path; updates the existing #516 fake
backend to the new ensure_loaded() contract; CHANGELOG ### Fixed entry.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two frontend bugs reported on v0.3.7, both Windows/Chromium-flavoured:
1. The PLAY button on the dubbed-video preview did nothing. WaveSurfer
builds its AudioContext at mount (before any user gesture), so on
Windows WebView2 / Linux FF/Chrome it stays "suspended" and
playPause() resolves with no sound. This is the same autoplay-policy
trap #510 fixed for WaveformPlayer, but the dub timeline player was
missed. togglePlay and the per-segment playRange now await the shared
unlockAudio() on the click before starting playback, and swallowed
play() rejections are logged. A source-contract regression test pins
the invariant (fail-before/pass-after verified).
2. The designer Script text field couldn't be expanded. It was a
`flex: 1` item in a flex column, so flex-grow recomputed its height
each reflow and snapped the resize-drag back — `resize: vertical` is
ignored on a flex-grown item in Chromium/WebView2. The textarea now
owns its height (flex: 0 1 auto + a taller min-height) so the corner
grip grows it reliably on every platform.
Gates: `bun run build` and `bunx vitest run` (563 tests) both pass.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An interrupted first download leaves the HF cache with config/tokenizer
files but no weight shard. transformers then raises an OSError ("does not
appear to have a file named pytorch_model.bin or model.safetensors") on
load, which model_manager translated into a 500 with a manual "delete the
model and install it again" instruction — a dead-end for the user.
Make the load path self-repair: on the truncated-cache OSError, re-fetch
just the missing files via snapshot_download (already-present blobs are
skipped, so a near-complete cache repairs fast and a healthy cache never
reaches this branch), then retry the load once. HF offline mode is
respected, and the actionable delete-and-reinstall message is preserved
as the fallback when repair can't fix it.
Adds tests/test_model_cache_repair.py covering completeness detection,
the fast path (no repair on healthy cache), self-repair + retry, the
offline guard, and the repair-failure fallback.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pasting a video URL into the dubber could fail outright with
`download: Unable to download video: [Errno 32] Broken pipe`. A broken
pipe raised while the write side of a pipe closes mid-stream (a killed
ffmpeg merge child, a CDN reset during muxing) aborts the whole
`extract_info` call and is NOT covered by yt-dlp's own per-fragment
retries, so a single transient blip killed the entire ingest.
Root cause: no download-level retry around `yt_download_sync`'s
`extract_info`. The failure was already classified as
`VIDEO_DOWNLOAD_NETWORK` (#554/#536) and carried a "just retry" hint, but
nothing actually retried.
Fix: wrap the download in a bounded retry (1 + 2 attempts) that retries
only on transient/broken-pipe-class failures, reusing the single
`failure.classify() == VIDEO_DOWNLOAD_NETWORK` taxonomy (plus the
BrokenPipeError/ConnectionError classes) rather than a parallel keyword
list. Partial `original.*` files are wiped between attempts so a
half-written download can't poison the next try. Unsupported links still
fail fast with their own hint (no wasted retries); after retries are
exhausted the existing actionable network hint is surfaced.
Adds tests/test_dub_download_retry.py: retryability classification +
retry-then-recover, bounded give-up, and no-retry-on-unsupported-URL.
Fails before (no retry loop / helper), passes after.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GitHub Sponsors isn't available for this account, so route donations to Ko-fi /
PayPal instead, add a standalone Contact page, and trim the commercial-license
page to the essentials.
- Donate: drop GitHub Sponsors. Pick an amount ($10 / $20 / $50) then choose
Ko-fi or PayPal; PayPal.me carries the amount into checkout. Updated
.github/FUNDING.yml (ko_fi + custom PayPal) and the README badges to match.
- Contact page (new `mode: 'contact'`, ContactPage.jsx): Discord, email, GitHub
issues, and website (palash.dev) as clean one-tap rows; reachable from a new
footer button. Routed in App.jsx, sidebar hidden like the other full pages.
- Commercial License: cut the 6-tile benefit grid + 3-item FAQ down to the
three deciding factors (IP ownership, no per-minute cost, direct support) and
one clear "request a quote" email CTA.
- All new copy goes through i18n (en.json: donate.choose_method*,
enterprise.hero_simple/contact_lead, contact.*, logs.contact*).
Build (vite) + vitest (561 passed) green; en.json validated.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(bootstrap): gate venv on omnivoice import + source fallback (#564)
`No module named 'omnivoice'` is a venv that starts uvicorn but can't import the
project's OWN package: an interrupted/offline `uv sync` installed deps yet never
laid the editable record (`_editable_impl_omnivoice.pth`), or antivirus removed
it. The bootstrap health gate only checked `import uvicorn` + `import
pkg_resources`, so it handed back the broken venv and the app failed only at the
first model call (the dub/generate SSE error in #564). #573's source fallback in
main.py wasn't enough on its own because the editable record, not the source
tree, was the missing piece.
Fix the root cause at the gate and harden the runtime:
- bootstrap.rs: add an `omnivoice` import check beside the uvicorn/pkg_resources
gates, using `importlib.util.find_spec` (resolves without importing, so no
torch load). When it fails, fall through to the repair `uv sync`, which
re-lays the editable install. Mirrors the #248 pkg_resources pattern exactly.
- core/omnivoice_path.py (new): `ensure_omnivoice_importable()` — a tested
helper that no-ops when the install resolves and otherwise appends the sibling
source root to sys.path, with a precise diagnostic when neither is found.
- main.py: replace the inline #573 block with the helper.
- model_manager._lazy_omnivoice: self-heal on ModuleNotFoundError at the actual
import site so the model-load path recovers and logs the searched roots.
Regression tests cover the path-resolution logic (env override, append-not-
insert precedence, no-source-found). cargo check passes for the Rust change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(omnivoice-path): patch via live module object to survive core reloads (#564)
The #603 CI flake: other suites importlib.reload(core.*), leaving the
top-level-imported ensure_omnivoice_importable closed over a stale module whose
_already_importable a string-form monkeypatch didn't touch, so it returned None.
Resolve the function + the patch target from sys.modules together.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`_reset_gpu_pool()` fires on a model-load timeout to recover a wedged worker —
it shut the ThreadPoolExecutor down and rebuilt a fresh one on next access. But
several request handlers (generation, dub_generate, dub_core, dub_translate,
openai_compat) did a *module-level* `from services.model_manager import
_gpu_pool`, capturing the executor object at import time. After a reset those
references pointed at the dead pool, so the next generate/dub/transcribe/
translate raised `RuntimeError: cannot schedule new futures after shutdown` —
surfacing as a 500 or "Can't reach the local backend" (#589#599).
Make `_gpu_pool` a single long-lived `_ResilientGpuPool` wrapper (a
concurrent.futures.Executor) whose *inner* ThreadPoolExecutor is swapped:
- every submit() resolves the live pool, and a submit that races a shutdown
rebuilds once and retries, so a stale captured reference self-heals;
- `_reset_gpu_pool()` now drops only the inner pool (fresh worker on retry)
while preserving the wrapper identity every importer holds;
- pool sizing stays lazy, so we still probe the device after torch's lazy
import (the reason for the original __getattr__ indirection).
Fixes the whole class — all importers share one wrapper, module-level or
function-level. Regression tests cover stale-ref-survives-reset, identity
stability, submit-after-inner-shutdown self-heal, and asyncio.run_in_executor
compatibility; updated the load-timeout test to the new reset semantics.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two defensive fixes for non-Latin / long-form synthesis quality:
#502 (Vietnamese clone distorted/unintelligible): the /generate text path never
NFC-normalized its input, so pasted decomposed (NFD) Vietnamese — base letter +
combining diacritic instead of the single composed codepoint — reached the
tokenizer/model as two characters and rendered as garbled speech. Normalize the
input text to NFC at the endpoint (no-op for already-composed text), mirroring
what the duration estimator already does so the estimate and synthesis agree.
#505 (long-form 5+ min degrades — repeated/skipped/mispronounced): the chunker
split purely by character count (800), but CJK/kana/Hangul pack ~1 char =
1 syllable, so an 800-char chunk is ~4-5 minutes of audio in a single shot —
past the model's reliable range, where it starts repeating/skipping. When a
chunk is predominantly dense-script, cap it to max_chars/2.5 so each chunk's
spoken length stays bounded; Latin/spaced text is unchanged. Dense-script
detection is by code point (no literal CJK in source — no-literal-CJK gate stays
clean).
Tests: _dense_char_count, _effective_max_chars (shrink-when-dense, unchanged-for-
Latin, disabled-passthrough, floor), and that a 400-CJK-char string now splits
(was one chunk) while a Latin paragraph still doesn't.
Note: #502's exact distortion still wants a user sample to fully confirm; this is
the defensive NFC fix that's correct regardless.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The wave of "Can't reach the local backend" reports — all on ~8 GB NVIDIA cards,
all during generate bursts — is the backend *process* dying, not a transport
blip. Root cause: the GPU pool was sized at 2.5 GB/job, so an 8 GB card (~7 GB
free) got 2 workers. The interactive clone path co-loads WhisperX large-v3 ASR
(~3 GB) alongside TTS (~1.6 GB), so two concurrent clone jobs is ~10 GB on an
8 GB card → a sticky CUDA "illegal memory access" that aborts the whole
interpreter (uncatchable by the per-request OOM guard, which only re-raises a
clean torch.cuda.OutOfMemoryError as HTTP 500).
Budget 5 GB/job (the real TTS+ASR concurrent footprint) instead of 2.5 GB:
≤10 GB cards now serialize to a single GPU worker — no concurrent-kernel
contention, so the crash can't happen — while 16/24 GB cards still parallelize.
Overridable via OMNIVOICE_GPU_WORKERS. This *prevents* the crash; the
auto-restart supervisor (#572) *recovers* from any other cause — defense in
depth.
Extracted `_workers_for_free_vram()` (pure) with tests pinning 8 GB → 1 worker,
the larger-card ladder, the floor/cap, and a guard on the budget constant so a
regression toward 2.5 GB can't silently re-enable the crash.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Voice design rolled a brand-new random seed on every synth, so tweaking an
attribute also re-rolled the whole base timbre — you could never iterate on the
"same voice, slightly different". #526 asks for the seed to be shown with a
"keep this seed" control.
- Backend: `/generate` already accepted `seed` and echoed `X-Seed`, but left
`used_seed=None` when nothing supplied one (non-deterministic, unreproducible,
empty X-Seed). Now it materializes a concrete random seed when none resolves,
so every take is reproducible and the real seed is always returned and stored
— this also helps the clone/profile paths, not just design.
- Frontend: new store slice (`designSeed`, `keepSeed`); the design synth reuses
the pinned seed when "keep this seed" is on (via `pickDesignSeed`) and reads
the authoritative seed back from `X-Seed`. Design tab gains a Seed field +
"keep this seed" checkbox + "New seed" (re-roll) button.
Test: `pickDesignSeed` (pin when kept+valid, re-roll otherwise, range guard).
i18n keys added to en.json (other locales fall back; parity probe is advisory).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Multi-speaker dubbing diarizes the speakers and clones each one from the video
(the Voice dropdown shows "From Video → Speaker 1 / Speaker 2"), but every
segment was left on "Default" — the user had to set the voice on each row by
hand. The clone→segment binding simply never happened: the transcribe `final`
handler stored `speaker_clones` but set the segments without filling their
`profile_id`.
Bind them up front: new `applySpeakerCloneDefaults(segments, speakerClones)`
sets each segment's `profile_id` to its speaker's `auto:<safe>` clone id when a
clone exists and the user hasn't already chosen a voice. The id is computed by
`autoProfileId()`, which mirrors the backend clone-resolution key
(`speaker_id.lower().replace(" ","_")`) and the DubTab dropdown option value, so
all three agree. Only an *empty* profile_id is filled — an explicit per-speaker
or per-segment choice is never clobbered.
Pure helper + unit test (assign-when-cloned, never-clobber, no-clone-stays-
Default, no-op-without-clones).
Note: the issue's second symptom — different speakers' turns merged onto one
line — is a separate diarization/segment-grouping concern (speaker-turn
re-split) tracked as a follow-up; this fixes the per-speaker voice assignment.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#569: on a restricted network the first-run install fails downloading the
~2.5 GB cu128 PyTorch wheel from download.pytorch.org, and the app won't launch.
Two problems: the error told users to "set UV_DEFAULT_INDEX to a mirror" — which
CANNOT redirect torch, because it comes from a *named, explicit* uv index
(uv 0.11 rejects index-name override values and `--frozen` pins the exact wheel
URLs); and there was no way to supply a manually-downloaded wheel.
- Detect a torch/pytorch-host `uv sync` failure and emit torch-specific guidance
(Clean & Retry → VPN → drop the wheel locally) instead of the wrong mirror
advice.
- Add a local wheel-drop dir `<env_root>/wheels` (survives Clean & Retry) wired
via `UV_FIND_LINKS`. On a frozen-sync torch-download failure WITH wheels
present, retry NON-frozen with find-links so uv re-resolves from the local
wheels. Verified empirically: a non-frozen find-links sync installs from a
local wheel fully offline, while a `--frozen` sync ignores find-links — so the
retry is the only mechanism that can consume a dropped wheel. Best-effort: if
it can't satisfy, it fails identically to before and the actionable error
still fires.
- docs/install/troubleshooting.md: new "#12 CUDA PyTorch wheel download fails"
entry (docs-sync) — the offline wheel-drop path + why a PyPI mirror can't fix
this index.
Note: an automatic mirror redirect for the cu128 index is intentionally NOT
shipped — uv provides no working override for a named explicit index, so it
couldn't be verified; the offline wheel path is the reliable escape hatch.
Test: sync_failure_is_torch_download host/keyword detection + negative guard.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#564 ("No module named 'omnivoice'") is the backend failing to import its OWN
package at the first model call (the dub SSE error on dub:upload). `omnivoice`
is an editable install, so an interrupted/offline `uv sync` that installed deps
but never laid the editable record, an antivirus-quarantined
`_editable_impl_omnivoice.pth`, or an upgrade where only the lock-gated drift
sync ran leaves the venv able to start uvicorn yet unable to import omnivoice —
it boots fine and only fails at runtime, so the bootstrap health gate and the
exit-based broken-venv self-heal (which only see a process that won't start)
never catch it.
Fix the whole class at the import layer: main.py now also appends the project
root (the parent of backend/, where the desktop layout always copies
omnivoice/) to sys.path, guarded on omnivoice/__init__.py existing. The backend
then resolves omnivoice from source regardless of the editable-install state —
covering every variant above. Appended (not inserted) so a real
site-packages/editable install keeps precedence and it can't shadow a different
omnivoice; a no-op in Docker (no sibling omnivoice/) and a harmless duplicate
in a dev checkout.
Also routes "No module named 'omnivoice'" through failure.classify() →
BROKEN_VENV so, if it ever still surfaces, the toast points at Clean & Retry
instead of a bare import error. Regression test covers the classify mapping and
its negative guard (a legitimately-named omnivoice_* helper must not match).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Can't reach the local OmniVoice backend" cluster was a long-standing
supervision gap (dates to v0.3.0/#38), not a v0.3.7 regression: the backend
was spawned once and never watched again — `spawn_backend_and_wait` returned
the instant it was healthy. When the uvicorn process then died mid-session (a
CUDA OOM/context fault under a burst of generations — #571's log shows the
startup banner replaying 6× during a 20-generate burst — an antivirus kill, any
crash), nothing restarted it, so every later request threw connection-refused
and the user was stuck on the toast until a full app restart.
Two layers, both default-mode and platform-neutral:
1. Backend auto-restart supervisor (bootstrap.rs). After Ready, the bootstrap
thread (which used to just return) keeps watching the child and respawns it
on a *confirmed process exit* (try_wait — never a slow health probe, so a
busy-but-alive backend is never killed). Bounded to 5 restarts/60s (then
Failed) so a deterministic startup crash can't fork-bomb; the #314
broken-venv self-heal stays the venv-failure path. Strictly gated on
AppFlags.quitting so it never resurrects the backend during shutdown. A
single-supervisor guard (compare_exchange) prevents duplicate loops when
Retry re-enters concurrently. Emits backend-restarting/backend-restored
events (the splash poll stops post-Ready, so the stage alone can't show it).
2. Client transport-retry (client.ts). A *thrown* fetch (the backend briefly
down while it respawns) is retried a bounded few times with backoff
(~2.9s total) before surfacing the actionable ApiError, making the restart
window invisible. HTTP errors and deliberate aborts are never retried.
Resolves the whole cluster regardless of the crash trigger. Tests: Rust
backoff-policy unit test (cap + window-pruning); 4 client-retry vitest cases
(retry-then-succeed, no-retry-on-HTTP-error, no-retry-on-abort, bounded
give-up). Also corrects a stale Cargo.lock omnivoice-studio version (0.3.6→0.3.8).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The v0.3.7 notes were missing several user-facing changes that shipped between
v0.3.6 and the tag: Stories global reading-speed (#508), the Settings sparse-tab
fill + Appearance i18n (#507), the donate progress correction (#513), and a
### Changed (version single-source #503, preview-nightly #500) + ### Internal
(frozen-backend version #501) section. Restructured to the 0.3.6 house style.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Non-English voices drifted to English/wrong-language because the request's or
profile's language wasn't reaching the model:
- #533: generate_speech() read instruct/ref_text/seed from a resolved profile row
but never row['language'] (and collapsed Auto→None), so a German archetype
previewed in German yet generated in English on the user's own call (and via
Docker/API). Fall back to the profile's stored language when the request didn't
pin one; an explicit non-Auto request language still wins. (Frontend already
sets the dropdown on profile-select; this is the authoritative backend fix.)
- #505 (B2): the audiobook/longform synth hardcoded language=None, so the engine
re-autodetected per chunk and a non-English clone flipped language mid-render.
Add _resolve_default_language (request → profile → autodetect) and thread the
resolved language through _build_synth/_prepare_synth/_render_longform_sse, the
three longform request models, the preview path, and the resume manifest.
Genuine Auto/unset behavior is unchanged.
- #502 (partial): the duration estimator weights combining marks (U+0300–036F)
at 0.0, so NFD/decomposed text under-allocated frames → rushed audio. NFC-
normalize text at the estimator entry — fixes the whole diacritic-script class
(no-op for precomposed text). (The residual "distorted" core still needs the
reporter's sample; tracked separately.)
Tests (fail-before/pass-after): profile language reaches the engine (German→de;
explicit/Auto override semantics); longform synth gets the resolved language
(→ja), not None; NFD vs NFC duration parity (Korean Hangul diverges ~3x pre-fix).
Full suite: tests/ 1740 passed, backend/tests/ 114 passed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The stabilization release: tags the startup-crash fixes already on main + the
wave of 0.3.6-line fixes (voice design [object Object], consent_audio_path schema
self-heal, Linux WebKitGTK buttons, pip install, ASR float16 fallback, audiobook
import, Windows auto-play, download errors, relocated-venv self-heal, About
version), and folds in the MOSS-TTS/dots.tts engines (#498/#531) + the
Linux/Android audio-playback fix (#510) that landed on main.
release.yml extracts this section verbatim as the v0.3.7 GitHub release body.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two robustness fixes that share the failure taxonomy:
- Video downloads (#554 douyin "Unsupported URL", #536 "Broken pipe"): yt-dlp's
raw error surfaced with no next step. classify() now names UNSUPPORTED_VIDEO_URL
(non-downloadable link shape — paste a direct video page or drop a file) and
VIDEO_DOWNLOAD_NETWORK (transient CDN/network drop — just retry; the partial
download is already cleaned up), each with an actionable hint. yt-dlp is on a
current pin, so this is graceful classification, not a dependency bump.
- "No module named 'encodings'" (relocated/copied/restored venv whose interpreter
can't bootstrap its stdlib — exit 1, not 106): slipped BOTH #314 self-heal
matchers, so the user saw the error forever. Widen
backend_exit_indicates_broken_venv to also match the full quoted phrase, routing
it into the existing rebuild-once self-heal. Kept narrow so an app-level import
of an 'encodings'-prefixed package can't trigger a rebuild. Plus a BROKEN_VENV
hint for the case where the rebuild itself can't run.
Tests: classify() maps the 3 new classes with hints (a generic reason still ""),
and the 'encodings'-prefixed-package negative guard holds; the Rust matcher test
gains the encodings positive + negative cases. 5 pytest passed; the matcher is
compiled by CI's Tauri shell check.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#551: both CTranslate2 ASR backends request compute_type="float16" on CUDA with
NO fallback. On GPUs without efficient fp16 (older Maxwell/Pascal, GTX 16xx) or a
CTranslate2/cuDNN binary mismatch, WhisperModel/whisperx.load_model raise a
ValueError at construction — which escaped the existing OOM-only `except
RuntimeError`, so every chunk failed and the user got "Transcription produced no
segments". Add a per-device compute_type fallback chain (cuda: float16 →
int8_float16 → int8; cpu: int8 → float32) to both backends + the ASR sidecar,
alongside (not replacing) the existing OOM→CPU path, with an ASR_COMPUTE_TYPE
override for exotic hardware (documented in README).
Also in the same ASR-robustness pass:
- #549: PyTorchWhisperBackend._ensure_pipe wraps the transformers pipeline load
and re-raises an actionable error (reinstall transformers / use faster-whisper)
instead of a bare "Could not import module 'AutoFeatureExtractor'".
- #516: the /dub/transcribe SSE generator is wrapped so it can NEVER close
without a terminal event — any unanticipated exception now yields a structured
`error` (with build_failure's hint) + `done`, turning "stream dropped, likely
ASR failed" into the real cause + Retry.
- failure.py: COMPUTE_TYPE_UNSUPPORTED + TRANSFORMERS_IMPORT classes so the
no-segments toast is actionable.
Tests (fail-before/pass-after): float16-unsupported → int8 for both WhisperX +
FasterWhisper; a generic non-OOM RuntimeError still raises; classify() maps the
two new classes; the SSE stream always terminates with error→done. 7 + 1 passed,
17 in the failure suite (no regression).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The audiobook/story library card called window.open(audioUrl, '_blank').
Under Tauri's WebView2 on Windows that handed the file to a new webview/OS
media surface, spawning a separate black playback window with centered
controls that couldn't be closed without force-quitting the whole app.
Route the render through the shared single-playback manager (playBlobAudio)
so it previews inside the app — identical behavior on macOS/Windows/Linux,
and starting another preview stops this one. This is the only raw
window.open on a media URL in the frontend, so it fixes the whole class.
Adds a regression test: the card plays in-app and never calls window.open.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
POST /audiobook/import ended with `plan.chapter_count`, but AudiobookPlan
only exposed `char_count` (a property) and emitted `chapter_count` from
`to_dict()` — so the attribute access raised AttributeError, surfacing as
"500 Internal Server Error: 'AudiobookPlan' object has no attribute
'chapter_count'". The parse itself succeeded, so this hit every import
format (.txt/.md/.epub/.pdf), not just PDF.
Add a `chapter_count` property mirroring `char_count`, and have `to_dict()`
derive its key from it so the attribute and serialized key can't drift.
No API/schema/data change.
Tests: unit property test + a direct-handler /audiobook/import regression
(pdf/md/txt) that fails-before with the AttributeError.
Fixes#543
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
In the web/Pinokio (non-Tauri) build with the backend idle, Settings → About
rendered an EMPTY Version cell. Both sources were unavailable: appVersion is set
only inside the isTauri()-gated effect, and info.app_version comes from
/system/info which never resolves while the backend is IDLE (retry: Infinity).
So `appVersion || info?.app_version || '—'` produced nothing.
Add resolveAboutVersion() in a small util that falls back to the build-time
__APP_VERSION__ (Vite-injected from package.json — always present regardless of
Tauri/backend), and use it at BOTH the About → Version row AND the diagnostics-
copy block (which had no fallback at all — the whole-class fix). Tauri/live-
backend sources still take precedence, so the packaged build is unchanged.
Test: resolveAboutVersion prefers Tauri → backend → build constant, and is never
blank/dash. vitest 2 passed; typecheck clean.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The engine Install chip (deep_translator / openai / argostranslate) 500'd with
"No virtual environment found". run_pip shells out to bare `uv pip install`,
which discovers its target venv from VIRTUAL_ENV / a .venv in CWD — NOT from the
running interpreter. The desktop spawns `<venv>/bin/python -m uvicorn` without
exporting VIRTUAL_ENV and CWDs outside the venv, so uv finds nothing. The
existing `--system` fallback never fires because it's gated on _in_virtualenv()
being False, but the running interpreter genuinely IS in a venv (it just can't be
auto-discovered) — the heuristic answers the wrong question.
Pass `--python sys.executable` for uv install/uninstall: targets the same
interpreter _probe()/is_installed() import from, fixing the whole class
(spawned-venv, system, conda). It takes precedence when both flags are present,
so the Docker `--system` path is untouched. (#527's openai is already bundled by
#484; this hardens the chip for the remaining runtime-installed engines.)
Test: run_pip's spawned argv contains `--python <sys.executable>` after
install/uninstall. 2 passed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#523 "voice synthesis not pressable" / #524 "Settings button not visible" — and
the Discord "where is the Generate button / the clone button disappeared" reports
on 0.3.6/0.3.7 with the backend UP. The #504 zoom fix only half-worked: it sizes
.app-container as calc(100vw/scale) magnified back by `zoom`, which round-trips to
the viewport on Chromium — but older WebKitGTK (Linux AppImage/deb) treats `zoom`
as a LAYOUT NO-OP, so the box stays shrunk to 77vw, leaving a ~23% black band and
pushing the bottom-pinned Synthesize/Generate CTA + the NavRail Settings footer
off-screen. The old comment's "FILLS the window (no black bands)" claim was false.
No single static rule satisfies both engines (they disagree on whether `zoom`
lays out), so detect it at runtime:
- App.jsx: a one-shot probe measures a real `zoom:2` element; if its rect isn't
magnified, the engine treats zoom as a no-op → set html[data-zoom-layout=off].
Robust where @supports(zoom)/UA-sniffing aren't (both lie on WebKitGTK), and
future-proof (flips back to the zoom path on engines that start honoring it).
- index.css: html[data-zoom-layout=off] .app-container renders at 1.0 filling
100vw/100vh — no band, no clipped CTAs. Chromium keeps the scaled zoom path.
- appShellScale.test.js: guard BOTH branches (the existing calc+zoom path AND
the 100vw/100vh fallback) so a future "simplification" can't re-break one engine.
vitest 4 passed; typecheck clean. NOTE: CI e2e is chromium-only, so the WebKitGTK
fallback path must be eyeballed on a real Linux build before the v0.3.7 tag.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A profile/persona/consent endpoint 500'd with "no such column:
consent_audio_path" (#552/#547) — and the same class for kind/vd_states/is_demo.
The 0003/0005 migrations exist and are wired, but init_db's
CREATE TABLE IF NOT EXISTS never adds columns to a pre-existing table, the legacy
_migrate only knows pre-0.3 columns, and _run_alembic_upgrade swallows every
failure. So on a DB whose alembic_version is stamped at a revision no longer in
versions/ (common after running a preview build) or where alembic isn't
importable, the alembic-era columns silently never land.
Fix the whole class: add _reconcile_additive_columns(conn), which builds the
canonical schema from _BASE_SCHEMA in-memory and ALTER TABLE ADD COLUMN any
column an existing table is missing (additive only — never drops/retypes;
names/types from _BASE_SCHEMA so injection-safe). Call it in init_db (so the
schema converges regardless of alembic) and again in the alembic-failure branch.
Also correct the false "_BASE_SCHEMA guarantees the schema regardless" comment.
Test (fail-before/pass-after): init_db on a legacy voice_profiles whose
alembic_version is a removed revision now lands consent_audio_path/kind/etc.
without raising; reconcile converges to the canonical column set; idempotent +
additive-only. 21 passed (incl. existing 0003/0005 migration tests).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Voice Studio "Save design as profile" passed buildDesignInstruct()'s RETURN
OBJECT ({instruct, unsupported, duplicates}) straight to FormData.append, which
string-coerced it to the literal "[object Object]". That got persisted into
voice_profiles.instruct and 400'd on first preview/use with "Unsupported instruct
items found in [object Object]" (#550#545#542#537#530#525).
Fix the whole class + heal already-poisoned data (backward-compatible-data rule):
- CloneDesignTab.jsx:609 — pass `.instruct` (the string), not the builder object.
- useProfiles.js — append via new instructToFormValue() helper, which extracts
`.instruct` if an object ever slips through again (defense-in-depth).
- omnivoice/_resolve_instruct — drop the "[object Object]" sentinel instead of
raising, so any value that slips through (e.g. generation_history) degrades to
neutral conditioning rather than a hard 400. A genuine unsupported token still
raises (keeps the #114/#115 user feedback).
- migration 0006 — UPDATE voice_profiles SET instruct='' WHERE it's the sentinel,
idempotent + table-guarded, to heal profiles saved on the buggy build.
Tests (fail-before/pass-after): frontend voiceInstruct (instructToFormValue never
yields "[object Object]"); backend _resolve_instruct tolerates the sentinel but
still rejects a real bad token; alembic 0006 heals a poisoned row, leaves a
healthy one untouched. Frontend 9 passed + typecheck clean; backend 5 passed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds two zero-shot voice-cloning TTS engines requested in #498, both
opt-in and subprocess-isolated with their own dedicated venv — the same
pattern as IndexTTS-2. The dedicated venv is forced, not just chosen:
each upstream pins a transformers version that conflicts with the
parent's >=5.3 (MOSS-TTS-v1.5 ==5.0.0, dots.tts ==4.57.0), so they cannot
share the parent interpreter.
Because they use the clone+venv bootstrap (env var -> clone -> uv venv),
this touches no pyproject.toml / uv.lock / bun.lock — `uv sync
--all-extras` and Docker's `bun install --frozen-lockfile` are unchanged,
so main's CI/Docker matrix stays green.
Engines:
- moss-tts-v15: 8B, 31 langs, ~16 GB weights, 24 kHz. AutoModel/
AutoProcessor via trust_remote_code. gpu_compat=(cuda,cpu) — MPS is
undocumented/untested upstream so it is never claimed; on a Mac it runs
on CPU. Apache-2.0, no license gate.
- dots-tts: 2B, 24 langs, ~9 GB weights, 48 kHz. DotsTtsRuntime;
continuation cloning (prompt_audio_path+prompt_text). Upstream is
Linux/macOS-only, so is_available() gates it off cleanly on Windows
(cross-platform parity rule — it is opt-in, never a broken default).
Wiring: registered in _LAZY_REGISTRY + _INSTALL_HINTS. list_backends()
surfaces both as subprocess/[cuda,cpu]/available-until-installed; the
data-driven Settings engine picker needs no frontend change.
Tests (19, fail-before/pass-after): registry resolution, subprocess
marker, no-MPS gpu_compat, the Windows gate, not-installed honesty, and
the parent-side generate() kwarg arbitration. Existing engine suite still
55 passed / 5 skipped. Sidecar inference follows the upstream-documented
APIs but, like IndexTTS/Supertonic, can't be executed in CI without the
multi-GB model clones.
Docs (same-PR per docs-sync rule): README + README_CN engine tables, new
docs/engines/moss-tts-v15.md + dots-tts.md, disk-usage.md (torch-dedup
note), CHANGELOG.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CSS `zoom` magnifies visually but does NOT enlarge the layout box. At the
default `uiScale=1.3` the shell was drawn at 130vw x 130vh while the window
stayed 100vw x 100vh, so the bottom/right edges were clipped by `overflow:
hidden` on `#root` and `.app-container`. This hid Settings save, Dub transcribe,
and Clone buttons (only reachable via Tab).
Fix: shrink the layout box with `calc(100vw / var(--ui-scale))` /
`calc(100vh / var(--ui-scale))`, then let `zoom` magnify it back to exactly the
viewport on Chromium. On WebKitGTK `zoom` is a no-op, so the UI renders smaller
but still fills the window - no black-band regression.
Updates the appShellScale regression test to match the new intentional
`calc(... / --ui-scale)` rule and keeps the `transform: scale()` blacklist.
- Nav links (Quickstart, Features, Why OVS, etc.) now sit above the
badge row in the header for better scannability
- Download badges (macOS/Windows/Linux/Debian) moved from the header
to the Quickstart section where the user actually installs
The in-app goal bar and its bundled snapshot showed $137.50 / $200 (23
sponsors) — fabricated numbers. Updated both the runtime JSON and the
TypeScript fallback to reflect the real amount: $10 / $200, 1 sponsor.
Also updates the README badge color from red to yellow (in-progress).
Locks down the resume path from the parent commit:
- Every AudioContext is tracked at construction (the wrap is active)
- unlockAudio() resumes all suspended tracked contexts in parallel
- Idempotent: repeated calls do not re-resume
- Contexts created after unlock are not re-resumed by a second call
- resume() rejections are swallowed — one bad context doesn't block others
- installAudioUnlock() is idempotent (the _installed gate works)
The unlock path was the fix for the "click does nothing" bug on Linux
Firefox/Chrome and Android Chrome, where AudioContexts created before a
user gesture stay suspended — decodeAudioData hangs → WaveSurfer's ready
never fires → the play button never enables. Without this test, breaking
the gesture wiring silently regresses every non-macOS browser.
Adds the __resetForTesting() export so the unlock can be exercised
repeatedly against the same module instance (the unlock is meant to be
a one-shot per page load).
Browser autoplay policy (Linux Firefox/Chrome, Android Chrome, mobile
Safari): AudioContexts created before a user gesture start in "suspended"
state — decodeAudioData hangs and WaveSurfer's `ready` event never fires.
The play button gated on `ready` stays disabled forever, so the click
silently does nothing and no /audio/ request ever fires.
macOS Safari/Chrome are more lenient (typically auto-resume on first
interaction) which masked the bug cross-platform.
Fix has three parts:
1. `frontend/src/utils/audioUnlock.js` (new) — monkey-patches
`window.AudioContext` (and `webkitAudioContext`) to track every
instance ever created. Exports `installAudioUnlock()` which wires a
one-time pointerdown/keydown/touchstart listener that resumes all
suspended contexts on the first user gesture. The patch MUST install
before any module constructs an AudioContext, so this file is imported
first in main.jsx before the dynamic import of main-app.jsx.
2. `frontend/src/main.jsx` — imports and installs the unlock before any
other module loads.
3. `frontend/src/components/WaveformPlayer.jsx` — three changes:
- Remove the `Loader` spinner that gated on `ready`. The spinner
itself was a visual signal that the user was waiting on a state
the browser refuses to produce without user interaction.
- Button is now `disabled={!resolvedUrl}` — clickable as soon as the
audio URL exists, so the user's click IS the gesture that unlocks
the AudioContext.
- `togglePlay()` explicitly awaits `unlockAudio()` before calling
`playPause()` to close any race with the global gesture listener.
The console warning "An AudioContext was prevented from starting
automatically" may still appear once on page load — that's the
informational signal that the pre-gesture context was created suspended;
it's harmless because we explicitly resume on first interaction.
Tested: Linux Firefox 151.0.3 — before fix, clicking play did nothing
(no /audio/ request fired, button never enabled). After fix, single
click on play resumes AudioContext + starts playback, waveform animates.
Pairs with the audio/wav MIME fix in the same PR — both bugs had the
same user-visible symptom (silent play button on Linux) but different
root causes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The #415 global speed only flowed through the full longform export
(storyToSpans). Per-segment preview and stem export resolved speed with a
hardcoded `track.speed || 1.0`, silently dropping the global → generated
audio played at 1.0x even with the global set to e.g. 0.70x.
Add a pure `effectiveSpeed(track, globalSpeed)` helper (mirrors
effectiveProfile / storyToSpans precedence: per-line override → global →
engine default) and use it at both call sites so all three generation
paths agree. Regression-tested in storyCast.test.js.
Fixes#508
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(settings): fill the panel on short tabs instead of a stunted box in a void
Settings tabs with little content (Appearance — UI scale/theme/font; About;
Privacy) rendered the accent-bordered .settings-content as a short box with the
rest of the page as empty black void below it (reported on Appearance).
Make .settings-page a flex column with min-height:100% (a FLOOR — tall tabs grow
past it and scroll exactly as before) and let .settings-content flex:1 1 auto
grow to fill. Safe by design: it only adds space when there's slack, and if the
parent height is ever indeterminate the rule no-ops rather than constraining
content.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* i18n(settings): route AppearancePanel strings through i18n
The Appearance panel hardcoded English ("UI scale", "Color theme", "Font", and
the help paragraph) — against the localization hard rule. Wire them through
t('settings.*', { defaultValue }) matching the ApiKeysPanel pattern; reuse the
existing settings.appearance/ui_scale/theme keys and add color_theme/font/
appearance_help to en.json (the reference superset — other locales fall back to
English and backfill via the translation pipeline; the i18n parity gate only
requires valid JSON). Also renamed the THEMES.map(t =>) variable to `th` so it
no longer shadows the translation `t`.
typecheck + Appearance vitest (3) + i18n parity probe (4) all pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#503 made tauri.conf.json derive its version from package.json
("version": "../package.json"), but the L3 desktop probe spec
(desktop_smoke.probe.yaml) asserts config.version == pyproject_version and was
reading the literal path string "../package.json" — reddening main.
Resolve the package.json reference in load_tauri_config() the way Tauri does, so
the integrity check sees the effective bundle version. The check now validates
the *real* thing end-to-end: the resolved desktop bundle version matches the
project version (and implicitly that package.json == pyproject).
Full suite: 1691 passed, 20 skipped, 11 xfailed, 3 xpassed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Five hand-maintained version literals (pyproject, Cargo.toml, tauri.conf.json,
package.json, version.py) drifting is what shipped a 0.3.6 build calling itself
0.3.5 (package.json lagged; the frozen backend's literal lagged). Collapse to
one canonical source.
- frontend/package.json is canonical: vite already injects __APP_VERSION__ from
it (first-run setup footer + bug reports).
- tauri.conf.json now reads its bundle version from it ("version":
"../package.json", a supported Tauri v2 feature) — the MSI/dmg/updater version
can no longer drift from the UI. Removes the most error-prone literal.
- Cargo.toml + pyproject.toml + version.py's _FALLBACK_VERSION remain as
toolchain-required CI-guarded mirrors, bumped in lockstep from the canonical.
- release.yml: the preview-stamp and version-bump jobs now read/write
package.json (the canonical) and no longer touch the derived tauri.conf.json.
- tests/test_app_version.py: new test_tauri_version_derives_from_package_json
guards the path; the lockstep test now checks the mirrors against the
canonical package.json.
- CLAUDE.md versioning rule updated to document the single-source model.
6 version tests pass.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Python's `mimetypes.guess_type()` returns `audio/x-wav` for `.wav` and
`audio/x-flac` for `.flac` — vendor-experimental types (x- prefix) that
were never IANA-registered. macOS Chrome/Safari MIME-sniff leniently via
CoreAudio so playback works there, but Linux Chrome/Firefox (FFmpeg) and
Android Chrome (ExoPlayer) strictly honor the declared type and treat
the x- variants as download-only.
Result: the play button in the browser web app silently did nothing on
Linux/Android (download prompt instead of inline playback), while the
Tauri desktop shell worked because its WebView is lenient. The
diagnostic signal — Chromium short-circuits to download BEFORE the
<audio> element sees the response, so no MEDIA_ERR_SRC_NOT_SUPPORTED
fires; just a download prompt that's easy to miss.
Fix: register `audio/wav` and `audio/flac` (the IANA-canonical types)
via `mimetypes.add_type()` before the StaticFiles mounts in main.py.
No browser-side workaround exists (no chrome://flags, no about:config
pref) — the server is the only place this can be fixed.
Existing comment in dub_export.py:766 already acknowledges this exact
quirk for video files; this applies the same treatment to audio.
Test: regression test in test_api.py asserts `/audio/<file>.wav` returns
`Content-Type: audio/wav`. Without the fix this returns `audio/x-wav`.
Ref: https://www.iana.org/assignments/media-types/media-types.xhtml#audio
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The desktop app's About panel, /health, /system/info, diagnostics, bug reports,
and exported persona/marketplace bundle metadata all read core.version.APP_VERSION.
In a synced env that resolves from package metadata (correct), so CI and the
lockstep test were green — but the PyInstaller-frozen backend has no omnivoice
.dist-info, hits PackageNotFoundError, and fell back to a hardcoded
APP_VERSION = "0.3.5". The version-bump job never touched that literal, so every
0.3.x desktop build has been reporting 0.3.5 regardless of its real version.
Fix (belt and suspenders, so it can't recur):
- backend.spec: copy_metadata('omnivoice') so importlib.metadata resolves in the
frozen build — the primary path now works there too.
- backend/core/version.py: resolution chain is metadata → pyproject (walked up,
correct for raw source checkouts) → a named _FALLBACK_VERSION literal as last
resort (no longer the only fallback).
- tests/test_app_version.py: _FALLBACK_VERSION joins the lockstep (now FIVE
sources); + a test that the fallback resolves to pyproject, + a test that
backend.spec copies the metadata (so the frozen path can't silently regress).
- release.yml version-bump: also bumps _FALLBACK_VERSION so the lockstep guard
never reddens main after a release.
Already-shipped binaries can't be fixed, but every build from here (tonight's
preview, the next stable) reports its real version. 5 version tests pass.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Preview update channel was effectively dead: its only build trigger was a
manual workflow_dispatch, so "preview = main" was never enforced — the live
preview manifest was stuck at 0.3.5-41 (June 7) while main moved to 0.3.7.
It also shipped two latent hazards: the `preview` GitHub release had drifted to
isPrerelease=false (a non-prerelease `preview` is eligible to become GitHub's
"Latest" — the exact URL the *stable* updater reads, so it could hijack the
Stable channel), and its updater manifest dropped darwin-x86_64 (Intel-Mac
preview users silently got no updates — a cross-platform-parity breach).
Changes (release.yml):
- Add a nightly `schedule` (07:00 UTC) that rebuilds the rolling `preview`
prerelease from main. A new `preview-gate` job no-ops the 4-platform matrix
on nights when main didn't move, so idle days cost only a ~30s gate job.
- Centralize the preview-vs-stable decision in `preview-gate.outputs.is_preview`
(schedule OR workflow_dispatch+publish_preview), consumed by the stamp step,
tauri-action, and preview-notes — replacing the repeated inline conditions.
- Harden the prerelease flag: preview-notes' `gh release edit` now re-asserts
`--prerelease` every run, and a new post-publish step fails the run if the
preview release isn't a prerelease or its manifest is missing any platform
stable ships (catches the Intel-Mac regression in CI).
Docs (docs-sync): update docs/update-channels.md — previews are no longer
"manual / no scheduled spend"; they build nightly from main (+ on demand).
The live `preview` release was re-flagged prerelease out-of-band to close the
hazard immediately; this makes it recurrence-proof.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.3.6 was tagged with the "Auto-generated release for v0.3.6…" fallback body
because CHANGELOG.md had no matching section (release.yml's "Extract CHANGELOG
section for tag" step found nothing). Backfill a real, user-facing
`## [0.3.6] — 2026-06-16` section (Longform suite, engine routing, dubbing +
install reliability, FSL→AGPL relicense) and fold the shipped `.ovsvoice`
persona entry out of [Unreleased]. The live GH release body was updated
in-place to match (notes + the per-platform checksum blocks preserved).
Add a "Release notes / changelog" hard rule to CLAUDE.md: every tagged release
(and preview build) gets a high-quality, house-style CHANGELOG section before
the tag — never the auto-generated fallback — since release.yml ships that
section verbatim as the release body.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pre-v0.3.6 release sweep found frontend/package.json stuck at 0.3.5 while the
other three version files were 0.3.6. package.json drives the runtime
`__APP_VERSION__` (vite.config.js), so a v0.3.6 build was calling itself "v0.3.5"
in the first-run footer AND in every auto bug report (undercutting the bug-report
feature). Root cause: the release.yml version-bump job only bumped the trio
(tauri.conf.json / Cargo.toml / pyproject.toml), never package.json, and no test
guarded the lockstep.
- Bump frontend/package.json 0.3.5 → 0.3.6 (matches the trip; `--frozen-lockfile`
still passes — the version field doesn't affect the bun lock graph).
- Add frontend/package.json to the release.yml version-bump job (set absolutely
via jq so any prior drift self-heals on the next release).
- Add tests/test_app_version.py::test_all_version_files_in_lockstep — fails CI if
the four files ever diverge again.
- CLAUDE.md versioning rule updated: it's now FOUR lockstep files, not three
(docs-sync).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Persist the planning artifacts produced this cycle:
- specs/006-dubbing-singing-mode/ (SoulX-Singer SVS evaluation + plan; supersedes
SPIKE-02, which is marked superseded here).
- specs/007-donate-cta/ ("Fund Claude Max" goal bar + kawaii postcard design,
conversion strategy, frequency state machine, Discord surface).
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The auto-repair ran `uv pip install setuptools>=75,<80`, which `uv` treats as
"already satisfied" (no-op, "Checked 1 package in 5ms") whenever setuptools'
*metadata* is present but its `pkg_resources` files are gone — the common cause
being Windows Defender quarantining `pkg_resources/`, or a partial extract on a
restricted network. So the repair never restored the files, the post-check
failed, and users hit the #248 dead-end. The error message *also* told them to
run the same no-op command, so the suggested manual fix didn't work either
(reported on Discord, Win11 + RTX 5070 Ti).
Fix: both repair sites in bootstrap.rs now use `--reinstall` (the flag already
used for the ROCm torch repair), which force re-extracts pkg_resources even when
uv thinks setuptools is satisfied. The fail() message and the failure.py hint now
suggest `uv pip install --reinstall 'setuptools>=75,<80'` + an antivirus-exclusion
note, and docs/install/troubleshooting.md (#pkg_resources-missing) is updated with
the real cause (metadata-present/files-missing) + AV guidance.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Problem
OmniVoice's donate surface was a static link page. There was no sense of
shared progress toward a concrete funding goal, and no gentle, success-only
ask after a user got value — only an always-on footer heart.
Design
Phase 1 — Goal bar + data (Option B):
- frontend/public/donation_progress.json (committed snapshot) + a bundled
offline fallback in api/donation.ts. loadDonationProgress() best-effort
fetches a fresher copy and gracefully falls back to the bundle on any
failure (offline / non-2xx / bad JSON). Never throws.
- <GoalBar> (page + `mini` variant), --goal-pct-driven fill, Pip mascot
perched on the fill, ONE shimmer pass, reduced-motion guard on every
animation. Added to SupportPage above the payment cards with
"Join {n} supporters" social proof + suggested amounts ($3/$5/$10/Custom,
middle flagged "most common", NONE pre-selected).
Phase 2 — Pip + postcard + state machine:
- Pip.jsx (currentColor->accent, pipBob/pipWave idle, reduced-motion off).
- donationSlice.ts composed into the store: added to partialize (all EXCEPT
shownThisSession), version 5->6 with a pass-through migrate branch.
shouldShow rules: first-3 grace, <=1/session, escalating 7d/14d/30d/75d
cooldowns, optedOut terminal, success-only.
- Postcard.jsx rendered via react-hot-toast as a NON-BLOCKING custom toast
(no backdrop, no focus steal, ~12s auto-dismiss, pause on hover) with the
perforation / dot-grain / stampThunk / postcardIn / .is-leaving art,
a mini GoalBar, and Chip in / Maybe later / quiet Don't ask again /
free Star on GitHub actions.
- One shared evaluateDonationPrompt() called right after each SUCCESS
(dub-complete, clone-save resolve, longform export) — never on the
error / in-progress / setup / first-run paths.
Phase 3 — Milestones + pill:
- Milestone eval (1st clone / 10th dub / 30-day sustained, each once-ever,
same cooldowns + opt-out) inside the shared evaluator.
- Quiet nav-rail .donate-pill (🩷 Support) that warms to the accent on
hover and opens setMode('donate').
Tests (vitest, all green: 60 files / 533 tests)
- donationSlice.test.ts: shouldShow truth table with injected `now` — grace,
each cooldown rung, session cap, opted-out terminal, success-only.
- GoalBar.test.jsx: renders from injected JSON, offline fallback to bundle,
goal-met state, mini variant; plus the data module's clamp/normalize/fetch.
- evaluateDonationPrompt.test.jsx: gating + that the postcard never fires on
the error path (success-only contract).
bun run typecheck:ci clean; vite build green; root bun.lock untouched
(frozen install verified); i18n: all user-facing strings via t('donate.…')
with English defaultValue fallbacks — no hardcoded CJK.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(profiles): decouple design-profile save from TTS render (#476)
Saving a design voice profile forced a full TTS model load + inference to
render a deterministic identity sample. On a fresh model-less image (Docker
first-run) that 503'd, so the save failed. A secondary guard also rejected an
all-Auto design (empty instruct) with a 422.
Saving a design profile is now a pure persistence operation:
- The seed-42 identity sample render is attempted opportunistically but is
non-fatal — if the engine isn't ready the row is persisted with
ref_audio_path=NULL (sample pending). The row's vd_states + instruct already
make the voice fully usable (generation.py falls back to instruct-only
conditioning for design profiles with no ref audio).
- The sample is rendered lazily + cached on the first GET /profiles/{id}/audio
request; if the engine is still unavailable that path returns a precise
"model not ready — finish setup / download a model" 503.
- The all-Auto (empty-instruct) design is now saveable (vd_states still
required).
Adds tests/test_profile_design_save_decouple.py (top-level tests/, asyncio.run
per test) covering: design save with model unavailable creates the row instead
of 503-ing; all-Auto design is saveable; the pending sample materializes on
first /audio request. Updates the unification spec (docs-sync).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(profiles): contain profile-audio paths under VOICES_DIR (CodeQL CWE-22)
The lazy design-sample path was built as `os.path.join(VOICES_DIR,
f"{profile_id}.wav")` / `os.path.join(VOICES_DIR, audio_file)` where profile_id
is the request path param — CodeQL flagged 5 high-severity path-injection alerts
(profiles.py + the taint flowing into archetypes.py's torchaudio save). Add
`_safe_voice_path()` (basename + safe-char sanitise + realpath containment,
mirroring core.config.dub_seg_path) and route both the read and lazy-render
sites through it; a traversal id now 404s instead of escaping VOICES_DIR.
Regression test covers the containment guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(profiles): use CodeQL-recognized path-injection guards (CWE-22)
The previous `_safe_voice_path()` helper was correct (basename + realpath
containment) but CodeQL's taint tracking didn't propagate the barrier through
the function return, so the 5 path-injection alerts persisted. Switch to guards
CodeQL recognizes, inline at each file-op site:
- validate `profile_id` against the generated-id charset (`[A-Za-z0-9_-]{1,64}`)
with `re.fullmatch` and 404 on mismatch (covers the `f"{profile_id}.wav"`
render path);
- read only `os.path.join(VOICES_DIR, os.path.basename(name))` so a stored/derived
filename is always a direct child of VOICES_DIR (covers the read + the taint
flowing into archetypes.py's torchaudio save).
Drop the helper. Test now asserts a traversal/separator/NUL profile_id 404s at
the guard. Same security property, recognized by CodeQL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(profiles): inline realpath+commonpath containment for CodeQL (CWE-22)
CodeQL didn't recognize the earlier sanitizers — neither the helper (barrier
hidden behind a function return) nor os.path.basename / a cross-function regex
guard cleared the 5 path-injection alerts. Use the canonical, CodeQL-recognized
form INLINE at each file-op site: resolve the path with os.path.realpath (which
collapses any `..`) and confirm os.path.commonpath((base, path)) == base before
the read / the render, returning 404 / raising on escape. Same property the
helper had, now in a shape CodeQL's taint tracking follows. Keeps the profile_id
charset guard as defense-in-depth.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(profiles): route design-sample path through shared _voices_path guard (#476)
The inline realpath+commonpath containment in get_profile_audio and
_materialize_design_sample wasn't recognized by CodeQL as a path-injection
sanitizer (5 new high-severity py/path-injection alerts at the file-op sites,
incl. archetypes.py mkdir via the rendered Path). Both now reuse the existing
_voices_path() helper, which applies the os.path.basename() barrier plus
symlink-resolved containment — the same guard the consent endpoint uses and
that CodeQL already accepts. Behavior is unchanged: the DB columns only ever
hold bare {profile_id}.wav filenames, so basename() is a no-op here.
Tests: tests/test_profile_design_save_decouple, test_profile_unification,
test_profile_consent, test_archetype_blank_guard — 25 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Owner-set 2026-06-16. (1) Fix issues properly/future-maintenance-proof — fix the
whole class, add a regression test, harden against recurrence; extra effort, not
extra verbosity. (2) A merge must never break main's CI — verify the full CI
matrix (every .github/workflows/* AND deploy/Dockerfile) before landing, with
explicit guidance that frontend/ is a bun workspace monorepo whose root bun.lock
must be regenerated on any frontend/package.json change (Docker uses
--frozen-lockfile; plain bun install in ci.yml tolerates drift). Motivated by the
#485 bun.lock incident that reddened main's Docker workflow.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bug #481 — Clone "+Insert" popover was clipped offscreen and the script
textarea couldn't be resized:
- Apply the existing `.clone-panel--overflow-visible` helper to the script
`.studio-panel` so the upward-opening popover escapes the panel's
`overflow:auto` box instead of being shoved into its scroll region.
- Cap the popover at `max-width: min(360px, calc(100vw - 16px))` so the
14-chip grid can never spill past the viewport edge.
- Re-enable the textarea corner grip (`resize: vertical`, matching the base
`textarea.input-base`) and lift the ⊕ Insert button off the bottom-right so
it no longer physically covers the drag handle.
Bug #476 — the design-mode "Synthesize Audio" CTA dropped below the fold on
narrow shells:
- Replace the raw `@media (max-width: 900px)` reflow rules with the app's
shell-width classes (`.shell-narrow` / `.shell-mini`, set in App.jsx from
`app-container.clientWidth`). The shell scales via `zoom`, so a viewport
media query fired at the wrong threshold whenever `--ui-scale ≠ 1`.
- When stacked, let `.studio-with-history__main` grow (drop its `overflow:hidden`
clip) and pin the action bar `position: sticky; bottom: 0` so the Synthesize
CTA stays on-screen.
Pure CSS + one className; no component restructuring. Added a regression test
guarding the shell-class reflow + sticky CTA against the viewport-`@media`
anti-pattern. typecheck:ci clean; vitest 506/506 green.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Multi-speaker dubs detected speakers and built per-speaker clones (Voice
dropdown showed "From Video → Speaker N"), but most segments stayed on
"Default" voice and had to be set by hand — inconsistently across runs.
Root cause: after diarization, dub_core stamped each long line (the
default-on per-segment-ref path) with `auto-seg:{id}` as its profile_id.
The dub editor's Voice <select> (and the Cast panel) only render `auto:`
options, so an `auto-seg:` value matched no <option> and silently showed
"Default". Short lines (<3s) fell through to `auto:{speaker}`, which DID
render — hence "sometimes the cloned voice is picked".
Fix: bind every segment to the UI-visible `auto:{speaker}` whenever its
detected speaker has a clone; only fall back to `auto-seg:{id}` when the
speaker has no per-speaker clone at all. The per-segment-ref quality win
is preserved: dub_generate's `auto:` branch now transparently prefers
THIS segment's own per-segment ref (segment_clones[seg_id]) when present,
else the per-speaker clone. Manual overrides and the no-clone path are
untouched; existing jobs that persisted `auto-seg:` ids still resolve.
Tests: tests/test_dub_multispeaker_voice_486.py — assignment binds to
auto:{speaker} (not auto-seg:), never clobbers manual overrides, falls
back to auto-seg: only when the speaker has no clone; generate-time
resolution prefers per-segment ref then per-speaker clone. Green
alongside the existing dub generate/incremental/segmentation suites.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(deps): re-sync root bun.lock after #485 frontend floor bumps (main Docker red)
#485 bumped ~25 dependency floors in `frontend/package.json` but didn't
regenerate the repo-root `bun.lock` (this is a bun *workspace* monorepo — the
lockfile lives at root and embeds the frontend member's ranges). The Docker
workflow runs `bun install --frozen-lockfile`, which failed on the drift
("lockfile had changes, but lockfile is frozen") — turning main red on commit
4bcbc74. `ci.yml` uses a plain `bun install`, so it tolerated the drift and went
green, which is why only Docker caught it.
Regenerate `bun.lock` so its embedded frontend snapshot matches the manifest;
`bun install --frozen-lockfile` now passes (verified locally, bun 1.3.14, the
same version Docker uses). Lockfile-only change.
Follow-up (separate): switch `ci.yml`'s frontend `bun install` to
`--frozen-lockfile` so this drift class fails fast in CI, not only in Docker.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci: use --frozen-lockfile for frontend install so lockfile drift fails fast
Recurrence-proofing for the #485 incident: ci.yml's plain `bun install` silently
tolerated the root bun.lock drifting from frontend/package.json, so CI went green
while only the Docker build (which already uses --frozen-lockfile) caught it and
reddened main. Both frontend install steps now use --frozen-lockfile, so a
package.json change that forgets to regenerate root bun.lock fails in CI fast.
Verified `bun install --frozen-lockfile` passes from frontend/ against the
re-synced lockfile (bun 1.3.14).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cinematic dub refinement, glossary auto-extract, and LLM-based translation all
`from openai import OpenAI` (services.llm_backend / translator / dub_translate /
glossary), but `openai` was declared nowhere in pyproject — not in dependencies,
not in any optional extra, and no setup script installed it. So a fresh `uv sync`
never installed it, and these features were dead-on-arrival on every source
install: picking Cinematic showed "Cinematic needs an LLM" even with Ollama
running and correctly configured, because `OpenAICompatBackend.is_available()`
returned "openai package missing". The UI's "pip install openai" hint is a trap
on a managed venv — users (Discord report) installed it into system Python, not
the app's `.venv`, so it still didn't take.
Add `openai>=1.40` to dependencies (resolves to 2.41.1; verified the code's
`OpenAI(...)` + `chat.completions.create(model=, messages=)` call shapes are
unchanged in 2.x). Pure-Python, no native deps → identical on macOS/Windows/Linux
(default-parity rule). Cinematic + any OpenAI-compatible endpoint (OpenAI, Ollama,
LM Studio, vLLM) now work after `uv sync`, no manual package install.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Changing the model download location in Settings had no effect: after the
prompted restart, new downloads still went to the old folder and "Effective
location" stayed stuck on it.
Two stores hold the models dir. The in-app Settings panel writes the new path to
the durable per-user env file (`~/.config/omnivoice/env`, OMNIVOICE_CACHE_DIR),
but the desktop launcher injects the OLD value from its own Tauri config into the
backend's environment before startup — and main.py loaded the per-user file with
`override=False`, so the launcher's stale value always won. main.py then maps
OMNIVOICE_CACHE_DIR → HF_HOME/HF_HUB_CACHE/TORCH_HOME, pointing downloads at the
old dir; `_effective_models_dir()` reads that live env, so the UI faithfully
reported the old path as if the change had failed.
Fix: load the per-user env file with override so it beats launcher-injected
defaults — restoring this file's documented "values written here take effect on
the next backend launch" contract. Centralized as `user_env.load_into_environ()`
(the file is the in-app Settings source of truth) and called from main.py. Both
keys this file can hold (OMNIVOICE_CACHE_DIR, HF_ENDPOINT) are the user's explicit
Settings choice and should beat the launcher default, so the override is correct
for both (this also fixes the same latent bug for a Settings-set HF mirror).
HF_TOKEN isn't launcher-injected, so its behavior is unchanged.
Follow-up (separate PR): add a Tauri `set_models_dir` command so the launcher's
config.json stays in sync, covering the reset-to-default edge too.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WhisperX transcription called `whisperx.load_audio()`, which shells out to a
literal `"ffmpeg"` resolved against the OS PATH. On Windows that resolves to a
WindowsApps alias stub or a corrupt/wrong-arch binary — passing `which` but
exploding at spawn with `[WinError 193] %1 is not a valid Win32 application`.
whisperx only catches `CalledProcessError`, so the spawn-time `OSError` escaped
and the dub/batch path reported the opaque "Transcription produced no segments".
#377 added ffmpeg validation but only for the dub-export path; the transcription
path never went through the validated resolver. Since WhisperX is a default ASR
engine, this is a P0 platform-parity break (works on mac/Linux, fails on Windows).
Fix: decode the audio ourselves in `WhisperXBackend.transcribe` via
`find_ffmpeg()` (which `-version`-probes each candidate and returns the bundled
imageio-ffmpeg / Tauri sidecar) and hand WhisperX the array — bypassing the bare
PATH lookup entirely. This is more robust than a PATH-prepend, which couldn't
fix the imageio case (its binary is named `ffmpeg-<plat>-vN.exe`, not `ffmpeg`).
If no runnable ffmpeg exists, raise a clear, locale-independent error instead of
"no segments". Fixes both the dub and batch transcription paths.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When neither the request nor the job carries a detected source language,
_resolve_source_lang() silently fell back to "en". For non-English audio
(e.g. Korean) this produced en -> en, which has no Argos package and failed
every segment — even though WhisperX had detected the language correctly
(e.g. "Detected language: ko (0.98)").
Add a last-resort script-based guess (ko/ja/zh/ru/ar) from the segment text
so the bare "en" fallback no longer breaks non-English dubbing.
Co-authored-by: stronghamjji <289942360+stronghamjji@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The entire main UI is gated behind `bootstrapStage === 'ready'` (App.jsx) — until
the Python backend reports ready, only the BootstrapSplash shows. If the backend
hangs in a non-terminal stage and never reaches ready (e.g. a failed from-source
backend spawn on Windows: uv/Python not on PATH), useBootstrapStage polled
forever, trapping the user on a splash with no Settings / Start / Clone / Extract
buttons — which is exactly what #474 reports (verified: no backend-startup
regression; every startup-imported router imports cleanly).
- useBootstrapStage: add a per-stage stall watchdog. Track when (stage,message)
last changed; if a non-terminal stage sits past its budget (installing_deps
gets 20 min since it legitimately runs 5–10 min; everything else 120 s), flip
to the existing `failed` state — which already surfaces actionable hints, the
live log panel, and Retry / Clean-&-Retry. Any change resets the clock, so a
live install never trips it.
- detectHints + bootstrap.hint_stuck: a targeted hint for the stuck case
(run `uv sync`, check uv/Python on PATH, read the log / Settings → Logs).
- CONTRIBUTING.md: document `bun run desktop-prod` (the prod desktop command the
reporter typo'd as `desktop=prod`), note both desktop scripts auto-run
`uv sync` + start the backend, and add a "stuck on the setup splash" pointer.
No backend code change. Frontend suite green (503); CJK guard green.
* feat(audiobook): durable crash-resume for interrupted longform renders
Chapter WAVs were already content-addressed (a re-run reused finished chapters),
but resume only worked if the user could re-submit the EXACT script — impossible
for Stories, whose plan is compiled from cast+lines. This persists the plan
itself so an interrupted render is resumable without the original input.
- New services/longform_resume.py (pure file/JSON): on render start, write a
resume.json manifest (compiled plan + render params + title) into the job work
dir, atomically; clear it on successful completion. read/has/clear/build
helpers, schema-versioned (a foreign/corrupt manifest is ignored, never
resumed).
- _render_longform_sse: accepts an optional job_id + resume flag (resume reuses
the original job row + cached chapters instead of creating a new one); writes
the manifest at start, clears it on done. Both front doors (/audiobook,
/longform/render) unchanged for callers.
- GET /audiobook/jobs — lists interrupted renders (running/failed longform jobs
that still have a manifest; a job left "running" across an app restart is
interrupted by definition), with title + total/done chapter counts for the UI.
- POST /audiobook/resume/{job_id} — rebuilds the plan from the manifest and
replays _render_longform_sse under the original job_id; the content-addressed
cache makes finished chapters instant, so only the unrendered ones synthesize.
404 on unknown id / missing manifest.
Resume durability is best-effort — a manifest failure never blocks the render.
The resume UI affordance is a follow-up (the endpoints are ready for it).
Tests: tests/test_longform_resume.py (7, pure manifest round-trip / version &
corrupt rejection / atomic write — monkeypatches OUTPUTS_DIR, no global
core.config stub so the shared tests/ session isn't polluted) +
backend/tests/test_audiobook_resume_api.py (6, config-stub: jobs-list with
progress, failed-included, done/manifestless/non-longform excluded, resume
404s). 13 passed. CJK green. Stale module docstring updated.
* fix(audiobook): confine resume paths — py/path-injection (CodeQL) + quality
The default-setup CodeQL (security-and-quality suite) flagged the crash-resume
work: longform_resume built filesystem paths from job_id, which on the
POST /audiobook/resume/{job_id} endpoint is a request-supplied path param →
py/path-injection (10 high-severity sinks: open/replace/remove/makedirs/isfile).
- longform_resume.work_dir now confines like profiles._voices_path: reject an
unknown job_type or an id that isn't a bare safe token (^[A-Za-z0-9_-]{1,64}$),
then realpath + startswith(OUTPUTS_DIR + os.sep) — a crafted id (`../`, NUL,
separators) can never escape OUTPUTS_DIR. Returns None on violation; all
callers (manifest_path/read/write/clear/has) degrade gracefully.
- The resume endpoint also gates the path-param id up front (404 on a bad
token) — barrier at the source as well as the sink.
Also cleared the quality alerts the same diff introduced:
- py/repeated-import: the 4 inline `from services import longform_resume` calls
collapse to one module-top import (it's pure, no torch).
- py/empty-except: the best-effort manifest blocks now logger.debug instead of
a bare `pass`.
13 resume tests still pass; all job ids in tests are safe tokens.
* fix(audiobook): sanitize resume job_id at the source (path + log injection)
The first CodeQL pass wasn't enough: resume made job_id request-controlled, so
it tainted not just the manifest paths but the EXISTING work-dir join and the
progress log lines too (py/path-injection + py/log-injection, ~14 alerts).
Fix at the source so the whole dataflow is clean:
- _render_longform_sse strips job_id to a safe token (`re.sub` removing anything
but [A-Za-z0-9_-], capped 64) right after it's resolved — no path separator,
no CR/LF can survive, whether the id came from the resume path param or a
fresh uuid.
- The work dir now routes through longform_resume.work_dir, which adds the
proven os.path.basename(seg)==seg barrier (the shape CodeQL accepts in
_voices_path) on top of the realpath+startswith confinement — so the join and
every path derived from it (meta/concat/out) is sanitized.
- The best-effort manifest-write log no longer interpolates the raw exception
(uses exc_info); clear_manifest's OSError handler returns instead of bare pass
(py/empty-except).
13 resume tests still pass.
* fix(audiobook): launder resume job_id via trusted FS scan (CodeQL path/log-injection)
The custom realpath/regex barriers weren't in CodeQL's recognized sanitizer set,
so the request-supplied resume job_id kept tainting the work-dir/manifest paths
and the progress logs. Switch to the pattern CodeQL does accept — launder the id
through a trusted filesystem enumeration:
- longform_resume.scan_resumable() lists resumable jobs by scanning OUTPUTS_DIR
for <type>_<id>/resume.json; every id it returns is sourced from os.listdir
(never request input).
- POST /audiobook/resume/{job_id} now only resumes an id that scan_resumable()
reports (membership match), and uses the (job_type, job_id) pair FROM that
trusted list for everything downstream — so nothing request-controlled reaches
a filesystem path or a log line.
- GET /audiobook/jobs lists from scan_resumable() too (filesystem-sourced ids).
work_dir keeps the realpath+startswith+basename confinement as genuine defense;
the render path's job_id is now always either a fresh uuid or a laundered id.
13 resume tests still pass.
* fix(audiobook): exact-match allowlist on the work-dir name (CodeQL path-injection)
The remaining 4 path-injection alerts were inside work_dir: I validated job_id
with an anchored regex but then joined a DIFFERENT f-string (`{job_type}_{job_id}`),
so CodeQL didn't carry the sanitization to the joined value. Mirror the pattern
the repo's _safe_cover_path uses (which CodeQL accepts): validate the WHOLE
joined component against an exact-match allowlist regex (_SAFE_SEG_RE), then
confine with os.path.commonpath containment (the recognized barrier) instead of
startswith. 13 resume tests still pass.
* fix(audiobook): basename-sanitize the work-dir name for CodeQL path-injection
The exact-match regex alone wasn't credited; route the joined value through os.path.basename() first — the sanitizer CodeQL recognizes (mirrors _safe_cover_path) — then the regex + commonpath. Functionally identical (no separator in the name) but clears the 4 remaining alerts. 13 tests pass.
* fix(audiobook): allow-list membership guard launders resume job_id (CodeQL)
The next(... if pair[1]==job_id) comparison-select didn't sanitize for CodeQL. Build a dict of resumable ids from the trusted scan and gate with 'if job_id not in resumable' — the membership barrier CodeQL recognizes — then use job_id directly downstream. 13 tests pass.
* fix(audiobook): eliminate request→path flow in resume (definitive CodeQL fix)
Five rounds of recognized path-injection barriers (regex, basename, exact-match,
commonpath, membership-guard) still left CodeQL flagging the resume job_id →
work-dir/manifest/log flow. Remove the flow entirely instead of guarding it:
- scan_resumable() now returns {job_type, job_id, manifest_path} where
manifest_path is built from the os.listdir dir name (trusted), plus
load_manifest_file(path) / discard_manifest_file(path) that operate on those
trusted paths. The request job_id is used ONLY to *select* a scan entry, never
to build a path.
- POST /audiobook/resume/{job_id} reads the manifest via the trusted scan path
and renders under a FRESH server uuid (job_id=None). The chapter cache is
content-addressed (keyed by chapter content, not the job id), so finished
chapters still hit instantly — resume works, but the request's id never names
a work dir, output file, or log line.
- The interrupted job's manifest is discarded (trusted path) once the fresh-id
resume kicks off, so it stops showing as resumable.
Net: no request-controlled value reaches any file operation or log on the
render path (job_id there is always a server uuid). work_dir keeps its
confinement barriers as defence-in-depth. 13 resume tests pass.
Every cloned generation re-encoded the reference audio from scratch — a fixed
per-request latency that compounds on batch / long-form / dataset workloads that
reuse one saved voice across many calls.
The OmniVoice model already exposes the fast path (create_voice_clone_prompt →
VoiceClonePrompt, generate(voice_clone_prompt=)); the Studio backend just wasn't
using it. OmniVoiceBackend.generate now:
- builds a VoiceClonePrompt once per reference and caches it (bounded LRU, max 8,
keyed by ref path + mtime + ref_text; thread-safe — generation runs in a GPU
thread pool), then passes voice_clone_prompt= to skip the re-encode;
- falls back to the inline ref_audio/ref_text path on ANY cache miss or error,
so output is identical either way (the model documents the two as equivalent)
— this is purely a latency optimization, never a behaviour change;
- the design/instruct path (no ref_audio) is untouched.
- unload() clears the cache so a flush / engine-switch frees the prompt tensors.
tests/test_clone_prompt_cache.py: 6 cases (encode-once-then-hit, ref_text +
mtime invalidation, LRU eviction at the cap, encode-failure → None fallback,
clear). 6 passed.
Closes#427.
The Stories editor only had a per-track speed slider; long scripts had no way to
set one speed for the whole thing. Add a global speed control that applies to
every line WITHOUT its own per-track override (the per-track slider still wins).
- storyToSpans(tracks, cast, globalSpeed): per-track speed wins, else the global
speed, else engine default. 1.0× (and null) is treated as "no override" so a
resting control never stamps an explicit speed on every span. Builds on the
#27 default_speed plumbing already in the canonical parser.
- StoriesEditor: a global speed slider in the toolbar (0.5–2.0×, with reset),
persisted to localStorage (UI preference — no project-state/slice migration).
- i18n: stories.global_speed / global_speed_hint in en.json.
Tests: storyToSpans.test.js +2 (global applies to un-overridden lines, per-track
wins; 1.0×/null/default-arg = no override). 17 file / 120 suite pass; CJK green.
Closes#415.
Two reliability bugs from open issues, both first-run papercuts where the error
told the user the wrong thing.
#437 — `[Errno 13] Permission denied: bin/omnivoice-tts-linux-x86_64`: a git
clone / zip extract on POSIX can drop the bundled binary's execute bit. It only
surfaced at spawn time, and the generic synth handler then mislabeled it as
"ran out of memory" and told the user to flush the model.
- omnivoice_gguf.is_available() now self-heals: after the SHA check confirms the
binary is the right file, it adds +x (best-effort) on POSIX; if it can't, it
returns a clear "isn't executable — run chmod +x <path>" message instead of a
spawn-time crash. No-op on Windows.
- generation.py classifies PermissionError / EACCES / "Permission denied" as its
own case ("a bundled binary lost its execute bit — reinstall or chmod +x"),
so it never again masquerades as OOM.
#438/#454/#466 — bare "Failed to fetch" / "NetworkError": when the local backend
is still starting, crashed, or the dev server dropped, fetch() throws a TypeError
that propagated raw to the user.
- client.ts apiFetch now catches the thrown fetch and raises an ApiError with an
actionable message ("Can't reach the local OmniVoice backend — it may still be
starting up… restart the app or check Settings → Logs"), status:0 to mark a
transport failure vs an HTTP error.
Tests: client.test.ts +1 (thrown fetch → ApiError status 0 + actionable text);
3 pass. CJK guard green.
test_stored_value_is_encrypted_not_plaintext asserted `"hf_" not in raw`, but
the stored value is Fernet URL-safe base64 whose alphabet includes `_`, so a
random ciphertext occasionally contains the substring `hf_` by chance — a
false failure that bit unrelated PRs on CI (~1 in N runs).
Replace the 3-char-prefix substring check (weak AND flaky) with stronger,
deterministic guarantees:
- the full token is absent from the raw column (kept),
- a 16-char leading chunk is absent (no partial leak; 62^16 ≈ never collides),
- and the value round-trips via get_hf_token() — proving it's genuinely
encrypted, not merely absent/empty.
Verified non-flaky: the target test passed 8/8 consecutive runs.
The SSML-lite client port header said "keep in sync with
backend/services/ssml_lite.py" — a manual contract with no test behind it.
After #27 the canonical longform grammar (incl. SSML-lite via the
longformParser.js → storyToSpans path) is asserted byte-for-byte against the
Python parser through the shared golden corpus
(tests/fixtures/longform_parser_cases.json), so drift between the two SSML impls
now fails CI. Update the comment to point at that enforcement.
No user-facing docs document the marker dialect (verified by grep: only the
internal competitive-analysis planning doc references it), so no docs-sync
update is required for the converged behaviour.
The longform marker dialect (# heading / [voice:] / [pause] / SSML-lite) was
parsed by three independent code paths that already disagreed (client vs server
on [pause] units, [voice:] empty, H1-only chapters). This lands the single
canonical Python parser; the JS port + cross-impl test follow in slice B.
- New backend/services/longform_parser.py — parse_script_to_spans(text, *,
default_voice, default_speed) + _parse_chapter_body (the reusable voice→pause
→SSML layering the JS twin mirrors). Moves the H1/voice regexes verbatim from
audiobook.py (already CodeQL-cleared), reuses parse_pause_markers + ssml_lite
unchanged. Coerces None→"" and normalizes CRLF/CR→LF at entry (cross-platform
parity so Windows-authored scripts never carry a stray \r). Adds default_speed
plumbing (inline SSML speed overrides the per-line default).
- audiobook.py: parse_audiobook_script is now a thin wrapper that wraps the
canonical span dicts in Span/Chapter/AudiobookPlan — public return type and
.to_dict() shape unchanged, all four router call sites untouched. Deleted
_parse_spans / _HEADING_RE / _VOICE_RE and the now-dead `import re` +
parse_pause_markers import.
- tests/fixtures/longform_parser_cases.json — 78-case golden corpus (≥40
required) covering §A–I: H1-only chapters (H2–H6 + `# ` no-title → body), the
full pause dialect incl. the NO-MATCH boundary, banker's-rounding ties
([pause 0.5]→0, [pause 1.5]→2), [voice:] empty→default, [voice:[nested]]
literal, SSML nesting/spell/unknown-tag, speed override, CRLF, combined
precedence. Generated from actual parser output (the truth the JS port must
match).
- tests/test_longform_parser.py — parametrized over the corpus + None-input +
ReDoS-linearity (5000× repeats < 1 s).
130 passed (corpus + test_audiobook + test_pause_markers + test_ssml_lite all
green); CJK guard green.
- docs/persona-format.md: export (privacy/include-reference, watermarked
preview), import (consent/verification non-forgeability rule), the ZIP layout
table, SPDX-license semantics (metadata only), and the local-first / zero-
network guarantee. Notes legacy .omnivoice compatibility.
- CHANGELOG.md: [Unreleased] → Added entry for portable personas.
Satisfies the docs-sync hard rule for the new bundle format.
* feat(persona): .ovsvoice build/parse core + embed_watermark(force=) (#29 slice A)
Extends the merged persona-bundle nucleus (constants, normalize_spdx,
build_manifest, build_consent_json) with the model-coupled core that the
export/import router (next slice) will sit on:
- `build_persona_bundle(profile, *, license_spdx, tags, include_reference,
embed_fn, …)` → assembles the .ovsvoice ZIP in memory: a watermarked
preview.wav (24 kHz mono 16-bit, downmixed + resampled + trimmed ≤8 s),
manifest.json, a legacy-shaped metadata.json (so an older OmniVoice can still
import the ref audio), optional consent.json, and the raw ref/locked/consent
members unless include_reference=False (privacy / preview-only, A12). Raises
NoPreviewSource (router → 503) when no source clip is readable (A2-A5).
- `parse_persona_bundle(bytes)` → validates the ZIP, prefers manifest.json and
falls back to legacy metadata.json, resolves audio members by prefix
(last-wins, B9; member names never build paths — zip-slip safe), normalizes
the SPDX id, flags preview-only / future-schema_version. Raises
BundleError(400|413) for B1-B11. No DB, no file writes.
- `ParsedPersona` dataclass with `extract_member(prefix, dest_path)` — the
router derives dest_path from the server-generated id, never the member name.
- `embed_watermark(..., *, force=False)`: keyword-only flag that bypasses the
user's invisible-watermark preference for the mandatory persona preview, but
still no-ops without AudioSeal. All existing positional call sites are
unchanged (default force=False) — default cross-platform behaviour identical.
All heavy imports (torch/torchaudio/watermark/audio_io) are lazy so the module
stays model-free at collection (avoids the local torch/Triton segfault).
tests/test_persona_bundle.py: +31 cases — parse validation (manifest/legacy
selection, preview-only, future-schema, missing/malformed/no-audio → 400,
oversize → 413, bad-SPDX normalize, last-wins dup, advisory consent), build
round-trip (identity fields, metadata sibling, no-source → NoPreviewSource,
include_reference=False, stereo/off-rate downmix+resample), and the force=
unit (D1/D3). 25 pure cases pass locally; the 6 torchaudio-coupled cases run on
CI (local torch+pytest segfault is pre-existing). CJK guard green.
* feat(persona): /personas export·import·inspect router + wiring (#29 slice B)
Thin HTTP layer over the persona_bundle service (slice A), registered in main.py
next to the legacy marketplace router:
- POST /personas/export/{id} → builds the .ovsvoice off the event loop
(run_in_executor) and streams it (application/zip, .ovsvoice filename;
empty name → persona_<id>). 404 when the profile is missing;
NoPreviewSource → 503 (no readable source audio); any other build error → 503
with a generic message (no raw exception text in the body).
- POST /personas/import → parse (BundleError → its HTTP status), extract audio
members to server-named files ({id}{ext}/{id}_locked{ext}/{id}_consent{ext} —
never the member name, zip-slip safe via profiles._voices_path), 17-column
INSERT (legacy 13 + the 4 consent columns), event_bus emit after commit.
Verified-own-voice is granted ONLY with a real recording ≥ floor AND non-empty
consent_text AND consent.json present (forgery guard, B12-B16). Rollback:
every written file is deleted on any extraction/INSERT failure; id-collision
retries once (renaming the on-disk files to the new id). Accepts legacy
.omnivoice too (case-insensitive extension guard).
- POST /personas/inspect → manifest + consent summary with NO DB row and NO
file extracted (import-preview UI).
backend/tests/test_personas_api.py: 13 cases (config-stub pattern → mounts only
the router, no main/torch import) — export 404; import bad-ext/non-zip/missing-
manifest 400; round-trip row+file under server name; case-insensitive ext;
forgery-unverified; verified-with-recording; short-recording-unverified;
preview-only-as-ref; legacy .omnivoice; inspect no-write + consent summary.
13 passed locally. CJK guard green.
* feat(persona): export/import UI for .ovsvoice bundles (#29 slice C)
Wires the persona endpoints (slice B) into the voice UI:
- api/profiles.ts: exportPersona (blob download, builds the license/tags/
include_reference query), importPersona, inspectPersona; PersonaImportResult
+ PersonaBundleMeta types in types.ts.
- VoiceProfile.jsx: "Export persona" toolbar action + a privacy "Include voice
clip" checkbox (default ON; off → preview-only bundle, no raw reference clip).
Triggers a blob download named <voice>.ovsvoice; distinct toast for the 503
no-audio case vs a generic failure.
- VoiceGallery.jsx (My Imports): an Import-persona button next to Upload, accept
".ovsvoice,.omnivoice", that POSTs to /personas/import and refreshes the
voice list. Surfaces the 413 too-large case distinctly; flags an unverified
import in the success message.
- i18n: voice_profile.persona_* + gallery.persona_*/import_persona keys in
en.json only (fallbackLng=en covers other locales).
Tests: frontend/src/api/profiles.persona.test.ts (7 cases — export query
construction incl. include_reference omitted-when-true, non-ok → throws status,
blob passthrough; import/inspect post FormData to the right path). Full suite
408 passing; en.json valid; CJK guard green. No new tsc errors in the changed
files (pre-existing errors elsewhere are unaffected).
* feat(persona): .ovsvoice build/parse core + embed_watermark(force=) (#29 slice A)
Extends the merged persona-bundle nucleus (constants, normalize_spdx,
build_manifest, build_consent_json) with the model-coupled core that the
export/import router (next slice) will sit on:
- `build_persona_bundle(profile, *, license_spdx, tags, include_reference,
embed_fn, …)` → assembles the .ovsvoice ZIP in memory: a watermarked
preview.wav (24 kHz mono 16-bit, downmixed + resampled + trimmed ≤8 s),
manifest.json, a legacy-shaped metadata.json (so an older OmniVoice can still
import the ref audio), optional consent.json, and the raw ref/locked/consent
members unless include_reference=False (privacy / preview-only, A12). Raises
NoPreviewSource (router → 503) when no source clip is readable (A2-A5).
- `parse_persona_bundle(bytes)` → validates the ZIP, prefers manifest.json and
falls back to legacy metadata.json, resolves audio members by prefix
(last-wins, B9; member names never build paths — zip-slip safe), normalizes
the SPDX id, flags preview-only / future-schema_version. Raises
BundleError(400|413) for B1-B11. No DB, no file writes.
- `ParsedPersona` dataclass with `extract_member(prefix, dest_path)` — the
router derives dest_path from the server-generated id, never the member name.
- `embed_watermark(..., *, force=False)`: keyword-only flag that bypasses the
user's invisible-watermark preference for the mandatory persona preview, but
still no-ops without AudioSeal. All existing positional call sites are
unchanged (default force=False) — default cross-platform behaviour identical.
All heavy imports (torch/torchaudio/watermark/audio_io) are lazy so the module
stays model-free at collection (avoids the local torch/Triton segfault).
tests/test_persona_bundle.py: +31 cases — parse validation (manifest/legacy
selection, preview-only, future-schema, missing/malformed/no-audio → 400,
oversize → 413, bad-SPDX normalize, last-wins dup, advisory consent), build
round-trip (identity fields, metadata sibling, no-source → NoPreviewSource,
include_reference=False, stereo/off-rate downmix+resample), and the force=
unit (D1/D3). 25 pure cases pass locally; the 6 torchaudio-coupled cases run on
CI (local torch+pytest segfault is pre-existing). CJK guard green.
* feat(persona): /personas export·import·inspect router + wiring (#29 slice B)
Thin HTTP layer over the persona_bundle service (slice A), registered in main.py
next to the legacy marketplace router:
- POST /personas/export/{id} → builds the .ovsvoice off the event loop
(run_in_executor) and streams it (application/zip, .ovsvoice filename;
empty name → persona_<id>). 404 when the profile is missing;
NoPreviewSource → 503 (no readable source audio); any other build error → 503
with a generic message (no raw exception text in the body).
- POST /personas/import → parse (BundleError → its HTTP status), extract audio
members to server-named files ({id}{ext}/{id}_locked{ext}/{id}_consent{ext} —
never the member name, zip-slip safe via profiles._voices_path), 17-column
INSERT (legacy 13 + the 4 consent columns), event_bus emit after commit.
Verified-own-voice is granted ONLY with a real recording ≥ floor AND non-empty
consent_text AND consent.json present (forgery guard, B12-B16). Rollback:
every written file is deleted on any extraction/INSERT failure; id-collision
retries once (renaming the on-disk files to the new id). Accepts legacy
.omnivoice too (case-insensitive extension guard).
- POST /personas/inspect → manifest + consent summary with NO DB row and NO
file extracted (import-preview UI).
backend/tests/test_personas_api.py: 13 cases (config-stub pattern → mounts only
the router, no main/torch import) — export 404; import bad-ext/non-zip/missing-
manifest 400; round-trip row+file under server name; case-insensitive ext;
forgery-unverified; verified-with-recording; short-recording-unverified;
preview-only-as-ref; legacy .omnivoice; inspect no-write + consent summary.
13 passed locally. CJK guard green.
Extends the merged persona-bundle nucleus (constants, normalize_spdx,
build_manifest, build_consent_json) with the model-coupled core that the
export/import router (next slice) will sit on:
- `build_persona_bundle(profile, *, license_spdx, tags, include_reference,
embed_fn, …)` → assembles the .ovsvoice ZIP in memory: a watermarked
preview.wav (24 kHz mono 16-bit, downmixed + resampled + trimmed ≤8 s),
manifest.json, a legacy-shaped metadata.json (so an older OmniVoice can still
import the ref audio), optional consent.json, and the raw ref/locked/consent
members unless include_reference=False (privacy / preview-only, A12). Raises
NoPreviewSource (router → 503) when no source clip is readable (A2-A5).
- `parse_persona_bundle(bytes)` → validates the ZIP, prefers manifest.json and
falls back to legacy metadata.json, resolves audio members by prefix
(last-wins, B9; member names never build paths — zip-slip safe), normalizes
the SPDX id, flags preview-only / future-schema_version. Raises
BundleError(400|413) for B1-B11. No DB, no file writes.
- `ParsedPersona` dataclass with `extract_member(prefix, dest_path)` — the
router derives dest_path from the server-generated id, never the member name.
- `embed_watermark(..., *, force=False)`: keyword-only flag that bypasses the
user's invisible-watermark preference for the mandatory persona preview, but
still no-ops without AudioSeal. All existing positional call sites are
unchanged (default force=False) — default cross-platform behaviour identical.
All heavy imports (torch/torchaudio/watermark/audio_io) are lazy so the module
stays model-free at collection (avoids the local torch/Triton segfault).
tests/test_persona_bundle.py: +31 cases — parse validation (manifest/legacy
selection, preview-only, future-schema, missing/malformed/no-audio → 400,
oversize → 413, bad-SPDX normalize, last-wins dup, advisory consent), build
round-trip (identity fields, metadata sibling, no-source → NoPreviewSource,
include_reference=False, stereo/off-rate downmix+resample), and the force=
unit (D1/D3). 25 pure cases pass locally; the 6 torchaudio-coupled cases run on
CI (local torch+pytest segfault is pre-existing). CJK guard green.
The audiobook importer accepted .txt/.md/.epub but not PDF — the single most
common "ebook in" format. Add a pure `pdf_to_chapter_script(data)` that
extracts the text layer page-by-page and runs it through the existing
chapterizer, so PDFs land in the same `# Heading` + body grammar EPUB and
plaintext already produce (one front door onto the unchanged render pipeline).
- Dep: `pypdf>=4.0` — pure-Python, MIT, zero native deps, so PDF import behaves
identically on macOS/Windows/Linux (default-feature cross-platform rule).
EPUB + plaintext stay stdlib-only; only PDF needs a real parser.
- Robustness, surfaced as actionable 400s rather than silent empty imports:
corrupt file, password-protected (empty-password decrypt attempted first),
scanned/image-only (no text layer → clear "scanned PDF" message), and a
page-count ceiling. A single unparseable page is skipped, not fatal.
- Route: `.pdf` branch in audiobook_import; frontend accept filter +
api-client doc updated to `.txt,.md,.epub,.pdf`.
tests/test_longform_import.py: 5 PDF cases (extract+chapterize, no-marker
single chapter, corrupt, image-only, page-cap) using a hand-built in-memory
PDF — no PDF-authoring test dep, mirroring the in-memory-EPUB approach.
16 passed; frontend suite 401; CJK guard green.
The Wave 3.3 QC backend was complete but unreachable from the UI: the
`POST /dub/qc/{job_id}` route (re-recognizes the dubbed audio, scores per-line
drift vs the target text, annotates segments with qc_drift/qc_flagged/
qc_recognized/qc_measured_start-end), the `dubQc()` API client, and the
DubSegmentRow "Verify" badge all existed — but nothing ever called the route,
so the badge never lit and the measured timings were never surfaced.
Add a "Verify dub timing" action to the dub editor header (shown once
dubStep === 'done'):
- Calls `dubQc(jobId, lang)` for the currently-previewed language.
- Merges the returned per-segment scores back onto dubSegments by id, so
flagged lines light their re-listen badge and carry the measured onsets.
- Toast summary: "{flagged} of {total} lines may need a re-listen", or a
clean-pass success when nothing drifted. Loading + error states handled;
non-destructive (generated text untouched).
i18n: dub.qc_btn / qc_running / qc_result / qc_clean / qc_failed in en.json
(fallbackLng=en covers other locales). Frontend suite green (401).
The live-dictation socket (capture_ws) already runs the final transcript
through the configured local LLM (disfluency/self-correction/punctuation
cleanup, Wave 2.1). The REST /transcribe endpoint — the MCP / CLI / file-upload
surface — only did the always-on hallucination-loop collapse, so agentic and
batch callers couldn't get the same cleaned output.
Add an opt-in `refine` form flag that runs the identical `maybe_refine`
pipeline off-thread:
- OFF by default → existing MCP/CLI callers keep raw-only output and pay no
LLM latency (backward-compatible).
- Honours the user's Settings → Dictation-refinement config and silently
passes through when no LLM backend is configured (cross-platform default
parity — identical no-op everywhere with no LLM).
- Raw `text` is always returned; `refined_text` is added only when the LLM
actually changed the text — same contract the socket emits.
tests/test_capture_refine.py: 13 cases — flag-off no-call, refined_text on
change, no-op/identical omission, and flag parsing. maybe_refine is patched at
its source module since the handler imports it lazily.
Replace the two flat markdown templates with validated YAML Issue Forms and a
chooser config, so reports arrive with the diagnostic fields triage actually
needs and "how do I…" traffic routes to chat instead.
- `bug_report.yml` — dup-search + latest-version checkboxes; required
what/repro/expected; OS / install-method / version / compute-device dropdowns
(incl. ROCm + XPU); active-engine; logs (render: text) with the diagnostic-
bundle + `--diagnose` tip up top.
- `install_problem.yml` — NEW, for the "first-run that just works" core value:
a failure-stage dropdown (launch / uv-bootstrap / model-download / engine-
install / first-synth), required error + OS/install/version, and a
network-conditions dropdown (proxy / restricted-region / offline) since
restricted networks are a known bootstrap failure mode.
- `feature_request.yml` — problem/solution/alternatives + an Area dropdown, with
a local-first/cross-platform constraints note so proposals fit.
- `config.yml` — `blank_issues_enabled: false`; contact links to Discord,
Discussions, and the private security policy.
Removes bug_report.md / feature_request.md (superseded). Forms validated (yaml
parse); SECURITY.md backs the security link; CJK guard green.
* feat(longform): two-pass loudnorm measure orchestrator + wiring (#28 slice 2)
Completes accurate ACX/podcast mastering end-to-end (builds on the pure builders
from #28 slice 1).
- `services/loudness.py` — `measure_loudness(ffmpeg, concat, preset, *, job_id)`:
runs ffmpeg's measure pass, parses the loudnorm JSON → MeasuredLoudness.
**Never raises** — skip / non-zero rc / rc None / asyncio.TimeoutError / spawn
OSError / empty or unparseable stderr / silent program all WARN + return None
→ single-pass fallback (a slow/broken measure degrades the master, never
aborts the render). Logs rc + a static message only, never the raw stderr
(path-safe / local-first). UTF-8 decode with replacement (Windows-cp safe).
- `_render_longform_sse` (audiobook.py): between the concat write and the mux,
when `loudness` is a known preset (acx/podcast; same `.lower()`/no-strip gate
as the builders) → emit a `mastering` event, measure, and pass `measured` into
`build_render_cmd` (two-pass apply; `None` → single-pass). `done` gains a
`loudness` block {preset, target_i, target_tp, two_pass, measured_i} ONLY for
a requested preset — off/None paths keep the byte-identical legacy `done`
shape. Both front doors (/audiobook + /longform/render) get it via the shared
generator. Chapter cache key is deliberately untouched (loudness-agnostic →
acx/off reuse the same cached WAVs; no re-render, no cache-layout break).
Tests: `test_loudness.py` (14 — happy fixture, skip-without-spawn for off/
unknown/whitespace/None, non-zero/None rc, timeout-not-propagated, OSError,
empty/unparseable stderr, non-UTF-8 stderr, job_id+argv forwarding) + 2 e2e
cases (mastering event + done.loudness present for acx; absent for off). Orch
tests run locally (stubbed run_ffmpeg, no torch); e2e on CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(loudness): lazy-import run_ffmpeg so the measure stub survives sys.modules purges
test_loudness monkeypatched services.loudness.run_ffmpeg, but the route-shape
fresh_app fixture purges services.* from sys.modules, so under the full-suite
ordering the patch missed the re-imported module → real ffmpeg ran → 3 failures.
Lazy-import run_ffmpeg inside measure_loudness and patch it at its source
(services.ffmpeg_utils.run_ffmpeg) so the stub is always picked up at call time.
Verified by running the purging suite + test_loudness together (31 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>
The model-free nucleus of the portable .ovsvoice persona-bundle format: format
constants, SPDX normalization, and the manifest/consent builders — all pure (no
torch, no I/O), fully locally testable. The audio preview + ZIP pack/unpack +
watermark `force=` param + router + frontend are follow-on slices.
- Constants: OVSVOICE_FORMAT/SCHEMA_VERSION, MAX_BUNDLE_BYTES (100 MB),
DEFAULT_LICENSE (`LicenseRef-OmniVoice-Personal`), the SPDX allowlist.
- `normalize_spdx()` — membership + `LicenseRef-` prefix; junk/None/injection →
DEFAULT_LICENSE, never raises/400s. No regex over the SPDX string (CodeQL-clean).
- `build_manifest()` — mirrors the legacy `_bundle_metadata` persona fields into
the manifest + format discriminator + normalized license + tags + engine /
preview / members blocks. seed/vd_states pass through (None-safe; vd_states is
a JSON string, never re-parsed). `BundleError(status, detail)` for the router.
- `build_consent_json()` — designed-synthetic for `kind='design'`, self-recorded
for an attested clone, None when nothing to attest; `recorded_at` coerced.
Fields are advisory by design — real verification needs the actual consent
audio member, so verified-own-voice can't be forged by editing a manifest.
Tests: 14 cases (SPDX allowlist/prefix/junk/strip, manifest schema + field
mirror + None-passthrough + bad-license-normalized, consent design/clone/none/
coerce). Backend pytest green; CJK guard green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause: uiScale DEFAULTS to 1.3, so the shell's `width: calc(100vw/--ui-scale)`
+ `transform: scale(--ui-scale)` path is active for every user. On WebKitGTK
(the Linux webview) the transform wasn't magnifying the shrunk shell, so
`calc(100vw/1.3)` left ~⅓ of the window black — on EVERY view, by default.
(The earlier #445 fix addressed the responsive breakpoints, not this — wrong
layer.)
Permanent fix: scale via `zoom` and keep the shell at full `100vw × 100vh`
(drop the `calc(…/scale)` shrink + the `transform`):
- Chromium (mac/win): `zoom` magnifies AND fills (standard browser zoom — same
mechanism the bootstrap/wizard wrappers already use).
- WebKitGTK (Linux): `zoom` is a no-op → UI renders at 1.0× but the shell is a
plain 100vw×100vh element → it FILLS, no band. A missed magnification now
degrades to "unscaled but full", never "shrunk + black band".
Regression-proofed: `src/test/appShellScale.test.js` fails CI if anyone
reintroduces `width: calc(100vw/var(--ui-scale))` or
`transform: scale(var(--ui-scale))` on the shell, or drops the zoom/100vw/100vh
contract — so a future change can't silently bring the band back. The fix +
guard are documented inline in the `.app-container` rule.
Full vitest green (398, incl. the 3-case guard); typecheck:ci + vite build clean.
The cold-start health probe added in #439 used a raw fetch() to
/model/status. Raw fetch does not carry the LAN PIN / remote API-key
headers that apiFetch attaches, and /model/status is not in the backend
_SHELL_PATHS allowlist, so it is gated by NetworkAccessMiddleware and
BearerKeyMiddleware. In LAN-share / remote-API mode the probe gets 401,
rejects forever, and the realtime-events WebSocket never opens.
Probe /health instead — the auth-exempt liveness endpoint (in
_SHELL_PATHS) that returns 200 as soon as Uvicorn is up. Using
apiUrl('/health') also avoids a double-slash when the API base has a
trailing slash. Default loopback desktop use is unaffected.
Adds a regression test asserting the probe targets /health (not a gated
path) and only opens the WebSocket after the probe succeeds.
Fixes#450
Co-authored-by: mergetest <hashduch@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Groundwork for accurate ACX mastering: the pure, ffmpeg-free pieces of the
two-pass loudnorm upgrade, layered over the existing single-pass builders
(which stay). The async measure orchestrator + SSE wiring into the render path
is slice 2.
- `MeasuredLoudness` (frozen dataclass: the 5 measure-pass floats).
- `build_loudnorm_measure_filter(preset)` — first pass (+print_format=json);
mirrors build_loudnorm_filter's lookup (no strip) so the same inputs map to
"no filter".
- `parse_loudnorm_measure(stderr)` — extracts the LAST balanced {...} via a
linear brace-depth scan (NO regex → CodeQL-safe), json.loads + coerces the 5
keys to finite floats; returns None on the full failure matrix (absent/empty/
unbalanced/malformed/missing-key/non-numeric/non-finite "-inf"/array/scalar).
Rejecting "-inf" is the silent-clip path → single-pass fallback.
- `build_loudnorm_apply_filter(preset, measured)` — second pass feeding
measured_*/offset back in with linear=true; None for off/unknown OR measured
is None.
- `build_loudnorm_measure_cmd(ffmpeg, concat, filt)` — exact 16-element argv,
input segment byte-identical to build_render_cmd (measured == muxed),
portable `-f null -` sink (no /dev/null or NUL).
- `build_render_cmd` gains `measured: Optional[MeasuredLoudness] = None`: apply
two-pass when present, else single-pass; off-render still emits no -af. The
`measured=None` default keeps every existing caller + argv byte-identical.
Loudness stays opt-in (default None) → default cross-platform behavior unchanged.
Tests: 28 cases — measure-filter goldens + off/unknown/whitespace; parser
success (last-block-wins, ignores extra keys) + full failure matrix +
non-finite rejection; apply-filter golden + None cases; exact measure argv;
build_render_cmd two-pass/single-pass/off branches. Backend pytest green (71).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Groundwork for "import from a past dictation": the shared store reader + the
reusable picker modal, fully unit/RTL-tested. The two-tab wiring (Audiobook
Replace/Append prompt + Stories split-panel routing) is slice 3 — deferred for
visual verification.
Slice 1 — shared reader (`utils/transcriptionsStore.js`):
- `loadTranscriptions()` (parse + Array.isArray guard, [] on
absent/empty/malformed/non-array/blocked-storage) + `TRANSCRIPTIONS_KEY` /
`TRANSCRIPTION_EVENT` consts. Kills the third copy of the localStorage parse.
- Refactored `Transcriptions.jsx` + `Projects.jsx` onto it (behavior-preserving;
the Array.isArray guard is a superset that only hardens against corrupt
blobs). Storage key/shape/200-cap unchanged → no migration.
Slice 2 — `components/TranscriptionPicker.jsx`:
- Controlled modal wrapping the shared `ui/Dialog` (Radix → focus trap, ESC,
backdrop, ARIA inherited). Reads on open, subscribes to the add-event only
while open. Per-row display normalization, hides empty-text rows, distinct
empty vs empty-search states, case-insensitive `String.includes` search (no
RegExp → no ReDoS surface), keyboard-activatable `<button>` rows, Invalid-Date
guard. `onPick` gets the original un-normalized entry. Every string via t().
Tests: util edge matrix (3) + picker RTL (7: empty, list+hide-empty,
click→onPick+onClose, keyboard rows, search filter + empty-search, bad-timestamp
chip omitted, live-refresh on event). Full vitest green; typecheck:ci clean;
CJK guard green (new files i18n-only).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The frontend mounts faster than the Python backend (which takes ~14s to
import torch/fastapi before Uvicorn starts). useRealtimeEvents was
creating a WebSocket immediately, which always failed with code 1006
on the first attempt, triggering an unnecessary exponential-backoff
reconnect.
Fix: poll /model/status via HTTP fetch before creating the WebSocket.
Once the backend responds 200, proceed to open the WS. If the health
check fails, schedule a reconnect using the same backoff — but without
the noisy 'closed (code=1006)' log.
The /model/status endpoint is chosen because it's already polled by the
TanStack Query hooks and always returns 200 once Uvicorn is running,
even before models are loaded.
The app shell is sized `width: calc(100vw / --ui-scale)` then `transform:
scale(--ui-scale)` (the WebKitGTK fix, #407), so its grid lays out against
`100vw / scale`. But the responsive collapse used viewport `@media (max-width)`
queries, which fire on raw `100vw` — so at any `--ui-scale ≠ 1` they trip at the
wrong threshold. In a narrow window the 3-column grid was kept, the sidebar's
`min 180px` crushed the main column toward 0, and the content ended up jammed
into a left sliver with a black band filling the rest.
Fix: drive the breakpoints off the shell's OWN width. A ResizeObserver on the
app-container reads `el.clientWidth` (= the pre-transform layout width =
100vw/scale; transforms don't change the layout box) and toggles `shell-narrow`
(≤1100) / `shell-mini` (≤600) classes; the `@media` queries become equivalent
`.app-container.shell-*` rules. Correct on every engine and at every UI scale.
Observer fires on both window resize and scale change (the calc width changes).
Needs a visual check in the running app at a couple of window sizes + UI scales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The render-faithful interchange between the two long-form editors, as pure,
unit-tested functions (no UI/store yet — that's slice 2). The store seam for
this (convertMode/projectMode) already shipped in #31a.
- `storyToScript(tracks, cast, {projectName})` → `{script, defaultVoice,
metadata}`. Emits **profile-id** `[voice:]` tags (the backend resolver keys on
id, not display name) so the script renders identically through
/longform/render from either door. Most-used effective voice → defaultVoice
(no tag), deterministic earliest-occurrence tie-break; tags emitted only on
voice change; single-# un-indented headings; inline markup ([pause], SSML-lite,
emotion) passes through verbatim — never re-tokenized (no drift vs the backend
parser). Respects the three client/server divergences (heading depth,
[voice:default] semantics, [pause] dialect): it never synthesizes a pause and
never emits [voice:default].
- `scriptToStory(text, profiles)` → `{tracks, cast}` (persisted StoryTrack shape;
cast always ≥ a narrator clone). One physical line = one track; a leading
[voice:id] becomes the track override + a cast member (named from profiles or
the raw id, which is kept as profileId so it round-trips); mid-line markup +
body text preserved byte-for-byte; CRLF normalized; slug-collision-safe cast
ids; sequential numeric ids.
- No new regex over user input (leading-voice detection is string ops) —
CodeQL-clean; render output stays identical across both doors (the invariant).
Tests: 19 cases incl. the edge matrix + **round-trip equivalence** both
directions (script→story→script reproduces; story→script→story preserves spoken
text + voice mapping). Full vitest green; typecheck:ci clean; CJK guard green.
Deferred (slice 2, needs visual verify): the two UI buttons, store prefill
fields, mount read-clear effects, AppMode 'audiobook' fix, i18n.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First call-site migration onto the shared <VoiceSelector> (#22): the Audiobook
default-voice <select> becomes the searchable, grouped picker. Value contract is
unchanged ('' = engine default | profileId), already store-bound (#31b), so no
behavior or data change — just search + clone/designed grouping. `defaultLabel`
preserves the existing "engine default" row label.
Stories cast / per-line track / Dub segment pickers are intricate live layouts
(custom select CSS, row composition) — deferred to follow-up migrations that can
be visually verified, rather than blind-swapped.
vitest green (357); typecheck:ci clean; vite build clean.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audiobook's script, default voice, output format, loudness, book metadata
(title/author/narrator/genre/year/description) and pronunciation lexicon now
bind to the unified store (#31a) instead of component useState — so they
**survive a tab switch / reload** (previously all lost). The headline #31 win.
- text→script, defaultVoice, format→outputFormat, loudness, meta→setProjectMeta,
bound to store selectors. `meta` is default-filled so an empty record never
flips a controlled input to uncontrolled.
- Lexicon rows stay LOCAL (half-typed rows aren't junk-persisted); the filtered
dict flushes to the store on change and hydrates back into rows on mount.
- Transient state (plan, generating, progress, output, chapter previews) stays
component-local — correctly NOT persisted.
Deferred (noted): coverRef persistence (a File/blob can't go to localStorage);
the "Save as named project" affordance + Projects-list card + App `onOpenStory`
mode-aware routing (criterion 4 — re-open from Projects). This slice lands the
working-state persistence (criterion 3); save/reopen is the next slice.
Full frontend vitest green (357); typecheck:ci clean; CJK guard green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single searchable, grouped voice picker to replace the per-tab <select>s
across Stories / Audiobook / Dub. This slice ships the COMPONENT + the two
backward-compatible SearchableSelect extensions it needs; the call-site
migrations are a follow-up slice (component lands first, tested in isolation).
- `SearchableSelect` gains two opt-in, back-compat props (the two existing
call sites are untouched, both render-identically):
- `renderGroupHeaders` (default false) — emits a `.ss-group-label` header on
the first MAIN row of each new `option.group` with a non-empty `groupLabel`
(pinned recent/popular rows never trigger one; empty groups never emit a
stray header).
- `isRecentable` (default `() => true`) — gates which committed values get
recorded as recents.
- `VoiceSelector` builds a group-ordered options array (default → fromVideo →
clone → designed → preset) over the EXISTING value contract
('' | id | preset:<id> | auto:<slug>) — byte-identical to what every call
site already sends, so project data stays compatible. Clone-vs-designed
splits on the runtime `.instruct` string (matching VoicePreview), not
`.kind`. Renders optional preview / gallery-jump / create adornments (the
component owns no audio and makes no API call — it only emits the value and
fires the parent's callbacks). A deleted-but-referenced voice renders a
"Voice not found (re-pick)" ghost row WITHOUT auto-clearing the value.
`isRecentable` excludes '' / preset: / auto: so only real voices are recents.
- i18n keys under `voiceSelector.*` (en.json; other locales fall back via
fallbackLng, matching the project's established pattern).
Tests: 9 RTL cases — grouping/headers, value contract for id/preset/auto,
from-video slug parity, ghost row (no auto-clear), recents guard (sentinels
excluded, real ids kept), preview button presence/value/loading. Full frontend
vitest green (362); typecheck:ci clean; CJK guard green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduces one project concept both long-form editors bind to: Stories
(cast+tracks) and Audiobook (raw script + book metadata), discriminated by a
`projectMode`. Store-only, no UI behavior change — Audiobook is not yet bound
(its inputs still use local state; that's the #31b follow-up). Ships the data
model + migration + the `convertMode` seam #24 will consume.
- `storiesSlice.ts` → `longformSlice.ts`: `StoryProject` → `LongformProject`
(gains mode/script/meta/lexicon/coverRef/outputFormat/loudness/defaultVoice);
new working fields + actions (setScript, setProjectMeta [merge], setLexicon
[replace], setOutputPrefs [merge], setCoverRef, convertMode). `loadProject`
restores the FULL surface default-filled (old records never surface undefined
to a controlled input); `newProject(mode?)` clears it. `SLICE_DEFAULTS` +
`genProjectId` exported (the migrate fn imports genProjectId). Deprecated
aliases (`StoryProject`/`StoriesSlice`/`createStoriesSlice`) re-exported so the
rename breaks no import.
- **Field names kept** (`storyProjects`/`storyTracks`/`cast`) so all 6 consumers
and every existing localStorage blob keep working with zero change — the
persisted KEY is unchanged; only the per-project SHAPE is enriched.
- The project-mode working field is named **`projectMode`**, NOT `mode` — `mode`
is already the app navigation field (uiSlice/AppMode); the spec's `mode` would
collide (TS error + duplicate partialize key). The stored
`LongformProject.mode` (nested) keeps its name.
- persist `version: 4 → 5` + a `version < 5` migrate branch (the localStorage
analog of an alembic upgrade): enriches each saved project with defaults
(spread `...sp` last so id/name/cast/tracks/updatedAt win), drops malformed
entries, never throws. v4 users see the same projects, same names/cast/tracks.
Tests: ported the back-compat suite (Stories unchanged) + new coverage —
default-fill on a v4-shaped record, no-stale-carryover, merge-vs-replace
semantics, convertMode idempotency/guard, snapshot+restore of the new fields.
Full frontend vitest green (357); typecheck:ci clean; CJK guard green (new
slice scanned). No app version-file change (the persist version is the
localStorage schema, not the release).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the last #21 gap: a per-request engine=/model= override bypasses the
/engines/select host-gate, so an engine that can't use this host's GPU could
still be triggered at synth time and silently fall back to CPU (or die mid-
synth). Now enforced at every TTS synth entry point, reusing the SAME probe +
resolver — never re-deriving routing.
Shared helpers (services/engine_routing.py):
- `routing_notice(result)` → (status, reason) to surface, or None. Fires for
cpu_fallback (always) and accelerated-with-caveat (driver/arch); silent for
cpu_only / clean-accelerated / n/a.
- `header_safe_reason(reason)` → scrubbed + ASCII-sanitized (headers are
latin-1; a non-ASCII device name would 500 otherwise) + ≤256 chars. No regex.
Entry points:
- REST `POST /generate` (generation.py): after engine resolution, resolve
routing once; `unavailable` → 400; cpu_fallback / accelerated-caveat → 200 +
`X-OmniVoice-Routing` + `X-OmniVoice-Routing-Reason` headers on the WAV
StreamingResponse; benign → no headers. Covers OmniVoice + adapter branches.
- OpenAI-compat `POST /v1/audio/speech` (openai_compat.py): same gate + same
headers; the tts-1/tts-1-hd alias inherits the active engine's routing.
- WebSocket `/ws/tts` (tts_stream.py): no headers → frames. `unavailable` →
`{"type":"error",...}` + skip stream; cpu_fallback / caveat → one
`{"type":"routing","status","reason"}` frame before any audio.
- `select_engine` response now echoes routing_status / effective_device /
routing_reason (PR #432 added the gate; this adds the fields so the UI can
warn on a cpu_fallback pick). New fields on SelectEngineResponse.
Frontend: `useTTS` reads the X-OmniVoice-Routing header and shows a one-time,
non-blocking toast (in-memory de-dup by status — a 50-clip batch fires once,
no localStorage). i18n keys `tts.routingFallback`/`tts.routingCaveat`.
Tests: routing_notice + header_safe_reason (ASCII/length/scrub) unit tests;
REST synth gate (unavailable→400, cpu_fallback→headers, cpu_only→none) via the
fake-engine harness with a mocked host; select response routing fields.
Deferred (small follow-up): dub-pipeline ASR routing note on the preflight_error
SSE channel — separate path, not a TTS synth entry point. No frontend /ws/tts
client exists today (the routing frame serves external API consumers).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#34 runtime-verify Layer 1 — the cheap regression net over the audiobook /
stories convergence. Drives the REAL `_render_longform_sse` generator + REAL
ffmpeg with a stub CPU-tone synth (no GPU/model), and ffprobes the muxed output.
Covers happy m4b (full SSE sequence + 2 tagged chapters), mp3 container,
per-chapter partial failure (chapter_error isolates ch.0, surviving chapter
still muxes), total failure (error + NO file), empty plan, and the no-ffmpeg
branch. Gated on ffmpeg present (skip otherwise; runs in CI).
Like the other endpoint tests it imports the app+torch stack, so it's validated
on CI (local pytest segfaults on the pre-existing torch/Triton import).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surfaces the /engines routing data (PR 3) in the matrix so users see the
device each engine will actually use on THIS machine.
- The chip matching `effective_device` is highlighted (accent ring + bold),
with a "Runs on X on this machine" tooltip.
- A status-toned routing badge: accelerated→success "GPU active",
cpu_fallback→warn "CPU fallback" (reason in tooltip), cpu_only→neutral
"CPU". The badge is SUPPRESSED for unavailable rows (the availability badge
already says so) and for legacy payloads with no routing_status (renders
exactly as before). An unknown/future status falls back to a neutral
"Unknown" badge.
- LLM rows (routing 'n/a') render a single neutral "Remote" badge instead of
device chips — no false GPU claim.
- types.ts: EngineBackend gains effective_device / routing_status /
routing_reason; GPUTarget gains `xpu`; new EffectiveDevice + RoutingStatus
unions. Corrected the stale "only TTS migrated" comment (all 3 families now
emit the full shape).
- i18n keys in en.json (other locales fall back to en via fallbackLng until
translated — no key-parity gate). xpu chip color in the matrix CSS.
Tests: 5 new RTL cases (accelerated highlight+badge, cpu_fallback badge,
unavailable suppression, legacy no-badge, LLM Remote). Full frontend vitest
green (350); typecheck:ci clean.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surfaces a routing verdict for the CURRENTLY-SELECTED TTS engine in the two
system-health surfaces, so a CPU fallback / unavailable-GPU is heard about
before a slow or failed synth — the no-silent-fallback contract, read-only.
- `tts_backend.active_routing()` + `gpu_routing_verdict()`: the active engine's
routing derived from list_backends() (byte-identical to the matrix) plus the
host compute summary (family + VRAM from the canonical probe). Never raise.
- `/system/diagnose` gains a `gpu_routing` check: accelerated→ok,
accelerated-with-caveat / cpu_fallback→warn (+ actionable hint), cpu_only→ok
(no-GPU host is the expected normal state — never noise-warns), unavailable→
fail, no-engine→warn. ASCII-safe detail strings (the text dump enforces ASCII).
- `/setup/preflight` gains an "Active engine routing" check + an explicit
`gpu_routing` object on PreflightResponse (a real field — the response has no
extra="allow", so it would otherwise be dropped). `device` gains `gpu_family`
(ROCm-vs-CUDA aware) + `vram_gb`. New `GpuRouting` schema.
Tests: gpu_routing_verdict (host + active-engine + degraded), diagnose status
mapping across all 6 states + never-raises, preflight gpu_routing object +
check + device.gpu_family. Existing diagnose/preflight tests stay green (checks
are additive; the report's top-level key set is unchanged).
Deferred (documented): synth-time routing headers/WS-frames at the 3 synth
entry points. Selection is already hard-gated (PR 3 select_engine), and the
matrix (PR 5) + this preflight/diagnose verdict surface the situation — the
synth-time signal is incremental belt-and-suspenders for the env-var-pinned
edge and is best validated interactively. Tracked as a #21 follow-up.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(routing): wire effective-device + routing_status into /engines (#21 PR 3/5)
Surfaces the PR-1 probe + resolver through the engine registries so the
matrix UI (PR 5) and the no-silent-fallback gates can consume it.
- `engine_routing.routing_fields()`: shared helper returning the three
serialization-ready keys, centralizing the scrub rule — routing_reason is
scrubbed via `core.scrub.scrub_text` only when truthy, so a None reason
stays JSON `null` (never coerced to "").
- TTS/ASR `list_backends()` each gain `effective_device` / `routing_status` /
`routing_reason`, computed from a SINGLE `detect_host_caps()` call per
request (host caps are constant per process). ASR is brought to full TTS
parity: it now also carries `install_hint` / `last_error` / `isolation_mode`
and a SCRUBBED `reason` (closing a pre-existing ASR token-leak gap) — an
identical 11-key shape across families. ASR also gains the same
is_available()-raises resilience TTS has (degrade to available:false, never
500).
- LLM `list_backends()` reaches 11-key parity too but emits literal
`effective_device:"network"` / `routing_status:"n/a"` / `routing_reason:null`
(NOT via resolve_routing — LLM runs no local GPU model). `LLMBackend.gpu_compat
= ()`. "network" is a label, not a probe — nothing here touches the network.
- `select_engine` host-routing gate: refuses a pick whose `routing_status` is
`unavailable` on this host (400 with an actionable detail), while ALLOWING
`cpu_fallback` (it runs, just slower). LLM is never gated. Defensive `.get`
so legacy payloads still select. New typed `SelectEngineResponse`.
Tests: 11-key shape across all 3 families, well-formed tts/asr routing keys
(+ None-not-"" contract), LLM network/n/a labels, select gate (block
unavailable / allow cpu_fallback / never-gate LLM). Updated the registry
exact-shape test for the 3 new keys.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(cjk): allowlist docs/specs/ in the hardcoded-CJK guard
PR #429 merged the longform design specs, which legitimately quote functional
CJK (test-fixture descriptions, CosyVoice speaker IDs, multilingual sample
text). The CJK guard scans every tracked file, so those docs turned main red.
Specs are documentation, not shipped UI strings — allowlist the docs/specs/
prefix, matching the individually-allowlisted docs already in the set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds on the device probe from PR 1. Backend-only; the routing keys are
wired into /engines in PR 3.
- #390 closed: MLXAudioBackend / MLXWhisperBackend now call the shared
`core.device_caps.mlx_supported()` gate FIRST, before importing the
package. On Linux/Windows/mac-Intel they report unavailable and never
advertise a usable `mps` route, even with a stray mlx wheel installed.
Replaces the ASR backend's ad-hoc inline MPS check with the one shared
rule. (The Wave-4.4 OSError/RuntimeError import-guard is preserved — it
now lives behind the platform gate; its test forces the gate open so the
guard stays the path under test.)
- `ASRBackend` ABC gains `gpu_compat: tuple[str, ...] = ("cpu",)` mirroring
TTSBackend, and each subclass declares its real targets:
whisperx/faster-whisper → (cuda,cpu); mlx-whisper → (mps,cpu);
pytorch-whisper → (cuda,mps,cpu); nemo/funasr → (cuda,cpu);
moonshine → (cpu,). Inert until PR 3 serializes them.
- IndexTTS2 declares `gpu_compat = ("cuda","cpu")` so it stops advertising
the inherited CPU-only default.
- ROCm is deliberately NOT claimed for any ASR engine (or for IndexTTS2):
CTranslate2 has no upstream HIP build, and an unverified `rocm` claim
would route ROCm hosts to a broken GPU path — strictly worse than the
honest `cpu_fallback` the resolver already emits ("declares CUDA only;
ROCm not in its compat set"). The per-engine TTS ROCm audit is a tracked
follow-up that will verify each path before claiming it.
Tests: MLX gate regression (both backends, on/off Apple), ASR gpu_compat
tuples + no-false-rocm invariant, IndexTTS2 override; existing MLX
import-guard test updated for the new gate ordering.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stories and Audiobook are two authoring frontends over one server-side
renderer (_render_longform_sse), emitting the same chapter-progress events.
Both hand-rolled the identical read/decode/splitSSEBuffer/parseSSELine loop.
Extract utils/longformStream.consumeLongformStream(res, onEvent, {isAborted}):
one place owns the SSE protocol; each editor keeps only its own per-event state
handling (Stories: export %; Audiobook: {current,total,title,assembling,done}).
Behaviour unchanged — Audiobook keeps its abort check via isAborted.
The rest of the two editors stay distinct on purpose (cast/dialogue vs
manuscript/EPUB authoring), per docs/specs/2026-06-13-stories-audiobook-maturity.
Tests: frontend/src/test/longformStream.test.js (chunk-boundary parsing, abort,
no-body). Full vitest: 348 passed; typecheck:ci clean.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dub Projects + History rail (WorkspaceProjects/WorkspaceHistory) used to
sit beside the editor at all times. Now it's a landing: shown only when no
project is being edited (dubStep === 'idle'); opening/creating one switches to
a full-width editor. (The global Sidebar is already hidden in dub mode, so the
studio-right rail is the only surface — no Sidebar change needed.)
Adds project rename:
- backend: PATCH /projects/{id} updates just the name (400 on empty, 404 on
missing) — lighter than PUT which rewrites the whole state blob.
- api: renameProject(id, name); App.jsx renameProject handler (updates the
active-project label + refreshes the list).
- UI: inline rename on each project card (pencil → edit → Enter/Save / Esc).
Verified: PATCH create→rename→list / 400 / 404; frontend typecheck:ci clean.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(routing): canonical host device probe + routing resolver (#21 PR 1/5)
Foundational, backend-only slice of the GPU compatibility matrix (#21).
No API or UI change — wiring lands in PRs 3–5.
- `core/device_caps.py`: single source of truth for host accelerator
capability. `detect_host_caps()` distinguishes ROCm from CUDA (unlike
the gguf hardware_probe), never raises, makes no network call, stays
kernel-free on cold start, and caches per process. Enumerates the full
degradation contract (torch-unimportable→probe_ok=False, CUDA-init
raises, device_count==0, multi-GPU, mem_get_info failure, arch
mismatch, MPS, XPU, DirectML). Plus shared `mlx_supported()` gate
(#390 groundwork) — exact-string platform check, no regex.
- `services/engine_routing.py`: pure `resolve_routing(gpu_compat, caps)`
→ `{effective_device, routing_status, routing_reason}`; deterministic
and byte-identical across OSes. Rules for accelerated / cpu_fallback
(the no-silent-fallback signal) / cpu_only / unavailable, incl. the
ROCm-not-in-set, DirectML-neutral, and XPU edges.
- `get_best_device()` delegates its family decision to the probe so the
loader and probe can never disagree; keeps the ROCm HSA env override
and DirectML device-string return (probe reads, loader writes). String
contract unchanged.
- 39 unit tests (probe / resolver / mlx gate / reason-scrub contract);
no new regex (CodeQL-clean), English-only (CJK guard green).
The gguf hardware_probe rebase is a deliberate follow-up: it has its own
torch-mocked suite and a VRAM-driven quant table unaffected by the family
rename, so it stays out of this zero-risk slice.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(routing): address review — full available_families + empty-except comments
CodeRabbit / CodeQL review on PR 1:
- `available_families` no longer drops secondary accelerators on hybrid hosts
(e.g. NVIDIA + Intel-iGPU-via-IPEX). The probe now detects every accelerator
independently and picks `family` by priority at the end, instead of
short-circuiting after the first hit. Routing is unaffected (it keys off
`family`), but the field is now honest. + hybrid-host test.
- Annotated every `except: pass` in device_caps with an explanatory comment
(CodeQL py/empty-except).
- Removed the unused `_MIN_NVIDIA_DRIVER` constant — the driver-version check
stays in wizard preflight (no subprocess on the probe path); documented why.
- `get_best_device()` now checks MPS before DirectML, mirroring the probe's
family-priority order so loader and probe never disagree.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
One coherent lifecycle surface over the in-process model, diarization, and
subprocess sidecars; fixes the engine-switch VRAM leak; tightens download
robustness. Backend-only, response shapes preserved, no new deps.
Tier 1 — correctness:
- MM2-01: get_active_tts_backend() caches one instance per backend id and
unload()s the outgoing engine on switch (fixes the VRAM leak behind #278);
adds reset_active_backend().
- MM2-02: OmniVoiceBackend.unload() releases the shared model_manager singleton
+ free_vram(); SubprocessBackend.unload() -> unload_sidecar(self.id),
inherited by all sidecar engines. Idempotent + preload-safe.
- MM2-03: /model/loaded ASR row reports the real device + a note explaining the
disabled unload button.
Tier 2 — single surface:
- MM2-04: new services/model_lifecycle.py owns list_loaded/unload/unload_all/
free_vram; system.py routers are thin delegations (shapes unchanged).
- MM2-05: idle timeouts (in-process + sidecar) resolve via prefs.resolve
(env wins, no restart); removed the duplicated _IDLE_TIMEOUT_SECONDS.
Tier 3 — robustness/observability:
- MM2-06: _install_cooldowns swept (1h TTL) + cleared on success — bounded.
- MM2-07: per-extension weight floors (onnx 64KB, tensors 5MB) OR the original
>=5MB catch — small ONNX no longer false-flagged, #352 still caught.
- MM2-08: indextts GPU sidecar self-reports vram_mb in pong; parent surfaces it
in list_live_sidecars (0 = CPU/unmeasured).
- MM2-09: is_cached scan_cache_dir->disk fallback logs WARNING w/ exc type
(#117/#118), was invisible at DEBUG.
Tests: tests/test_mm2_lifecycle.py (15). Full suite: 1379 passed.
Plan/summary: .planning/quick/260613-mm2-clean-model-management-v2/.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(downloads): Xet fast path + accurate progress (FDL W0–W2)
Make model downloads fast and show accurate downloaded/remaining/speed.
Research confirmed hf-xet already implements the IDM/uGet technique
(content-defined chunking, parallel byte-range gets, dedup, resume), and
the spike found all 25 catalog repos are Xet-backed — so the win is
driving Xet well + accurate progress, not a custom downloader.
W1 — maximize + guarantee Xet:
- pin huggingface_hub>=1.7 + hf-xet>=1.1 (was transitive); no hf_transfer
- drive snapshot_download with explicit tqdm_class + max_workers + endpoint
- opt-in HF_XET_HIGH_PERFORMANCE / HDD sequential-write knobs (default off)
- /system/info reports fast_download {xet_enabled, xet_version, high_perf}
W2 — accurate progress:
- dry_run preflight -> install_plan event (exact total/cached/remaining)
- utils/download_aggregator.py: one overall bar; byte bars (by id) vs the
"Fetching N files" count bar; windowed rate; emits one 'aggregate' event
- frontend overall bar (speed/remaining/ETA), cached-skip, ⚡ fast badge
Known limit (verified live): under Xet+hf_hub 1.7.2 per-file byte bars
never advance/close via tqdm, so mid-download the bar is file-granular and
bytes flush to the exact total on completion. Classic-LFS/mirror repos get
true byte progress (W4).
Drive-by: download.py used os.walk without importing os (latent NameError
in _validate_snapshot_has_weights on every install) — fixed.
Tests: tests/backend/setup/test_download_preflight.py (10). Spike + plan
under .planning/quick/260613-fdl-fast-model-downloads/.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(downloads): opt-in mirror + cancel + docs (FDL W4)
- mirror (FDL-10): snapshot_download(endpoint=) honours prefs hf_endpoint /
env HF_ENDPOINT on preflight + download (per-call, no process-wide env).
Documented as the classic-LFS path (no Xet) for restricted networks.
- cancel (FDL-11): POST /models/install/cancel {repo_id} stops further
retries at the next boundary, emits install_cancelled, clears the cooldown
(cancel is intent, not failure). Frontend treats it as a terminator.
- docs (FDL-12): docs/downloading-models.md (Xet fast path, progress
semantics + byte-speed limitation, opt-in tuning, mirror, cancel,
troubleshooting) + README pointer. Docs-sync rule satisfied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(planning): model-management v2 cleanup plan (mm2)
GSD plan for cleaning the model-management subsystem: registry unload-on-
switch + per-engine unload() (fixes VRAM leak), model_lifecycle facade,
unified idle/timeout config, bounded cooldowns, sidecar VRAM self-report,
cache-fallback logging. Planning artifact only — no code.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(downloads): reconcile with main's HF_HUB_DISABLE_XET; honest status
Rebasing onto main surfaced that main forces HF_HUB_DISABLE_XET=1 (classic
LFS) because Xet progress bypasses the tqdm hook — the same limitation found
here. Reconcile instead of fight:
- /system/info fast_download now reports runtime truth: xet_installed +
xet_active (installed AND not HF_HUB_DISABLE_XET) + xet_enabled alias. The
⚡ badge only shows when Xet actually runs; startup log says
"downloads: Xet disabled → legacy LFS".
- complete(): clear the rate window before the final flush so crediting the
full size in one step can't emit an absurd instantaneous rate.
- docs/downloading-models.md rewritten: default is legacy LFS for accurate
progress; Xet is opt-in via HF_HUB_DISABLE_XET=0. hf-xet pin stays (ready
for a future Xet progress hook).
W2 (preflight total/remaining + aggregate bar + exact completion) is the
value on either path; W1's "maximize Xet" is dormant by main's design.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(downloads): opt-in segmented multi-connection accelerator (FDL W3)
Since main forces Xet off (HF_HUB_DISABLE_XET=1), the default path is
single-stream legacy LFS — so a segmented downloader is the way to get BOTH
parallel speed and live byte progress.
- services/segmented_download.py: async multi-connection Range downloader for
one file — parallel byte-ranges, resume (.part + manifest), per-segment
short-read truncation guard, optional sha256/etag verify, cancel, and a
single-stream fallback when the server won't range. Auth-safe: the HF
Authorization header is sent only to huggingface.co/hf.co and never
forwarded to a CDN host on redirect (unit-tested).
- dispatch (download.py): opt-in via prefs segmented_downloader / env
OMNIVOICE_SEGMENTED_DOWNLOAD (default off). When on and Xet inactive,
fetches each file into the HF cache mirroring hf_hub_download (blobs +
snapshot symlinks + refs/main), feeding real bytes to the aggregator. Any
failure falls back to snapshot_download — never breaks a correct install.
- fix: complete() was adding a full total on top of accumulated segmented
bytes (2x); now replaces byte bars so the sum is exactly total.
Verified live (accelerator on): real byte progress to ~16.6 MB/s, final
bytes==total, /models installed=True, delete frees correctly.
Tests: test_segmented_download.py (7) + aggregator double-count regression.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(downloads): relocate FDL tests to top-level; loop-isolate segmented test
CI runs the full suite, which exposed a pre-existing test-isolation leak:
several tests/backend/** fixtures purge core.*/services.* from sys.modules
under a temp OMNIVOICE_DATA_DIR and never restore, leaving core.config/core.db
bound to a dead temp dir. It only bites when collection order puts a purging
test ahead of a real-DB reader (test_longform_jobs). Adding tests under
tests/backend/setup/ reordered collection and tripped it.
Fix without touching the shared (fragile) fixtures or risking class-identity
breakage from a blanket sys.modules restore:
- move the two FDL test files to top-level tests/ (tests/test_fdl_*.py) so
tests/backend/** collection order is identical to main — longform passes.
- rewrite the segmented test to run each case under asyncio.run() (fresh loop)
instead of asyncio.get_event_loop(), which an earlier async test can leave
closed in the full suite.
Full suite green locally: 1364 passed, 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ui): align Audiobook + Stories controls to the design tokens
The hand-written tabs used a bare `.btn` class (which has NO CSS rule → bright
white browser-default buttons) and an unstyled `.field-label`, so the buttons,
labels, and selects looked off-theme. (Other tabs use the Button/ui-btn system,
which is why only these looked wrong.) Found via a design-token audit workflow.
AudiobookTab:
- Import / Preview plan / Add word / Add cover / Download → `ui-btn ui-btn--subtle`;
Create → `ui-btn ui-btn--primary`; cover-remove / lexicon-remove / chapter-play
→ `ui-btn ui-btn--icon` (the app's themed button variants from ui/Button.css).
- AudiobookTab.css: define `.audiobook-tab .field-label` (chrome mono/uppercase
via --chrome-* tokens) + header serif title / muted subtitle (--font-serif,
--text-xl, --color-fg/-muted). Selects/inputs already used `.input-base` (the
canonical chrome look) — left as-is.
StoriesEditor:
- Format `<select>` now uses `.input-base` (canonical chrome select + arrow);
trimmed the bespoke `.stories-editor__format` rule to just the toolbar sizing.
Build clean; 345 frontend tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(longform): cohesion quick-wins — Audiobook launchpad card + Stories in Projects
Make Stories/Audiobook feel wired into the app (integration-map plan, quick-win tier):
- Launchpad: an Audiobook ActionCard (was NavRail-only; Stories already had one).
- Projects/OmniDrive: saved Stories projects now appear as a "Stories" category
(line + voice counts) and open via onOpenStory → loadProject + setMode('stories'),
mirroring onOpenDub. App.jsx reads storyProjects/loadProject from storiesSlice.
- Live profile sync (QW1) confirmed already working: both tabs map the `profiles`
prop in render (no mount snapshot), so a voice cloned/designed/imported anywhere
shows up live in the cast/default pickers — no code needed.
Deferred (no trigger yet): QW4 create-voice handoff to these tabs needs an inline
create/gallery "use here" affordance first (QW3/M3).
Build clean; 345 frontend tests green; en.json valid.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hand-written tabs used a bare `.btn` class (which has NO CSS rule → bright
white browser-default buttons) and an unstyled `.field-label`, so the buttons,
labels, and selects looked off-theme. (Other tabs use the Button/ui-btn system,
which is why only these looked wrong.) Found via a design-token audit workflow.
AudiobookTab:
- Import / Preview plan / Add word / Add cover / Download → `ui-btn ui-btn--subtle`;
Create → `ui-btn ui-btn--primary`; cover-remove / lexicon-remove / chapter-play
→ `ui-btn ui-btn--icon` (the app's themed button variants from ui/Button.css).
- AudiobookTab.css: define `.audiobook-tab .field-label` (chrome mono/uppercase
via --chrome-* tokens) + header serif title / muted subtitle (--font-serif,
--text-xl, --color-fg/-muted). Selects/inputs already used `.input-base` (the
canonical chrome look) — left as-is.
StoriesEditor:
- Format `<select>` now uses `.input-base` (canonical chrome select + arrow);
trimmed the bespoke `.stories-editor__format` rule to just the toolbar sizing.
Build clean; 345 frontend tests green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The content-addressed longform_cache/ accumulated uncompressed chapter WAVs
across every render with no bound (a review finding). Add prune_cache_dir() —
LRU-by-mtime eviction down to a 2 GB ceiling (OMNIVOICE_LONGFORM_CACHE_MAX_GB);
best-effort, never raises. Called at the start of each render job, before its
chapters are written, so the fresh ones are never the eviction target.
Tests: under-cap no-op, evicts-oldest-keeps-newest, missing-dir safe. 38 green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Makes the lexicon backend (#419) and SSML-lite markup (#421) usable from the tab.
- A "Pronunciation" editor in the full-width side pane: add/remove {word → say
it as…} rows, compiled to a lexicon dict sent with both the full render and
per-chapter preview (so previews match the final output).
- A collapsible "Markup reference" listing the script syntax (# chapter,
[voice:], [pause], [slow]/[fast]/[emphasis]/[spell]).
- api/audiobook.ts: lexicon field on the generate + preview bodies.
Build clean; 345 frontend tests green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inline delivery hints within a narration line, wired into BOTH front doors so
Audiobook and Stories behave identically.
- services/ssml_lite.py (parallel-built, 18 tests): parse_ssml_lite splits a
line into {text, speed, spell, emphasis} segments — nesting (innermost wins),
unclosed-to-EOL, stray-close ignored, adjacent-merge; ReDoS-safe literal
alternation. + spell_out().
- _parse_spans (audiobook script path) now applies SSML-lite as the innermost
layer (precedence: [voice:] → [pause] → SSML); each segment becomes a Span
with its speed (threaded to the renderer) and spelled-out text for [spell].
Trailing pause attaches to the run's last segment.
- frontend/src/utils/ssmlLite.js: client port (kept in sync with the .py) +
storyToSpans applies it per chunk — inline speed OVERRIDES the per-line slider,
falls back to it otherwise.
Tests: parse_ssml_lite (18 py + 10 js), script-level prosody parse, Stories
SSML compile (override + spell). 70 backend + 345 frontend green; build clean.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both tabs rendered as narrow centered columns (Audiobook maxWidth:860, Stories
max-width:1040 margin-auto) while the rest of the studio is full-bleed.
- AudiobookTab: rebuilt into a full-height two-pane layout (new AudiobookTab.css)
— header with the action buttons, a left script editor that grows to fill the
window height, and a right settings+results pane (voice/format/loudness, cover
& metadata, progress/output/plan) that scrolls independently. Collapses to one
column under 900px. Removed the inline 860px cap.
- StoriesEditor: dropped the `max-width:1040px; margin-inline:auto` cap → fills
edge-to-edge like the dub/projects/transcripts tabs.
Build clean; 334 frontend tests green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lets a render correct hard-to-say words (e.g. {"GIF":"jiff","Dr":"Doctor"}).
Backend wiring; the editor UI folds into the full-width Audiobook redesign.
- services/pronunciation.py (parallel-built, 19 tests): apply_lexicon —
whole-word, case-insensitive, longest-first, word-boundary, single ReDoS-safe
re.sub pass; + normalize/load/save_lexicon (JSON).
- synthesize_chapter gains a `lexicon` kwarg, applied to each span's text before
chunk splitting (None/empty = no-op → backward compatible).
- _render_chapter_cached folds the normalized lexicon into the chapter cache key
(a lexicon edit re-renders); threaded through _render_longform_sse + the
/audiobook, /audiobook/preview, /longform/render request models.
Tests: synthesize_chapter respells via lexicon; pronunciation module (19);
75 related backend tests green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(longform): correctness + robustness fixes from adversarial review
Fixes the confirmed findings from a multi-agent review of the convergence:
HIGH (correctness/output):
- MP3 + cover produced a corrupt file (-map 2:v -c:v copy is invalid for mp3).
Cover art is now embedded for M4B only; mp3 skips it (m4b is the cover format).
- Chapter cache key omitted ref_text — editing only a profile's ref_text served
stale audio. ref_text is now part of the voice signature.
- Preview wrote audiobook_cache/ but the render reads longform_cache/ (rename
missed in PR 5) → cache-warming silently broke. Unified to longform_cache/.
Robustness (DoS/OOM guards):
- /audiobook/import caps upload at 64 MB; epub_to_chapter_script bounds per-entry
(25 MB) and cumulative (300 MB) uncompressed reads (zip-bomb guard).
- /longform/render rejects > 10,000 chapters (422).
Frontend leaks:
- StoriesEditor.removeTrack revokes the line's preview blob URL.
- AudiobookTab revokes the cover blob URL on replace/unmount.
Deferred fast-follows (also from review): render-cache disk eviction; restoring
the standalone chapter cue-sheet export (needs chapter times in the done event).
Tests: mp3-drops-cover, epub entry/total caps, import + chapter-count limits;
updated the cache-hit test for the 4-field voice sig. 70 backend + 334 frontend green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(longform): pass EPUB caps as params, not monkeypatch (CI import-path fix)
The cap tests monkeypatched module constants, but in the full-suite CI context
the module loads under a different import path so the patch missed the function
(it used the real 300 MB cap → tests failed). epub_to_chapter_script now takes
max_entry_bytes/max_total_bytes kwargs (default to the constants); tests pass
small values directly — deterministic regardless of import 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>
Surfaces finished Audiobook + Story renders so they're re-downloadable from the
Projects view — closing the resume/history loop of the convergence.
Backend (new, no migration — reads existing job_store rows):
- routers/longform_jobs.py: GET /longform/jobs lists finished audiobook/story
jobs newest-first, recovering output/chapters/duration from each job's
persisted 'done' SSE event. Pure build_longform_library() over the job_store
callables; defensive (skips unparseable jobs, never 500s). Registered in main.py.
Frontend:
- Projects.jsx: new "Audiobooks" category fed by /longform/jobs; each row opens
the rendered file (/audio/<output>) with type/chapters/duration. Offline-safe
(empty on fetch failure). en.json keys added.
Built via parallel worktree agent; backend tests/test_longform_jobs.py (9) green;
334 frontend tests + build clean.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR 5 moved Stories' full export to /longform/render but dropped per-line
**speed** — the old client export sent each line's speed to /generate; the
converged path silently ignored it. This restores it end-to-end.
- Span gains an optional `speed`; synthesize_chapter passes it to the injected
synth (signature now `synth(text, voice_id, speed)`); both engine paths
(OmniVoice model + generic TTSBackend) forward it to generate(speed=…).
- chapter_cache_key now includes speed (a speed change re-renders; tuples accept
an optional 4th element so existing 3-tuple callers/tests still work).
- LongformSpan + /longform/render carry speed; storyToSpans emits each line's
speed onto its spans.
Emotion note: per-line tone is already model-native via inline tags
([laughter] etc.) inserted into the text, so no separate emotion→instruct
plumbing is needed — the dead `emotion` store field stays unused/superseded.
Tests: storyToSpans speed passthrough (8); cache-key speed sensitivity; synth
stubs updated for the 3-arg signature. 65 backend + 334 frontend green; build clean.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(stories): full export → shared server-side renderer (PR 5/8)
The convergence core. Stories' full export no longer stitches audio in the
browser (Web Audio, capped by RAM, no resume/loudness/markers) — it compiles
cast + lines into a chapter/span plan and streams through the same chapterized
renderer the Audiobook tab uses.
Backend:
- Extracted the audiobook SSE job into a shared `_render_longform_sse(plan, …)`
generator (resume cache, per-chapter fault isolation, mux). /audiobook is now
a thin caller.
- New POST /longform/render — accepts a pre-built {chapters:[{title,spans:
[{voice_id,text,pause_ms_after}]}]} plan (+ format/loudness/cover/metadata) and
renders it. Pause-only spans (empty text) are kept as silence. job_type=story.
- Shared content-addressed cache renamed longform_cache (one render per unique
chapter across both front doors).
Frontend:
- storyToSpans(tracks, cast) — pure compiler: `# ` lines → chapters; each line
resolves its cast/override voice; inline [voice:]/[pause] split into spans;
pauses fold into the previous span.
- StoriesEditor.generateAll now posts via longformRender and downloads the
server file (chaptered M4B / MP3). Single-line preview stays client-side;
stems export unchanged. Format select WAV→M4B.
Deferred to PR 6 (with the component split): per-line regenerate, emotion→instruct.
Tests: storyToSpans (7) — cast resolution, chapters, per-line + inline voice,
pause folding, empty-drop. 64 backend + 333 frontend green; build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(audiobook): confine cover_path to OUTPUTS_DIR + don't leak exception text (CodeQL)
- _safe_cover_path() restricts the user-supplied cover to OUTPUTS_DIR before it
reaches ffmpeg (py/path-injection).
- SSE error events now emit a generic message and log the detail server-side
(py/stack-trace-exposure); empty best-effort excepts annotated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(audiobook): cover path via basename+fixed dir (clears CodeQL py/path-injection)
CodeQL didn't recognize realpath+startswith as a barrier; os.path.basename is a
recognized sanitizer. Covers only come from /audiobook/cover (OUTPUTS_DIR/
audiobook_covers), so rebuilding from the basename onto that fixed dir is both
CodeQL-clean and strictly tighter — no caller path can escape it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(audiobook): regex-allowlist cover filename (clears CodeQL py/path-injection)
basename alone wasn't a barrier CodeQL credits. Restrict the cover name to the
exact pattern /audiobook/cover emits (12 hex + jpg/jpeg/png) before joining onto
the fixed covers dir — an anchored-regex guard CodeQL recognizes as sanitizing,
and strictly tighter than before.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(audiobook): commonpath-confine resolved cover path (CodeQL py/path-injection)
Add an os.path.realpath + os.path.commonpath containment check on the resolved
cover path (the barrier static analysis recognizes), on top of the regex
allowlist + basename. Defense in depth; the path provably cannot escape
OUTPUTS_DIR/audiobook_covers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The image build+push succeeds, but the "Update Docker Hub description" step
403s (Forbidden) — DOCKERHUB_TOKEN can push yet lacks description-edit scope, a
common limitation of fine-grained Docker Hub tokens. That cosmetic overview
sync was failing the whole Docker (GHCR) run on main.
Mark the step continue-on-error so a creds-scope mismatch no longer reds-out an
otherwise-successful build. To actually sync the overview, the token needs
read/write (incl. description) scope, or use the account password.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Spec PR 4. A front door onto the existing chapter parser: import a file, get a
chapter-delimited script in the editor.
Backend (new services/longform_import.py — pure, stdlib only, no new dep):
- chapterize_plaintext(text): inserts `# ` headings ahead of short standalone
chapter-title lines (Chapter/Part/Prologue/…); no-op if the text already has
H1s; long "Chapter …" sentences stay prose. ReDoS-safe (anchored, per-line).
- epub_to_chapter_script(bytes): parses EPUB (zipfile + ElementTree +
html.parser) in spine order → `# Title` + stripped body per document; skips
empty/nav pages; the heading becomes the chapter title (not narrated). Raises
ValueError on a malformed EPUB. ET.fromstring annotated `# nosec B314` (local
user file, no external-entity expansion).
- POST /audiobook/import (UploadFile) → {text, chapters}.
Frontend: an Import button (.txt/.md/.epub) that fills the script editor.
Tests: tests/test_longform_import.py (9) incl. an in-memory synthetic EPUB
(spine order, empty-doc skip, tag stripping, bad-zip). 64 backend + 326 frontend
green; build clean; en.json valid.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hub.docker.com/r/palashdeb/omnivoice-studio overview was managed by
hand and had gone stale (stuck at the sha-f86beb0 era, missing the tag
table, audiobook/long-form, Supertonic-3, server-mode networking notes).
Add deploy/dockerhub-overview.md as the source of truth and a
peter-evans/dockerhub-description step in docker.yml that pushes it to
Docker Hub on main pushes. Gated identically to the image push: only when
DOCKERHUB_TOKEN is set, so forks / GHCR-only runs are unaffected.
Overview adds the :latest=preview / :stable=release tag semantics (matching
docs/install/docker.md), the current feature set, server-mode + LAN
networking notes, and shields badges. Short description is 98/100 chars.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(audiobook): per-chapter preview + resume + chapter fault-isolation (PR 3/8)
Builds on the shared core (#408) and metadata UI (#409). Chapter-level control,
the spec's PR 3.
Shared core:
- chapter_cache_key(spans, sr, engine_id, voice_sig) — deterministic content
hash of a chapter's audio inputs. Same inputs → reuse; any change (text,
voice, order, pauses, sr, engine, resolved-voice signature) → re-render.
Backend (audiobook router):
- Chapter WAVs are now content-addressed in OUTPUTS_DIR/audiobook_cache. A
re-run after a failure/interruption reuses already-rendered chapters and only
synthesizes the missing/changed ones (resume). Job emits `cached` per chapter
and `cached_chapters`/`failed_chapters` on done.
- Per-chapter fault isolation: a chapter that throws emits `chapter_error` and
the job continues; the m4b assembles from the successful chapters. Re-running
retries only the failed (un-cached) chapters.
- POST /audiobook/preview — render a single chapter to audition it; shares the
same cache so a preview warms the full run and a re-preview is instant.
- _build_synth now exposes resolve + engine_id; _prepare_synth unifies the
omnivoice/generic paths for both the job and preview.
Frontend:
- Plan view: a ▶ preview button per chapter with inline playback.
- Done panel: "reused N chapters" + "N failed — click Create to retry" notes.
Tests: chapter_cache_key determinism + sensitivity (8); preview validation +
cache-hit-skips-synth (3). 55 backend + 326 frontend green; build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(audiobook): mark cache-key SHA1 usedforsecurity=False (bandit B324)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surfaces the shared-render-core capabilities (PR 1, #408) in the Audiobook tab.
Backend:
- POST /audiobook/cover — multipart cover upload (jpg/png, 8 MB cap), returns a
server-side path passed back as cover_path. Unit-tested via the handler
directly (no main+torch import).
Frontend:
- api/audiobook.ts: AudiobookGenerateBody (format/loudness/cover_path/metadata)
+ audiobookUploadCover(file).
- AudiobookTab: format select (M4B/MP3), loudness select (off/ACX/podcast,
default off), and a "Cover & details" panel — cover picker with preview +
title/author/narrator/year/genre/description. On create, the cover uploads
first, then the job runs with metadata + format + loudness.
- en.json: audiobook.* keys for the new controls.
Tests: tests/test_audiobook_cover.py (4) green; frontend vitest 326 green; prod
build clean; CJK + i18n-parity gates pass.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First slice of the Stories+Audiobook convergence (spec:
docs/specs/2026-06-13-stories-audiobook-maturity.md). Both features will compile
to one server-side chapterized renderer; this lands the shared pure builders and
wires them behind Audiobook.
New `backend/services/longform_render.py` (all pure, unit-tested without
ffmpeg/torch):
- build_ffmetadata(chapters, global_meta) — FFMETADATA1 with an optional global
tag block (title/author→artist/narrator→composer/year→date/genre/description→
comment) + chapter table.
- build_loudnorm_filter(preset) — `-af loudnorm` for ACX (~-19 LUFS, -3 dBTP) or
podcast (-16 LUFS); off/unknown → None. Opt-in, so default behavior stays
platform-identical.
- validate_cover_image — jpg/png + 8 MB cap guard.
- build_render_cmd — generalizes the m4b mux: m4b|mp3, optional cover
(attached_pic) + loudness, bitrate validated.
- build_concat_list — moved here.
`services/audiobook.py`: build_chapter_ffmetadata / build_m4b_cmd / build_concat_list
are now backward-compatible wrappers over the core (existing imports + tests
unchanged).
`POST /audiobook`: now accepts optional `format` (m4b|mp3), `loudness`,
`cover_path`, and `metadata` and passes them through — backend-complete; the UI
for these lands in PR 2.
Tests: tests/test_longform_render.py (28) + existing test_audiobook.py (11) green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CSS `zoom` is honoured by Chromium (the macOS/Windows webview) but IGNORED by
WebKitGTK (the Linux webview). The shell sized itself to `100vw/scale` ×
`100vh/scale` expecting `zoom` to magnify it back to full size; on Linux the
magnification never happened, so at the default uiScale of 1.3 the whole app
rendered at 1/1.3 ≈ 77% of the window, leaving black bands on the right and
bottom (a cross-platform default-parity P0 — 1.3 ships out of the box).
Switch to `transform: scale(var(--ui-scale))` + `transform-origin: top left`,
which scales identically on every engine and doesn't alter how vw/vh resolve,
so `declared (100vw/scale) × scale` fills the viewport exactly. Drop the inline
`zoom` (keep setting the `--ui-scale` CSS var the transform reads).
Verified on the real WebKitGTK webview (Tauri debug build, localStorage
uiScale=1.3): shell now fills edge-to-edge — header, content, and logs footer
all reach the window edges; no black bands.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the dynamic engine load/unload slice. The idle reaper (#401) frees
sidecar VRAM after 5 min; this adds a user-initiated "free VRAM now" path so
multi-engine users don't have to wait:
- subprocess_backend: `list_live_sidecars()`, `unload_sidecar(id)`,
`unload_all_sidecars()` via a shared `_force_reap(predicate)` — busy-guarded
exactly like the idle reaper (non-blocking lock; a sidecar mid-synth is
skipped, never interrupted; next request respawns it).
- system.py: `/model/loaded` now surfaces live sidecars as unloadable rows;
`/model/unload/{sidecar:<id>|sidecars}` frees one or all. The existing
generic flush panel picks these up with zero frontend change.
Also refresh CLAUDE.md stale version notes: main is 0.3.6 (latest release
v0.3.5 + 1 patch); the v0.3.0-as-unreleased framing in the project/cadence
notes is corrected to the v0.3.x continuous-to-main reality.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The persona-gallery surface already exists (VoiceGallery Community zone +
community.py manifest + marketplace .omnivoice bundles). The blocker for §R3's
'synthetic-only' gate was data integrity: a *designed* persona lost its
kind='design' (and vd_states) when imported from the community gallery or
round-tripped through a bundle — silently demoting it to a clone.
- community.py /use: a 'preset' (rendered from instruct) imports as
kind='design'; a 'voice' (real reference clip) as 'clone'.
- marketplace.py: extract a pure _bundle_metadata() (dedupes export+publish)
that captures kind + vd_states; import restores them. Old bundles without
the keys import as 'clone' (backward-compatible).
This makes 'accept only designed/synthetic voices' enforceable instead of
everything defaulting to clone. No new persona-gallery feature was built — that
would duplicate the existing community/marketplace surface.
4 torch-free tests (isolated DB): _bundle_metadata captures design + defaults
to clone; import round-trip preserves design kind+vd_states; legacy bundle →
clone. docs §R3 status updated.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Frontend for the audiobook backend (#402/#403): a dedicated Audiobook tab.
- pages/AudiobookTab.jsx: script textarea + default-voice picker (reuses the
app's profiles), 'Preview plan' (POST /audiobook/plan → chapter list) and
'Create' (POST /audiobook → reads the SSE stream, shows per-chapter progress
+ assembling, then an <audio> player + m4b download via the /audio mount).
- api/audiobook.ts: typed plan() + generate() (returns the raw streaming
Response).
- utils/sseParse.js: pure splitSSEBuffer/parseSSELine helpers for reading the
POST event-stream (EventSource is GET-only) — unit-tested (the buffer/line
handling is the easy thing to get subtly wrong).
- NavRail + App.jsx wiring (lazy tab, hideSidebar); i18n keys in en.json.
All strings via i18n (CJK gate green). 7 new SSE tests; full vitest 326 +
vite build green. Runtime-unverifiable here (Tauri webview) — wants an in-app
pass. docs §R3 updated.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Completes the audiobook backend: POST /audiobook renders each chapter through
the active TTS engine (synthesize_chapter + chunked_tts), writes per-chapter
WAVs, then muxes a chapterized m4b (FFMETADATA1 chapters via build_m4b_cmd +
concat demuxer). Progress streams as SSE (started/chapter/assembling/done/
error), recorded to job_store. ffmpeg-gated — emits an error event and stops
when ffmpeg is absent (m4b is the only output).
- services/audiobook.build_concat_list: pure ffmpeg concat-list builder with
proper single-quote escaping (no arg injection). Unit-tested.
- router: voice resolution (compact form of generation.py's locked/design/
clone cases) cached per id; OmniVoice native model path + generic TTSBackend
path; chapter synthesis runs on the GPU pool, ffmpeg via run_ffmpeg.
Reuses the tested building blocks from #402 (parser, synthesize_chapter,
FFMETADATA + m4b argv builders) — the new router glue is thin and
import-checked by CI. Deferred: epub/pdf ingest, ACX loudnorm mastering,
crash-resume, UI. 15 audiobook tests (added concat-list); docs §R3 updated.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(audiobook): chapterized audiobook core + plan preview (Wave 5)
First cut of the long-form vertical (parity §R3). Engine-agnostic core in
services/audiobook.py:
- parse_audiobook_script: pure parser. Markdown '# H1' headings → chapters;
inline [voice:NAME] switches the narrator; [pause …] is delegated to the
shared omnivoice.utils.text.parse_pause_markers so audiobooks and single-shot
synthesis keep one pause dialect. Returns a chapter/span plan.
- synthesize_chapter: orchestration via an injected synth(text, voice) callable
(reuses chunked_tts split + crossfade, stitches inter-span silence) — so it's
unit-testable with a stub backend, no model/GPU.
- build_chapter_ffmetadata + build_m4b_cmd: pure FFMETADATA1 [CHAPTER] builder
and faststart-m4b concat-demux argv (bitrate-validated, no injection).
POST /audiobook/plan returns the parsed plan (no TTS/ffmpeg, no side effects).
Deferred (follow-ups): the streaming synth job + chapterized-m4b run, epub/pdf
ingest (new dep), ACX loudnorm mastering, crash-resume, UI.
14 tests: parser (chapters/voice/pause/intro/empties/to_dict), FFMETADATA
offsets+escaping, m4b argv + bitrate guard, and stub-synth orchestration
(span+silence stitching, voice threading). docs §R3 status updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(audiobook): linear-time regexes (CodeQL ReDoS)
CodeQL flagged polynomial backtracking on user-provided input in three
regexes reachable from the new POST /audiobook/plan endpoint:
- _VOICE_RE: \s*(...)\s* → single [^\]]* class, stripped in code.
- _HEADING_RE: trailing [ \t]* removed; title captured greedily + stripped.
- _PAUSE_RE (omnivoice/utils/text.py): the numeric spec is now an atomic
group (?>…) so its leading \s+ can't backtrack against the trailing \s*.
Behavior-preserving (Python >=3.11 already required); 14 pause tests + 14
audiobook tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(audiobook): require non-space heading title start (CodeQL ReDoS)
The previous _HEADING_RE '[ \t]+(.+)' still let the leading whitespace class
and the title '.+' both match the same tab run (overlap → polynomial). Anchor
the title capture with \S so the two can't overlap. 14 audiobook tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(audiobook): exclude '[' from voice-tag content (CodeQL ReDoS)
[^\]]* still matched '[', so a run of nested [voice: prefixes produced
overlapping finditer match attempts → O(n^2). Excluding both brackets
([^\]\[]) makes matches non-overlapping and linear. A voice name never
contains a bracket. 14 audiobook tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Parity Action 13 (dynamic load/unload), subprocess-engine half. A subprocess
engine's sidecar holds a process — and, for GPU engines, VRAM — for the life
of the backend, even after the user switches engines. The default in-process
OmniVoice model already idle-unloads (model_manager.idle_worker); this gives
the subprocess engine class the same treatment.
subprocess_backend gains a background reaper (lazy daemon thread, started on
first spawn) that shuts down sidecars idle past OMNIVOICE_SIDECAR_IDLE_TIMEOUT_S
(default 300 s; <= 0 disables). The next request transparently respawns one via
the existing dead-process relaunch. Safety: the reaper only acts while holding
the per-backend lock acquired NON-blockingly, so it can never run mid-op — if
an op holds the lock it skips that backend this round. Reuses the idempotent
shutdown() (which doesn't take the lock, so no re-entrancy). Each backend tracks
last-use and registers in a weak live-set.
Scope: subprocess engines only (the heavy, VRAM-holding, process-isolated
class). In-process non-default engines and cross-engine VRAM preemption remain
TODO — get_active_tts_backend returns a fresh instance per call, so those need
an instance-tracking refactor.
6 reaper tests via the stdlib echo sidecar (no torch): kills idle, respawns,
skips busy (lock held), recent-use kept, disabled at <=0, ignores dead. The 3
subprocess suites pass together (24).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Completes Action 8: dictate-over-playback echo cancellation now works
end-to-end, gated behind a new off-by-default 'aecEnabled' pref so the
standard dictation + playback paths are untouched when off.
- utils/aec/{pcm,farEndBus,micCapture,playbackTap}.js + public/aec-worklet.js:
AudioWorklet captures the mic as raw int16 PCM; a player tap routes playback
output through Web Audio to a singleton far-end bus. Pure framing/encode
helpers are unit-tested.
- CaptureWidget: when aecEnabled, opens /ws/transcribe?aec=1, streams tagged
PCM (0x00 mic / 0x01 far-end) instead of MediaRecorder/WebM. Default path
unchanged; no POST fallback in AEC mode (the WS is the sole channel).
- WaveformPlayer: while actually playing AND aecEnabled, taps its decoded
output as the echo reference. Gated on isPlaying so only the one active
player holds an AudioContext (well under the browser cap); audio stays
audible (source always reconnected to destination).
- Settings → Capture: AecPanel toggle. prefsSlice: aecEnabled (persisted).
Runtime-unverifiable here (jsdom has no Web Audio); needs in-app testing in
the Tauri shell. 7 new pure-helper tests; full vitest (319) + vite build green.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(dictation): opt-in NLMS AEC for dictate-over-playback (Wave 8b)
Dictating while OmniVoice plays audio (TTS preview, dub, video) leaks the
loudspeaker signal into the mic, and the streaming ASR transcribes that
bleed. Browser echoCancellation varies per platform/webview — it can't be a
cross-platform default — so this adds a server-side canceller that behaves
identically everywhere.
services/aec.py ports Patter's NlmsEchoCanceller (MIT): a time-domain NLMS
adaptive filter with a Geigel double-talk detector, warm-up step ramp, and
far-end staleness pass-through. /ws/transcribe gains an opt-in '?aec=1[&sr=]'
mode: frames are raw int16 mono PCM tagged with a 1-byte prefix (0x00 mic,
0x01 playback reference); the mic is cleaned against the reference before
buffering, and the cleaned PCM is muxed via stdlib wave (not ffmpeg). Without
the param the protocol and behaviour are byte-for-byte unchanged.
Backend ships dark (no new deps — numpy already pinned); frontend far-end
streaming is a follow-up. Tests cover echo attenuation, double-talk
preservation, cold/stale pass-through, param validation, and the framing
helpers — all pure-numpy/stdlib so they skip the torch ASR stack.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(capture_ws): stubs accept the new pcm_sr kwarg
_transcribe_buffer/_transcribe_buffer_full gained an optional pcm_sr kwarg
for the AEC PCM path; the protocol-test stubs had fixed signatures and
raised TypeError on it, so the handler sent 'error' instead of 'final'.
Accept **kw in the stubs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(asr): crash-isolated faster-whisper subprocess backend (Wave 4.2)
Native ASR engines (faster-whisper / CTranslate2) can segfault on GPU
teardown — a process-level crash that kills the whole backend. Running the
engine in a child process turns that into a failed job: the sidecar dies,
the parent raises a decorated error (engine id + device), and the next
request respawns a fresh sidecar.
- services/subprocess_asr.py: SubprocessASRBackend reuses
SubprocessBackend's wire protocol + lifecycle — including
respawn-on-dead-process (_spawn relaunches when the child isn't alive) and
GPU-slot acquire/release — adding a 'transcribe' op (the TTS 'generate'
surface is stubbed). IsolatedFasterWhisperBackend wraps faster-whisper
using the PARENT venv (already a dep — only the process boundary is new);
opt-in via OMNIVOICE_ASR_BACKEND=faster-whisper-isolated.
- engines/_asr_sidecar/main.py: the faster-whisper runner (stdlib wire
protocol; torch/CT2 import lazily so the ready handshake fits the timeout).
- engines/_echo/main.py: a 'transcribe' echo op so the round-trip + crash
recovery are testable without a real engine.
- asr_backend._REGISTRY is now a lazy dict (mirrors the TTS registry) so the
isolated backend lists/resolves without importing the subprocess stack
unless selected.
Tests (echo sidecar, stdlib-only): round-trip, single long-lived sidecar
across calls, crash-mid-transcribe → decorated error + backend healthy +
next call respawns, registry exposure, generate-not-supported.
Spec 7 / parity program Wave 4.2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(asr): deterministic crash test + drift marker for lazy ASR registry (Wave 4.2 CI)
CI surfaced two issues:
- The echo crash test relied on the crash-AFTER-reply hook, whose reply
may still reach the parent (timing-dependent) — and a leaked
OMNIVOICE_ECHO_CRASH from a sibling subprocess test poisoned the
non-crash tests. Fix: a deterministic OMNIVOICE_ECHO_CRASH_NO_REPLY hook
that exits BEFORE replying (guaranteed dead pipe → decorated error), and
the asr fixture clears both crash envs so the round-trip/two-call tests
can't inherit a leak.
- check-docs-drift's _ASR_MARKER didn't match the new lazy registry line
(_LazyASRRegistry({); updated the marker + the self-test fixture.
Verified the no-reply crash hook by driving the sidecar directly
(reply=None, exit 1); drift self-test + real-repo check green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(asr): allowlist the 'segments' op so transcribe replies aren't dropped (Wave 4.2 CI)
The parent's PARENT_INBOUND_OPS frozenset gated inbound sidecar frames but
never included 'segments' — the ASR transcribe reply op. _recv() dropped the
frame as disallowed, tail-recursed, hit EOF, and returned None, so every
transcribe surfaced as a bogus 'sidecar crashed mid-transcription'. TTS
('audio') was allowlisted; ASR ('segments') was missed. Add it (and list
'transcribe' in the informational SIDECAR_INBOUND_OPS), update the exact-shape
allowlist test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The 2-column sidebar-hidden template still sent the nav rail to
grid-column 3 — it overflowed into an implicit column and the reserved
48px slot rendered as a dead black band beside it. Rail now maps to
column 2 under that combo (and history-panel to column 1 under
rail-right+collapsed). Verified in WebKit: main/footer edges meet the
rail exactly.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- category chips collapse behind an 'Identity' recipe line (male · elderly
· …) that the describe box rewrites live; all-Auto starts expanded
- right rail leads with an ACTIVE VOICE card: name, kind badge, recipe,
identity sample player, + New; empty card carries verbs
- empty saved-voices states point at the action ('Describe one in Voice ←')
- script column stacks naturally (no void before VOICE)
Spec: docs/specs/voice-console-10x.md §1.5, §2.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
P1 (fold): language, steps, and the overrides disclosure move into a
pinned action bar with SYNTHESIZE — the primary CTA is visible at every
window size (verified 1280×720 and 1400×900 in WebKit); Cmd/Ctrl+Enter
synthesizes from anywhere; overrides expand upward above the bar.
P2 (hierarchy/consistency): two kickers only (SCRIPT, VOICE — method
toggle inline); the four redundant headers removed; the old PROMPT preset
chips merge with personalities into one edge-faded scrollable 'Starting
points' lane; the 14-chip tag wall becomes a ⊕ Insert popover at the
script corner (click-outside dismiss).
Spec: docs/specs/voice-console-10x.md §1.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The model manager already lists/deletes cached models; this adds the
remaining high-value slice — an in-app HF mirror setting so users behind
restricted networks (e.g. the Great Firewall) can route downloads through
hf-mirror.com or any HF_ENDPOINT. Persisted to the durable per-user env
(survives Tauri/Finder launches); HF reads HF_ENDPOINT at import, so the
override applies on restart (surfaced in the UI).
- GET/PUT /api/settings/hf-mirror (loopback-gated): presets (official +
hf-mirror.com), http(s) validation, empty clears to official.
- Models-tab panel with quick-picks + free-text field + restart note.
(Skipped 'hf cache verify' — version-fragile across huggingface_hub
releases and low value vs the mirror, which the China/Russia network
research flagged as the real gap.)
3 endpoint tests (default, set+trim+clear, non-http rejection).
Spec §R4(c) / parity program Wave 4.3.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
MLXWhisperBackend / MLXAudioBackend is_available() caught only ImportError.
In a PyInstaller bundle mlx's native dylib/metallib can fail to load even
when the package imports, raising OSError/RuntimeError — which would
propagate and crash the registry scan instead of reporting the backend
unavailable. Broaden to (ImportError, OSError, RuntimeError) so the picker
falls back cleanly. 6 tests across all three exception types.
The capture ASR path already prefers MLX Turbo on Apple Silicon
(get_capture_asr_backend), so this hardening is the remaining slice of
Spec 6 / Wave 4.4.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The old max-height:3em guillotined the third line mid-glyph. Now a true
-webkit-line-clamp with ellipsis, leading [tag] control tokens stripped
from the display (full text stays in the tooltip and restore flows), and
clicking the title toggles the full prompt.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Explain why dedicated-venv engines (IndexTTS2) add disk (a second torch +
CUDA libs: Linux cu128 ~0.83 GiB, Windows ~3.2 GiB), and how uv's link-mode
dedup (clone on macOS/Linux, hardlink on Windows) shares identical wheels
for free — provided UV_CACHE_DIR and the venvs are on the same filesystem.
Key policy: pin the same torch build as the parent whenever the engine
allows, since only identical wheels dedupe; UV_LINK_MODE=hardlink on Linux
ext4. Linked from the IndexTTS engine doc.
Spec §R4(a) / parity program Wave 4.5.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Link the published Docker Hub repo (https://hub.docker.com/r/palashdeb/
omnivoice-studio) as an official image alongside GHCR in the README install
list and docker.md header. Same images, same tags.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Each batch now uses input seeking (-ss before -i, frame-accurate under
re-encode) plus a bounded read (-t window+0.5s), with chunk times shifted
into window-relative coordinates — long-video Smart Fit exports drop from
O(n²) decode cost to O(n).
Fixes#382
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Canonical English added from the t() defaultValues introduced by the
overhaul PRs (#374-#381); 20 parallel translation passes added each key
to every locale (placeholders, product names, and existing per-locale
terminology preserved). All locale files parse; CJK gate + 312 frontend
tests green.
Fixes#383
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- #380: vite:preloadError (old hashed assets after an update) triggers a
one-time reload to pick up the fresh manifest; session flag prevents loops
- #284: check_device_compatibility's warning (e.g. Blackwell sm_120 on a
pre-cu128 torch) now appears in the notification panel as an error with
the pip fix — a log line never reached affected users while synthesis
silently produced noise. Cached once per process.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Live-debugged in Playwright WebKit with a pause() stack hook: the media
'play' event fired twice (a stale WaveSurfer instance's listeners survive
a destroy() that throws mid-teardown under StrictMode double-mount), so
the second claimPlayback stopped the current owner — this very element.
play → instant self-pause → 'click does nothing'.
- 'play' handler only claims when it doesn't already own the slot
- per-instance stale flag inert-izes leaked handlers
- cleanup detaches handlers (unAll) BEFORE destroy so a throwing destroy
can't leak them
Verified in WebKit: paused=false, currentTime advancing, 0 stray pause calls.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- #373: drop will-change:transform on the segment lane (persistent
compositor layer made the semi-transparent boxes vanish during
playback/drag on some Windows GPUs) + raise region alpha 0.30→0.45
- #352: validate a finished snapshot actually contains weights (>5 MB
file) so interrupted downloads fail at install time with a re-download
hint; loader translates the opaque transformers error into the same
guidance
- GET /history prunes rows whose audio file is gone instead of serving
dead 404 players forever
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
With an external `media`, wavesurfer's `url` option only fetches for peak
decoding and never assigns the element's src — so the waveform drew but
play() had nothing to play. Set src on the in-DOM <audio> via JSX (same
pattern as WaveformTimeline) and stop passing `url`. Also surface
playPause() rejections instead of swallowing them.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
One 'studio' navigation mode replaces the clone/design pair; the split
lives on as a 'Define voice' toggle (From audio / By design) at the top
of Voice Source. Selecting a saved profile sets the method from its kind.
- uiSlice: AppMode + 'studio'; defineMethod ('audio'|'design') persisted
- legacy shims: localStorage mode + restoreHistory map clone/design →
studio + method; history mode VALUES unchanged
- NavRail/Header: single Voice entry (Fingerprint, #d3869b)
- CloneDesignTab/WorkspaceVoices/useTTS/useProfiles/Gallery/Launchpad/
Sidebar: definition-method semantics moved off the navigation mode
Build clean; 312/312 tests; tsc clean; no setMode('clone'|'design') left.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(dub): break the dialect↔cinematic guidance loop (#372, #373)
- Cinematic toggle refuses the pick when no LLM endpoint is configured,
pointing at Settings → Credentials → LLM endpoint
- backend Fast fallback now syncs the quality toggle to 'fast'
- the dialect warning no longer fires alongside the cinematic-no-LLM
warning (the pair formed the loop), and both messages point at the
LLM endpoint settings instead of each other
Fixes#372
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ffmpeg): validate resolved ffmpeg/ffprobe actually runs — fall through on WinError 193 (#360, #361, #362)
A corrupt or wrong-arch imageio-ffmpeg download (and WindowsApps alias
stubs) passes os.path.isfile/shutil.which but explodes at spawn with
'[WinError 193] %1 is not a valid Win32 application', killing
transcription with an opaque 500. Every resolution step now probes the
candidate with '-version' (cached per process), logs the rejected
basename, and falls through to the next source.
Fixes#362Fixes#361Fixes#360
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Migration 0005_unified_profiles (0004 taken by mcp bindings):
- voice_profiles.kind TEXT DEFAULT 'clone' ('clone' | 'design'), backfilled
- voice_profiles.vd_states TEXT NULL — JSON of design category picks
- mirrored in _BASE_SCHEMA; idempotent _has_column guards; downgrade drops
POST /profiles:
- ref_audio now optional; kind + vd_states form fields with validation
(clone requires audio; design requires vd_states JSON object + instruct)
- design profiles render a deterministic identity sample (seed 42) through
the shared archetype renderer — one TTS code path
POST /generate:
- profile resolution branches on profile.kind (authoritative) instead of
the brittle is_locked/instruct inference; legacy pre-0005 rows keep the
old inference as fallback; history.mode records profile.kind
Frontend:
- 'Save design as profile' in the Design tab (vd_states + buildDesignInstruct)
- selecting a design profile restores its sliders (vd_states) for re-editing
Also unforks the alembic chain (0004_mcp + my 0004 both revised 0003 →
multiple heads broke alembic upgrade head and the 0003 migration tests).
Tests: tests/test_profile_unification.py — validation, design-create with
mocked renderer, migration up/backfill/downgrade. 18/18 profile tests,
312/312 frontend, related backend suite green.
Note: docs/specs/voice-studio-unification.md (on feat/studio-ux-overhaul)
still says 0004 — renumber to 0005 when branches meet.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(studio): workspace UX overhaul — right-side panels, shared waveform player, dub pipeline UX, setup polish, UI-wide fixes
Voice workspace (specs: docs/specs/voice-studio-unification.md, workspace-connectivity.md):
- Right-side panels replace the left sidebar for clone/design and dub:
WorkspaceVoices (saved profiles), WorkspaceHistory (scoped history with
All/Clone/Design filters), WorkspaceProjects (dub projects)
- Prompt restacked over Voice Source in one definition column (spec §1)
- Gallery "Use voice" now hands off via pendingProfileId and lands in clone
- Shared <WaveformPlayer> (wavesurfer + in-DOM media element for Tauri
WebKit, blob routing via preview endpoint, 404 -> "audio file missing")
replaces every bare <audio controls>; lazy-mounted via IntersectionObserver
Dub:
- Pipeline stepper (Upload -> Prepare -> Transcribe -> Edit -> Generate -> Export)
- Multi-language preview switcher pills (Original + per-track, ElevenLabs-style)
- Batch multi-language generation via langOverride loop
- FloatingPill: bottom-center, suppressed on its homeMode tab (no dup progress)
- Transcript skeleton shimmer (no fake data), progress overlays the video,
exports demoted behind Generate, empty right-panels collapse
Chrome/layout:
- Nav rail is full-window-height; content yields to the logs footer via
padding-bottom; footer joins the rail edge (no overlap at any UI scale)
- UI scale 60–175% slider with zoom-compensated container sizing
- LogsFooter: merged single Logs tab when collapsed, per-source tabs on
expand; Updates chip lives with the logs tabs
- Gallery: three independently scrollable filter lanes, uniform 26px controls
- Font picker as live-preview grid; double-click titlebar maximize fixed
(single mousedown detail-2 handler)
First-run:
- Setup wizard: pinned action row + scrollable content at every window size,
one-line head-ellipsized paths, height budget for short windows, library
rows back to one-line grammar, raw i18n key + duplicate host fixed
Performance/i18n/consistency sweep (10-agent scan, 47 fixes):
- i18n locales lazy-loaded per language (i18n chunk 1.84 MB -> 76 kB)
- Undefined CSS vars replaced with real tokens across 8 stylesheets;
hardcoded hexes tokenized; emoji swept to lucide icons app-wide
- Poll throttling (sysinfo subscription scoped to Header, logs 45s when
collapsed, rAF only during playback), hardcoded strings moved to t()
Build clean; 312/312 tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(studio): re-flow clone/design columns (grid rows collapsed in restack) + strip placeholder emoji across locales
The base .studio-column grid (minmax(0,1fr) rows) collapsed to 0 height
inside the new auto-height definition column, overlapping every panel in
design mode — found via Playwright visual pass. Columns now re-flow as
natural-height flex stacks. Also removed the leftover pencil emoji from
clone.prompt_placeholder in all 21 locales.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(design): compact the design control stack — 2-up facet selects, scrollable tag row, tighter rhythm
English accent + Chinese dialect dropdowns share one row (full-width on
narrow), insertable tag chips collapse from three wrapped rows to one
scrollable line, and describe/personality spacing tightens — the whole
design stack now fits a single viewport.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(spec): unification migration renumbered 0004 — upstream 0003 is voice-profile consent
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ci): clear hardcoded-CJK gate — ASCII '+' in spec wireframes, reword voiceIcons comment
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(spec): migration is 0005 — 0004 taken by mcp bindings upstream
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Push the same images (same tag set: :latest rolling main, :stable/:X.Y.Z
releases, :sha-) to docker.io/palashdeb/omnivoice-studio alongside GHCR.
Gated on DOCKERHUB_USERNAME/DOCKERHUB_TOKEN secrets — without them the
build still publishes to GHCR only. Docs-sync: docker.md mirror note.
Requires repo secrets: DOCKERHUB_USERNAME, DOCKERHUB_TOKEN.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(dub): Smart Fit phase B — per-segment video retime export, drift absorption, fitted subtitles
Executes the video side of the Smart Fit plans persisted by Phase A
(job["fit_plans"], #347) at export and preview time.
Backend:
- services/video_retime.py (new, clean-room): two-tier retime executor.
≤48 chunks → the proven single-pass split/trim/setpts/concat
filter_complex; above → batches of 40 chunks rendered to intermediate
slices (identical libx264 medium/crf20 params, keyframe at t=0) joined
losslessly with the concat demuxer. Slices are CFR-resampled (fps=)
because setpts leaves VFR-ish timestamps that broke tpad and drifted a
frame per retimed chunk on ffmpeg 7.x. Temp slices cleaned on success
AND failure/abort.
- Drift absorption: fitted track longer than retimed video → freeze-frame
tail (tpad=stop_mode=clone) predicted into the last slice / single-pass
graph, with residual mux-side tpad; video longer → silence-pad the dub
audio chain (apad=whole_dur). ±50 ms tolerance.
- VFR guard: probe r_frame_rate vs avg_frame_rate; normalise with fps=
before trim/setpts; probe failure degrades gracefully.
- Plan resolution: _video_retime_plan_for spans legacy video_stretch_plans
(byte-identical resolution + command construction) and fit_plans, gated
on the track's own timing_strategy so stale plans never retime a track
re-generated under another strategy.
- Fitted subtitles: /dub/srt + /dub/vtt accept ?lang= and serve cue times
from fitted_segments for Smart Fit tracks; _write_burn_srt does the
same for burn-in. burn_subs+retime is now allowed for smart_fit (burn
runs AFTER the retime graph); still rejected for legacy stretch_video.
- /dub/preview-video resolves the same plan so in-app preview matches
export.
- Fallback ladder: batch encode failure/timeouts → un-retimed export with
a structured core.failure warning (X-Dub-Export-Warning header +
job["last_export_warning"]); concat join rejection → one single-pass
retry while ≤96 chunks; abort → 409 + proc kill via run_ffmpeg job_id
registration (/dub/abort reaches export encodes now) + temp cleanup.
Frontend:
- Export drawer passes ?lang= on subtitle exports and shows an i18n'd
re-encode cost note (~0.5–2× video length on CPU) when a retiming
strategy is active — translated in all 21 locales.
Tests: tests/test_smart_fit_export.py — plan resolution, batch math,
graph parity + new stages, fitted-cue SRT/VTT/burn selection, burn
policy, VFR detection; ffmpeg-gated integration renders both executor
tiers (batch size forced to 2) and the real /dub/download endpoint,
ffprobing durations within ±50 ms across both pad branches. All existing
dub export/subtitle/preview/timing tests pass unchanged.
Refs docs/competitive-analysis.md Action 1 (dub-length fitting v2);
completes Smart Fit (Phase A = #347).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(security): sanitize Smart Fit retime work paths at every sink (CodeQL py/path-injection)
The job_id-derived retime work path (retimed_*.mp4 / preview_retimed_*.tmp.mp4)
flowed unguarded from dub_export into prepare_smart_fit_video /
render_retimed_video and their derived slice/concat paths and ffmpeg argv.
Apply the repo's proven inline realpath+startswith containment pattern
(helpers/commonpath are not recognized — see #309/#328/#329/#348):
- dub_export.py: validate work_path against DUB_DIR at both construction
sites (export + preview) and pass the validated realpath onward.
- video_retime.py: make both entry points self-defending — realpath +
DUB_DIR containment on out_path/work_path before any derivation, raising
RetimeError(stage="plan") on escape; slices_dir/slice_path/list_path and
RetimeDecision.file_path now all derive from the sanitized value. DUB_DIR
is read via module attribute so test fixtures reloading core.config work.
- ffmpeg_utils.py: document that all caller-assembled argv paths are
realpath-validated upstream.
- tests: sandbox DUB_DIR in the executor integration tests (tmp_path) so
the new guard sees the test workspace.
No behavior change for valid (server-built) paths — the guard only fires
on traversal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(smart-fit): patch DUB_DIR on video_retime's own config ref — survives suite-wide reload
The retime guard reads video_retime._config.DUB_DIR at call time; the
sandbox fixture patched a fresh 'import core.config' instead. Another
test reloads core.config in the full suite, so the two module refs
diverged — the patch missed and the guard rejected the test's tmp paths
(green in isolation, red in CI's full run). Patch the exact ref the
guard dereferences.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dub): resolve DUB_DIR live at call time in retime guards — survive full-suite reload
The path-containment guards bound DUB_DIR via a module-level
'from core import config as _config'. Other tests importlib.reload()
core.config (sandboxing OMNIVOICE_DATA_DIR), after which the guard
checked containment against a stale DUB_DIR while dub_export built the
path under the reloaded one — every retime path then 'escaped the dub
workspace' (green file-alone, red full-suite: the 5 integration
failures CI hit). Re-import DUB_DIR locally in each guard so it always
reads the current sys.modules value; simplify the sandbox fixture to
patch the canonical module. Verified: full backend suite green on the
Smart Fit tests (the 2 remaining settings_store failures are
pre-existing on main, unrelated — local data-dir artifact).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(security): clear CodeQL alerts on Smart Fit export — job_id allowlist, proc-registry decouple
- py/path-injection (8, video_retime.py): validate job_id with a strict
inline regex allowlist (re.fullmatch [A-Za-z0-9_-]{1,64}) at the entry
of dub_download and dub_preview_video, before it reaches any filesystem
path or ffmpeg argv. The existing realpath containment guards stay as
defense-in-depth; the regex barrier is the sanitizer CodeQL recognizes
through the service-module call chain.
- py/log-injection (4): newline-strip job_id inline at the logger calls
in ffmpeg_utils.run_ffmpeg and the two retime-fallback logger.error
sites in dub_export.
- py/empty-except (3): best-effort cleanup os.remove handlers now log
the OSError at debug instead of bare pass (video_retime + both
dub_export mux finally blocks; _discard_tmp too for consistency).
- py/cyclic-import (2): break the dub_pipeline <-> ffmpeg_utils cycle
for real — the subprocess registry (register_proc/unregister_proc/
kill_job_procs/has_active_procs + state) moves to a new stdlib-only
leaf module services/proc_registry.py. ffmpeg_utils now imports it at
module top (no lazy import); dub_pipeline re-exports every name so
dub_core aliases and tests keep working unchanged.
No behavior change for valid inputs; invalid job ids now get a clean
400 instead of a 404/containment error.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dub): address #350 review — cancelled-vs-failed retime, logged best-effort excepts, redacted probe logs, narrowed test assert
- rc<0 (killed by user cancel) now raises RetimeError(stage='aborted')
instead of reporting an ordinary render failure (CodeRabbit)
- best-effort cleanup/QC-event excepts log at debug instead of bare pass
(CodeQL empty-except x3)
- probe failure logs use basename, not full user paths (CodeRabbit/CodeQL)
- test_render_cleans_slices_on_failure asserts RetimeError, not Exception
Rebuttals (no change needed, see PR comment): fitted-cue subtitles track
the fitted AUDIO timeline which is correct even on retime fallback;
the planner only emits stretch ratios >1 so the early-exit guard is a
true no-op check; '\'' is ffmpeg's own utility quoting for concat lists;
has_active_procs is an intentional re-export (noqa'd).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Smart Fit Phase A (planner) + the export-side video retime + audio stretch
already shipped (#347 + dub_export stretch filter). The last piece of
Spec 1 was the subtitle timeline: under stretch_video the dubbed audio
plays at FITTED positions, but the standalone SRT/VTT export still used the
original segment times — so external subtitles drifted against the dubbed
video.
- services/fitted_subtitles.py (pure, tested): map_time_to_fitted() +
fitted_cues() remap original cue times onto the same per-chunk
{orig→new, stretch_ratio} plan the video stretch uses, with a
monotonicity guard.
- dub_export SRT + VTT endpoints: when a job used stretch_video, cues are
regenerated from the plan (subtitles track actual dub placement); no
plan → original times, unchanged. New optional ?lang= selects the track.
7 pure tests (chunk-bound mapping, linear interpolation, unit-rate tail,
fitted cues, monotonicity, empty-plan identity).
Spec 1 (remaining) / parity program Wave 3.1.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
After a dub is generated, re-recognize the synthetic audio and compare what
the ASR heard against what we asked the TTS to say. Lines that drift are
flagged for the user to re-listen / re-dub — turning subtitle timing and
pronunciation from trusted math into measured truth, and doubling as an
automatic dub-quality check.
Design delta from pyvideotrans (which lets recognized text REPLACE the
subtitles wholesale): we keep the generated text authoritative and use the
second pass only for MEASUREMENT — a per-line drift score + measured
start/end that feed the incremental re-dub loop, never silently overwriting
the translation.
- services/dub_qc.py (pure, tested): word_error_rate (normalized token edit
distance, case/punct-insensitive, script-agnostic) + score_dub (matches
recognized segments to dub segments by time overlap, concatenates the
hypothesis, scores drift, derives measured bounds).
- POST /dub/qc/{job_id}: runs the active ASR backend on the dubbed track in
the GPU pool, annotates each segment with qc_drift/qc_flagged/
qc_recognized/qc_measured_start-end (non-destructive — content untouched),
persists, emits a qc_done job event. Opt-in, never fatal.
- Frontend: dubQc() API fn + a red 'Verify' badge on flagged segment rows
(en.json keys; other locales fall back).
12 pure scoring tests (identical/substitution/empty/no-overlap/multi-segment
matching/measured-timing); endpoint validated in CI.
Spec 5 / parity program Wave 3.3.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Cut each long-enough dub segment's clone reference from the isolated vocals
at that segment's own timestamps, so the dub of each line carries the
prosody/emotion of its source line — finer than one reference per speaker.
Reimplemented from the clean-room spec (pyvideotrans per-line ref idea); our
design delta is a quality floor with fallback.
- services/speaker_clone.py: extract_segment_refs() keyed by segment id;
reference transcript is the SOURCE text (text_original), since the vocals
slice is source-language audio. Floor at MIN_SEGMENT_REF_DURATION_S=3.0
(not the per-speaker 5.0, which most dialogue lines fall under) — shorter
lines are omitted and fall back to the per-speaker clone, so it's a strict
improvement, never a regression.
- dub_core: run extraction at transcribe (per_segment_refs query param,
default on), store job['segment_clones'], default each unassigned
segment's profile_id to 'auto-seg:{id}' when it has its own ref, else the
existing 'auto:{speaker}'. Forcing per-speaker (per_segment_refs=false)
is supported for long-form consistency.
- dub_generate _gen: resolve 'auto-seg:' from segment_clones, ahead of the
per-speaker 'auto:' path. profile_id is already a fingerprint field, so
flipping the mode re-dubs automatically (no _GEN_INPUT_FIELDS change).
7 pure tests over a synthetic vocals wav (own-ref for long lines,
short-line omission/fallback, source-text transcript, bounds clamping,
floor boundary). Pipeline wiring validated in CI.
Spec 4 / parity program Wave 3.2.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(mcp): MCP server v1 — mount on /mcp, per-agent voice binding, stdio shim (Wave 2.2)
The FastMCP server (previously dead code, never mounted) is now mounted on
the main FastAPI app at /mcp via Streamable HTTP, with its session manager
composed into the app lifespan through an AsyncExitStack (best-effort: a
missing mcp package or OMNIVOICE_MCP_DISABLE=1 never breaks startup).
streamable_http_path set to '/' so the sub-mount lands at /mcp, not
/mcp/mcp. Adds the 'mcp' dependency (1.27.x).
Per-agent voice binding (Spec 2 headline): each MCP client sends an
X-OmniVoice-Client-Id header; generate_speech resolves the voice as
explicit arg > the client's binding > global default > app default. New
mcp_client_bindings table (alembic 0004 + _BASE_SCHEMA, additive/idempotent),
services/mcp_bindings.py (CRUD + resolve_voice + best-effort last_seen),
and a loopback-gated REST router (/api/mcp/bindings) the Settings panel
drives.
New transcribe tool (base64 audio in, 200 MB cap). Stdio shim
(backend/mcp_shim, httpx-only, ported from voicebox MIT) proxies stdio
clients to the mounted endpoint and forwards OMNIVOICE_CLIENT_ID as the
binding header. Settings → Sharing gains an MCP bindings panel. Docs:
docs/mcp.md (both connection modes + binding REST) and docs/mcp.json
updated to the shim form.
Tests: bindings service + resolution precedence + migration up/down (pure,
run locally); REST CRUD + mount-not-404 + disable-flag (main-importing,
validated in CI). MCP build + mount + initialize handshake verified
out-of-band (no torch).
Spec: docs/competitive-analysis.md Spec 2 / parity program Wave 2.2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(mcp): assert /mcp mount via app.routes, not a lifespan client
The two main-importing mount tests ran the app lifespan, which now starts
the FastMCP session manager and binds asyncio queues to the test loop —
contaminating later lifespan-running tests ('bound to a different event
loop'). The mount happens at import time, so inspecting app.routes for the
/mcp Mount is the correct loop-free assertion. Same fix shape as the
Wave 0.2 consent tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(mcp): stop reload-main poisoning across the MCP test files
Root cause of the CI failure: the bindings REST fixture set
OMNIVOICE_MCP_DISABLE=1 and reloaded main but never restored it, so a
later 'from main import app' in test_mcp_mount saw /mcp un-mounted
({'/audio','/voice_audio'}). Reloading main mutates the shared module for
every subsequent test.
- REST fixture: drop the disable flag (the mount is harmless without a
lifespan), yield the client, and restore main (+ core.config/db) to the
default data dir in teardown so the global module is clean again.
- test_main_mounts_mcp_route: reload main with the disable flag cleared so
the assertion is independent of any earlier reload.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
A '>>>>>>>' marker from the #365 rebase was committed at the tail of the
LLM-endpoint block, making the module unparseable. Strip it; settings.py
parses clean and the endpoint tests pass.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
A focused Settings panel for the OpenAI-compatible LLM that powers
cinematic translate, glossary auto-extract, and dictation refinement
(Wave 2.1). Persistence reuses the existing TRANSLATE_BASE_URL /
TRANSLATE_MODEL / TRANSLATE_API_KEY env vars (already in system.py
PERSISTENT_KEYS, restored at startup), so llm_backend/translator
resolution is unchanged — vLLM is a verified drop-in, Ollama ignores the
key, vLLM/LM Studio require it.
- GET/PUT /api/settings/llm-endpoint (loopback-gated): read shape returns
base_url, model, masked key, and live availability; PUT treats a null
field as unchanged and an empty string as clear (so the key isn't wiped
by a base-url-only save). Key is masked to last-4 in the read path,
never echoed.
- Credentials-tab panel with one-click presets (Ollama/LM Studio/vLLM/
OpenAI), base URL + model + optional key fields, and a reachable/not
status badge.
6 endpoint tests (read shape, set+mask, null-unchanged, empty-clears,
local-url-no-key, short-key masking); availability assertions guarded on
openai being installed.
Spec: parity program Wave 2.4 / competitive-analysis §R2 rung 4.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Agentic v1: OmniVoice is a provider, not the orchestrator. Its existing
OpenAI-compatible API already serves everything pipecat/LiveKit need
(POST /v1/audio/speech with pcm/wav, voice-profile id, speed; default
24 kHz output matching pipecat's OpenAITTSService) — so this is docs + an
example + a contract test, no new endpoint.
- docs/agentic-voice.md: the provider recipe for pipecat (base_url to
:3900/v1) and LiveKit, the remote-backend note (bearer from 2.3), the
consent-locked-voice nudge (0.2), and an explicit telephony-is-deferred
scope box.
- examples/agentic/pipecat_minimal.py: lazy-import skeleton wiring the
OmniVoice STT/TTS services (importable without pipecat installed).
- tests/test_agentic_provider_contract.py: pins the /v1/audio/speech
request shape pipecat sends (pcm + wav formats, voice-profile passthrough,
speed) so the documented recipe can't silently break. Validated in CI.
Spec: Action 15 / §R1 v1 / parity program Wave 2.5.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Run inference on a remote GPU box, drive it from the desktop app — opt-in,
off by default (loopback-only is unchanged when no key is set).
Backend:
- BearerKeyMiddleware (main.py): when OMNIVOICE_API_KEY is set, every
non-loopback HTTP + WebSocket request must present it (Authorization:
Bearer, ?api_key=, or the ov_key cookie set on first auth). Pure ASGI
(no response buffering), loopback always bypasses, SPA shell stays
reachable. Constant-time compare, never logged.
- ws_remote_authorized() in dependencies; capture_ws lets a keyed
non-loopback client through its inline loopback guard (the thin-client
dictation case: mic local, GPU remote).
Frontend:
- api/client.ts: ov_backend_url (localStorage) is the top-precedence base
override; new wsUrl() derives ws scheme + host from the API base (not
window.location, which lies in the Tauri webview) and appends ?api_key.
apiFetch attaches the bearer header. Both WS call sites (dictation,
events) routed through wsUrl; the HTTP transcribe fallback through
apiFetch.
- Settings > Sharing > Remote backend panel: URL + key fields, a
test-connection probe against {url}/health, save-and-reload.
Docs: docs/remote-gpu.md — the Tailscale recipe (MagicDNS + Serve, never
Funnel, headscale note, plain-HTTP-is-sniffable warning, PIN-vs-key split).
Tests: 10 bearer-middleware cases (inert without env, loopback bypass,
401 without/pass with key via header+query, wrong key, shell exemption,
plain-ASGI guard, WS handshake reject/accept). Validated in CI.
Spec: parity program Wave 2.3 / competitive-analysis §R2 rungs 1-3.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Phase 2 of Spec 3, on top of Wave 1.1's deterministic collapse. Prompt
design ported from voicebox (MIT): 'text filter, not an assistant' base
instruction + three toggleable sections (smart_cleanup, self_correction,
preserve_technical) + 7 few-shot examples passed as STRUCTURED chat turns
(small local models echo inline examples). Runs through the user's own
Ollama/LM Studio/OpenAI-compat endpoint via llm_backend — new additive
chat_messages() on the adapter; chat() now delegates to it.
Pass-through is the contract: with no LLM configured (backend 'off'),
on any error/timeout, or on an empty reply, the raw transcript stands —
identical default behavior on every platform. Refinement runs off-thread
on FINALS only; the WS final dict gains optional refined_text and the
dictation pill pastes refined_text ?? text (raw kept in history).
Settings: GET/PUT /api/settings/dictation-refinement (loopback-gated,
persisted in the settings table) + a Capture-tab panel with the master
switch + per-flag toggles and a 'no LLM configured' hint.
15 new unit tests: prompt sections per flag, structured few-shot message
shape, and the full maybe_refine pass-through matrix (off backend,
disabled config, LLM failure, empty reply, empty input).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
pyvideotrans drives OmniVoice as a per-line clone backend (their
videotrans/tts/_omnivoice.py — being replaced upstream with a REST
integration against POST /generate). This contract suite pins the exact
multipart shape that integration sends (text + uploaded ref_audio +
ref_text + language name + num_step/guidance_scale/speed/denoise/
postprocess flags -> audio/wav with X-Audio-Duration) so a /generate
change that would silently break the 17.9k-star upstream fails our CI —
the engine-compat constraint extended to an external consumer.
Engine stubbed; validated in CI (local torch/Triton segfault on
main-importing tests, see project memory).
Spec 11 / parity program Wave 1.3 (our-repo half).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Ports Patter's SentenceChunker (MIT, attribution header) behavior-identical
— all 61 upstream golden parity scenarios ship as fixtures and pass,
including documented quirks (current_behavior xfail semantics mirrored from
their parity runner). Terminator tables carry functional CJK; file added to
the test_no_hardcoded_cjk allowlist per convention.
/ws/tts now splits the request into sentences and synthesizes each in turn,
streaming the first sentence's PCM while later sentences are still
generating — the time-to-first-audio win on multi-sentence input.
Single-sentence requests behave exactly like the old single-shot path;
'start' metadata still waits for the first generation so lazy-loading
engines report their true sample rate. Italian comma-decimal guard
hard-disables aggressive first-clause flush per upstream.
Spec 8a (docs/competitive-analysis.md) / parity program Wave 1.4.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Ports voicebox's chunked TTS (MIT, attribution header) with two deliberate
changes: the concat half is reworked for torch tensors (matching what our
inference helpers feed the effect chain, incl. multi-channel on the last
axis), and the sample rate comes from the engine's declared rate instead
of the first chunk (fixes a latent upstream bug).
Long text (> max_chunk_chars, default 800) splits at sentence boundaries
(abbreviation/decimal-aware, bracket tags atomic, fullwidth enders via
unicode escapes for the CJK gate) -> per-chunk generation with
deterministic seed variation (seed+i) -> linear crossfade join (default
50 ms, 0 = hard cut) -> effect chain + watermark once on the joined audio.
Wired into BOTH inference paths (OmniVoice-native _run_inference and the
engine-adapter _run_backend_inference) beside the existing [pause]
stitcher; [pause] inputs keep their dedicated path. Short text is
byte-for-byte the old single-shot path; max_chunk_chars=0 disables.
New /generate form params: max_chunk_chars (>=0, default 800),
crossfade_ms (0-1000, default 50).
Tests: 15 model-free unit tests (split priorities, abbreviation/decimal/
tag guards, crossfade math incl. multichannel + clamping) + 3 stubbed-
engine endpoint tests (long text fans out with no words lost, short text
single-shot, 0 disables). Endpoint tests validated in CI — this machine
has a pre-existing local torch/Triton segfault on any main-importing test.
Spec: voicebox deep dive 1 / parity program Wave 1.2 / #346
unlimited-length item.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Deterministic pre-pass ported from voicebox (MIT, attribution header):
word-level (token repeated >=6x, punctuation-normalized) + character-level
(2-60-char unit repeated >=6x, catches multi-word and no-space-script
loops). Rhetorical repeats below 6 survive; no LLM involved; identical on
every platform. Applied to the FINAL text in /ws/transcribe and POST
/transcribe — segments keep raw recognition so timings stay truthful.
Phase 1 of Spec 3 (docs/competitive-analysis.md); the optional local-LLM
refinement pass (phase 2) lands with parity program Wave 2.1 in the same
module.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(profiles): consent-locked voice profiles — verified_own_voice + spoken consent flow (Wave 0.2)
A profile becomes 'verified own voice' when its owner records themselves
reading a consent statement (spoken attestation, not a checkbox). Agentic
features and gallery sharing will gate on the flag; plain local synthesis
never does.
- alembic 0003 (additive, PRAGMA-guarded, downgrade supported) +
_BASE_SCHEMA columns: verified_own_voice, consent_text,
consent_audio_path, consent_recorded_at
- POST/DELETE /profiles/{id}/consent — stores the recording as provenance
in VOICES_DIR ({id}_consent.*), replaces on re-record, cleans up on
revoke and on profile delete; 422 on empty statement / too-short audio
- VoiceProfile page: Verified badge + Voice ownership panel (record via
the existing useRecording denoise flow, revoke with confirm); en.json
keys only (other locales fall back per the advisory i18n parity policy)
Spec: docs/competitive-analysis.md Action 22 / parity program Wave 0.2.
Prerequisite for agentic v2/v3 and the persona gallery.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(profiles): harden consent paths against py/path-injection; drop lifespan in tests
- _voices_path(): resolve DB-stored filenames strictly inside VOICES_DIR
(bare-filename check + realpath containment); extension whitelist on the
uploaded consent filename (fallback .wav) so a crafted filename can never
steer the on-disk path. Applied to write, re-record cleanup, revoke, and
profile-delete cleanup. New test: malicious upload filename falls back.
- Test fixture no longer runs the app lifespan: startup/shutdown touched
module-level asyncio primitives bound to another module's event loop,
making the suite order-dependent in full-suite CI. init_db() is called
directly; endpoints under test need only the schema.
Fixes the CodeQL (3x py/path-injection high) and full-suite event-loop
failures on PR #354.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Ports Patter's eval harness (MIT, attribution headers) into tests/evals/
with the judge transport swapped to services/llm_backend.py — the judge
runs against whatever local Ollama/LM Studio/OpenAI-compat endpoint the
user configured, keeping local-first. Both Patter hardening details kept
verbatim: verdict recomputed locally from the score (hallucinated
'passed: true' at score 0.2 fails), and tolerant JSON parsing (fences
stripped, invalid JSON -> fail-with-reasoning). Per-case containment:
agent exceptions keep the partial transcript and still judge it; a judge
failure records score 0 instead of aborting the suite.
HARD RULE preserved: LLM judges never gate CI. The scheduled workflow
(weekly + dispatch) is continue-on-error with the JSON report as artifact;
run_evals.py exits 0 always and skips cleanly when the active LLM backend
is 'off'. Deterministic probe judges remain the only gates; the harness
unit tests (10, no LLM needed) do run in gating CI.
First suite: dub translation naturalness v1 (4 cases) driving the real
cinematic_refine_sync reflect+adapt chain. The telephony-specific
session/assertions layers were deliberately not ported. The
dictation-refinement suite lands with Wave 1.1/2.1.
Spec: docs/competitive-analysis.md Spec 9b / parity program Wave 0.3.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
docs/features.yaml is the curated single source of truth (12 features,
11 TTS + 7 ASR engine ids, required install docs). scripts/check-docs-drift.py
diffs it against README.md, docs/, and the engine registries — parsing
registry keys from source so the CI runner never imports torch. The daily
workflow updates ONE rolling 'docs-drift' issue in place and auto-closes it
when clean (pattern adapted from Patter, MIT). Self-test includes a
real-repo-is-clean gate, so any PR that changes engines/features without
updating the inventory fails CI too.
Spec: docs/competitive-analysis.md Spec 9a / parity program Wave 0.1.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Turns the discussion #346 roadmap and the competitive-analysis research (#345)
into an executable program of small PRs: 6 waves, dependency-aware, each item
citing its Spec/§R section with effort and acceptance criteria. Accounts for
Smart Fit Phase A (#347), the timeline editor (#348), and Scalar (#307) having
already shipped. Telephony explicitly deferred behind guardrails + two spikes.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(dub): full-track speech-onset detection + GET /dub/onsets/{job_id} (#280)
detect_speech_onsets() lists every speech rise across the track (frame RMS,
adaptive threshold, 150ms hysteresis) — powers the timeline editor's
snap-to-onset ticks. Route prefers the Demucs vocals stem, falls back to the
mix, and caches onsets.json per job (mtime-invalidated).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(dub): timeline editor math core — windowing, snap, clamp, fingerprint-safe commit (#280)
Pure helpers for the segment track: binary-search windowing, snapTime with
deterministic ties, neighbour/min-duration clamps with Alt-overlap (<=200ms),
commitMoveResize with fingerprint parity (move touches only start/end; resize
sets speed exactly like the old Regions handler and DELETES the key at 1.0 so
_canon_value's missing-vs-1.0 hashing can't mark untouched segments stale),
and overlap detection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(dub): SegmentTrack editing lane replaces the Regions plugin (#280)
Custom DOM segment boxes (6px edge handles, body-drag move, speaker colors,
stale/fresh tint, hatched overlap warning) virtualized by time over a single
{pxPerSec, scrollLeft} alignment source read off WaveSurfer's wrapper.
Snap-to-onset ticks on a viewport-sized canvas light up in snap range;
Ctrl/Cmd-wheel zooms centered on the cursor; double-click plays the slot via
playRange (timeupdate watcher pauses at slot end). Roving-tabindex listbox
keyboard model (arrows / Enter / Shift / Alt / Delete / S) with polite
aria-live announcements. WebKit fallback keeps a self-scrolling lane at a
fixed px/sec. timeline.* strings translated in all 21 locales.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(dub): wire timeline editor — per-gesture undo, id fix, table selection sync (#280)
segmentMoveResize() pushes undo ONCE per gesture (drag commits on pointerup;
keyboard nudges coalesce per focus session) and matches by String(id) — the
old parseInt('seg-3_a') path edited the wrong segment after a split. Commits
go through commitMoveResize for fingerprint parity, and the existing
recomputeIncremental effect picks up every commit. Clicking a timeline box
scrolls + highlights its row in DubSegmentTable; 'preview dub here' parks
the player at the slot start, then synthesizes the line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dub): inline the onsets-cache containment guard — CodeQL can't track helpers
Same lesson as #328/#329: the realpath+startswith sanitizer must sit at
the sink, not behind a function return. Unused helper removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(dub): Smart Fit planner, fit fingerprints, shared ffmpeg stretch helpers
- services/fit_planner.py: pure, I/O-free planner for dub-length fitting
v2 — slack absorption (gap guard), audio-only band (<=1.2x), geometric
50/50 audio/video split capped at 1.5x / 2.0x, residual overflow
accounting, and a stretch_video-compatible video_plan + fitted timeline
cursor. Clean-room reimplementation from a published description.
- services/incremental.py: fit_fingerprint() over the fit params with the
same _canon_value canonicalisation as segment hashes (#281 class).
Fit params stay OUT of segment_fingerprint — a fit change re-mixes,
never re-TTSes.
- services/ffmpeg_utils.py: move _atempo_chain/_pitch_preserving_stretch
out of the dub_generate router (lazy torch/numpy imports) so the Phase B
export pipeline can reuse them; add probe_duration() ffprobe helper.
- schemas/requests.py: timing_strategy gains "smart_fit"; optional
fit_options knob overrides default server-side.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(dub): smart_fit branch in the generate path
TTS loop unchanged (dur_s=None, natural-rate WAVs on disk). After the
loop, plan_fit() decides per segment; the mix loop applies audio_rate via
the pitch-preserving atempo pipe (linear-interp fallback), trims residual
overflow with the existing fades, and places audio at the planned
new_start on a fitted-length canvas. Truthful fit_status entries
(audio_rate / video_ratio / overflow_s) feed the row badges.
Persists job["fit_plans"][lang] = {plan (exact
_build_video_stretch_filter_graph shape), fitted_segments (cue times from
ACTUAL stretched sample positions), total/orig duration, params, fit_fp}
and mirrors fit_fp on dubbed_tracks[lang]. video_stretch_plans untouched.
Strategy-transition guard: job["seg_wav_kind"] records whether on-disk
seg WAVs are natural or slot-squeezed; a smart_fit partial regen over
slotted (or unknown) WAVs forces one full regen instead of
double-compressing. Old strategies and old persisted jobs are
byte-identical (all new reads via .get()).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(ui): Smart Fit option in the dub timing picker (all 21 locales)
- prefsSlice: TimingStrategy union gains 'smart_fit'; optional FitOptions
overrides (null by default — backend defaults apply identically on
every platform); persisted alongside timingStrategy.
- DubTab: Segmented gains Smart Fit with i18n label + tooltip.
- useDubWorkflow: sends fit_options only when set and strategy is
smart_fit. Default strategy stays 'concise' — no default behaviour
change on any platform.
- locales: dub.timing_smart_fit{,_title} translated in all 21 languages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(dub): fit planner unit + golden suites, smart_fit generate-path integration
- test_fit_planner.py: threshold boundaries (0.9/1.0/1.2/1.21/4.0), cap
saturation -> overflow, slack absorption incl. gap guard, last-segment
tail, cursor monotonicity, allow_video_retime=False, video_plan fed
straight into _build_video_stretch_filter_graph, fit_fingerprint
canonicalisation (int vs float, omitted vs default — the #281 class)
and a pinned stable digest.
- tests/fixtures/fit_planner/*.json: 4 golden FitPlans; algorithm drift
is a deliberate fixture diff, never a silent change.
- test_smart_fit_generate.py: hermetic end-to-end runs (mock TTS, no
ffmpeg) covering audio-only stretch, hybrid timeline growth +
persisted plan shape, fit_options override, strict_slot->smart_fit
forced regen then zero-TTS fit-only re-mix, and concise back-compat.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(competitive): dub-length fitting row reflects Smart Fit Phase A
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(incremental): mark fingerprint hashes usedforsecurity=False — dedup keys, not security (Bandit)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* docs: model-source support policy — verifiable public sources only
Owner decision (issue #310): the local-loading mechanism stays, but
official support covers only models from verifiable public sources
(HF repos, official releases with license + checksums). Privately
distributed / paywalled model files are use-at-your-own-risk; never
run bundled executables. Mirrored in SECURITY.md as a supply-chain
note. Per the docs-sync rule, shipped alongside the policy decision.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: firm up model-source policy — open, public, verifiable only; no private/paid models
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* docs: refresh community docs to match the project's current reality
- README: download badges now point to releases/latest (were frozen at
v0.2.7); Intel-Mac note (pre-built bundle is Apple Silicon; source
works on Intel; pre-built Intel tracked in #279)
- SECURITY: supported-versions table 0.2.x -> 0.3.x + 0.2.7 legacy row
- docs/install/docker.md: tag mapping matches docker.yml after #338 —
:latest is the rolling main preview, :stable (new) pins releases
- PR template: removed the abolished two-RC/48h-soak ceremony; documents
continuous-to-main
- CONTRIBUTING: new sections — what bot review looks like (CodeRabbit +
Greptile), conventional-commit + issue-link expectations, the quality
gates (cross-platform parity, 21-locale i18n + CJK allowlist, alembic,
engine back-compat, local-first, loopback security posture), and a
contribution-licensing grant that keeps the AGPL + commercial
dual-license viable
- SUPPORT.md: new — channels, before-you-file checklist, expectations
- docs/install/macos.md: Intel caveat aligned with reality
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: codify the docs-sync hard rule — behavior changes update their docs in the same PR
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(agents): rtk rules for Antigravity — token-compressed tool output
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Intel MacBook users had no installable artifact: the release matrix only
built aarch64-apple-darwin, and Rosetta 2 cannot run arm64 apps on Intel
(it only translates the other direction) — the rationale in the old
"Intel dropped" comment was backwards. Refs #279.
- Add a native `macos-15-intel` matrix leg (GitHub's designated x86_64
migration target after macos-13 retired Dec 2025; standard image,
supported through Aug 2027) building --target x86_64-apple-darwin
with app,dmg,updater bundles.
- Existing per-TRIPLE steps already carry x86_64-apple-darwin cases
(uv sidecar tar.gz, evermeet.cx ffmpeg/ffprobe — x86_64 Mach-O,
natively correct on Intel), so the leg flows through the same
Bundle/Build/Smoke/Verify steps untouched.
- The PR #290 signing path applies automatically: ad-hoc seal from
tauri.conf.json signingIdentity "-", opt-in APPLE_* stable signing,
and scripts/verify-macos-signing.sh both gated on runner.os == macOS.
- tauri-action includeUpdaterJson merges the new darwin-x86_64 platform
key into latest.json alongside darwin-aarch64, so Intel installs
auto-update on both Stable and Preview channels.
- docs/install/macos.md: table telling users which DMG (aarch64 vs x64)
matches their Mac, and the from-source fallback for old releases.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
torch.compile(mode="reduce-overhead") captures CUDA graphs whose state is
thread-local (torch/_inductor/cudagraph_trees keys its tree manager off the
capturing thread). The _gpu_pool ThreadPoolExecutor runs up to 4 workers, so
the first render captured the graph on worker A and a later render dispatched
to worker B replayed mismatched cudagraph state — silently corrupting the
audio (static noise + slowed playback from the second render onward, no
exception, so the #327 eager fallback never fired).
Fix: when the model is compiled with a cudagraph mode, wrap model.generate
(the same single choke point #327 uses) so every call hops to a dedicated
1-thread "compiled-infer" executor — capture and replay always happen on the
same thread, deterministically. A thread-ident re-entrancy guard runs inline
when already on that thread (a 1-worker executor submitting to itself would
deadlock). Installed after the #327 fallback wrapper, so the eager retry path
also runs on the dedicated thread.
No behavior change for CPU / MPS / Windows-no-Triton / compile-disabled
paths: should_torch_compile() gates exactly as before and uncompiled models
keep the full pool.
Closes#315
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(release): add macOS signing/Gatekeeper/notarization verification
Codify and enforce the macOS build-signing requirements. The release
pipeline built bundles and had opt-in Apple signing, but never verified
codesign/spctl/notarization — unsigned or broken bundles could ship silently.
- scripts/verify-macos-signing.sh: runs codesign --verify --deep --strict,
spctl Gatekeeper assessment, per-nested-Mach-O signature check, stapler
validate, and (opt-in) notarytool history. Report-only by default (unsigned
dev/preview is expected); --require-signed fails on any unsigned/un-notarized
component so a broken release stops instead of publishing an unsigned artifact.
- scripts/macos-dev-unquarantine.sh: local-dev-only quarantine stripper, with a
loud "never a substitute for notarization" warning.
- release.yml: new "Verify macOS signing" step on the macOS leg — report-only on
unsigned paths, STRICT on the opt-in signed stable path (same condition as
"Configure Apple signing"), so signing/notarization failures fail the job.
- docs/macos-signing-verification.md: the canonical 10-point requirements +
how-to-verify checklist, cross-linked to docs/install/macos.md and DESKTOP_RELEASE.md.
Verified locally: report-only PASS (exit 0) and --require-signed FAIL (exit 1)
against the real unsigned debug .app; release.yml parses as valid YAML.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(macos): ad-hoc sign bundle so users open it without Terminal (no Apple ID)
The "app is damaged and can't be opened" error is caused by a broken/incomplete
code-signature seal (codesign --verify failed: "code has no resources but
signature indicates they must be present") on the quarantined download — there
is no GUI bypass for that variant on modern macOS, forcing users to run `xattr`.
Give the bundle a VALID ad-hoc signature at build time (free, no Apple Developer
account) via tauri.conf.json bundle.macOS.signingIdentity = "-". Verified through
a real `tauri build`: the produced .app is now flags=adhoc,runtime and passes
codesign --verify --deep --strict. A valid seal flips the Gatekeeper prompt from
the un-bypassable "damaged" to the GUI-bypassable "unidentified developer", which
users clear with right-click → Open / Settings → "Open Anyway" — no Terminal.
Still not notarized (that needs the paid Apple ID), so there's a one-time
confirmation rather than a clean double-click. The opt-in Developer-ID path is
unchanged: APPLE_SIGNING_IDENTITY (env) overrides the "-" default on the signed
stable release.
- tauri.conf.json: signingIdentity "-" (ad-hoc default).
- verify-macos-signing.sh: detect ad-hoc tier; report the no-Terminal GUI path
in report-only, still FAIL it under --require-signed (production must notarize).
- docs/install/macos.md: lead the Gatekeeper section with right-click → Open;
keep xattr as fallback for the harsher "damaged"/corrupted-download case.
- docs/macos-signing-verification.md: signing-tiers table + ad-hoc default note.
- release.yml: comment the ad-hoc default + env override on the signed 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>
Items 1 and 2 from the improvement list:
1. Synchronization — Whisper-family ASR stretches segment starts back
over leading non-speech (intro music, silence), so the dub starts at
0:00 while the speaker starts at 0:02-0:03. New onset_align service
snaps each segment start forward to the first audible vocal onset
(adaptive RMS threshold over the Demucs-isolated vocals when
available). Forward-only and conservative: never moves a start
earlier, ignores sub-100ms shifts, preserves minimum duration,
leaves silent-window segments untouched. Pure NumPy — identical
across platforms.
2. Accent/vocabulary by country — a Dialect picker in the Dub panel
(BCP-47 codes per target language) injects a regional instruction
into LLM translation prompts (OpenAI/Ollama engines and the
Cinematic refine pass): Argentina yields 'Vos sos muy listo', not
'Tú eres muy listo'. Non-LLM engines show a clear hint that the
dialect needs an LLM. New i18n keys translated in all 21 locales.
Item 3 (segment rectangles: move/crop/stretch on the timeline) is a
larger editor feature and stays open on #280.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: mergetest <test@local>
* fix(tts): torch.compile failures fall back to eager — generation never fails on unsupported GPUs (#278)
On GPU architectures the bundled Triton doesn't support (e.g. Blackwell
sm_120 / RTX 5060), the compiled model dies mid-generation inside the
Dynamo/Inductor/Triton/cudagraph stack — previously surfaced as a fake
'ran out of memory' error and a dead Archetype preview. Now:
- up-front arch gate: skip compile when the GPU's compute capability is
not in this torch build's arch list (OMNIVOICE_FORCE_TORCH_COMPILE=1
overrides for PTX forward-compat setups)
- runtime fallback: model.generate is wrapped once; a compile-stack
failure (classified by exception chain: module, message, traceback
paths — the cudagraph case is a bare AssertionError) logs a warning,
restores the eager module, disables compile for the session, resets
dynamo state, and retries eagerly. Non-compile errors propagate
unchanged.
- the /generate OOM handler no longer mislabels compile crashes as OOM
and points users at the actual remedy.
Fixes#278
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Potential fix for pull request finding 'CodeQL / Empty except'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* Potential fix for pull request finding 'CodeQL / Empty except'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* Update backend/api/routers/generation.py
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: mergetest <test@local>
PR branches keep cancel-in-progress (superseded scans are wasted work).
On main each commit gets its own concurrency group, so a burst of merges
runs every scan to completion instead of cancelling all but the last —
'cancelled' renders as a permanent red ✗ in the commit history even
though nothing failed.
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Versioning hard rule (owner-set 2026-06-11), codified in CLAUDE.md:
- main's three version sources (tauri.conf.json, Cargo.toml,
pyproject.toml) always carry last release + 1 patch; bumped 0.3.5 ->
0.3.6 now.
- Preview builds stamp BASE-N which now sorts ABOVE the last stable
(0.3.6-N > 0.3.5) — the updater ordering becomes natural and the
Windows MSI ProductVersion wrinkle disappears.
- Docker: :latest = rolling main preview; :stable + :X.Y.Z + :X.Y =
tagged releases. workflow_dispatch still only emits throwaway :sha-.
- release.yml gains a version-bump job: on every stable v* tag it
bumps main to the next patch automatically.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(dub): burn translated subtitles, fix subtitle save JSON error (#309)
Two symptoms, one root: the job kept the original-language ASR transcript
while the editor only sent translated/edited text in the generate request.
- dub_generate now persists the segments the dub was actually generated
from back onto the job (metadata carried over by stable id, fallback
index; text_original retained for dual-subtitle layouts) — SRT/VTT
export and ffmpeg burn-in now render the dub language, not the source.
- The SRT/VTT export endpoints honor the save_path query param the Tauri
save dialog appends (like every other export) and return the standard
JSON envelope — previously they ignored it and returned the raw body,
so the frontend's JSON.parse choked on the SRT cue index ('Unexpected
non-whitespace character after JSON').
- Frontend guards the save response content-type so any future raw-body
response surfaces as a clear error.
Fixes#309
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* fix(dub): use the file's established realpath+startswith containment idiom (CodeQL)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dub): write subtitle saves from the Tauri process, not the backend (#309)
The backend save_path variant on /dub/srt and /dub/vtt routed a
user-controlled destination through the loopback HTTP surface — six new
CodeQL path-injection flows plus two log-injection flows. Subtitles are
small text bodies, so the frontend now fetches them raw and writes the
file via a new save_text_file Tauri command: the OS save dialog in the
trusted process is the write authorization, and the backend never sees
a destination path. Binary exports keep the established save_path flow.
Also strips newlines from user-derived values in the two flagged log
lines.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dub): leave _native_save byte-identical to main
The newline-strip on the log line moved a path sink onto a changed line,
which made CodeQL re-attribute the long-standing binary-export flow to
this PR as a new alert. The subtitle endpoints no longer feed this
function at all, so restore the exact original line — the baseline alert
stays baseline, and hardening pre-existing flows belongs in its own PR.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* chore(review-bots): visual walkthroughs — diagrams for mechanics, ASCII sketches for UI
CodeRabbit: enable sequence_diagrams explicitly and instruct the
high-level summary to sketch UI changes as compact ASCII before/after
and behavior changes as a small mermaid flow. Greptile: new repo-level
greptile.json turning on the sequence-diagram and summary sections with
matching instructions, plus the project's local-first and cross-platform
hard rules so both bots review against them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(review-bots): expert-panel review rubrics, pre-merge rule audits, knowledge base
Encode one senior-domain-expert lens per subsystem (ML inference for
backend/services, product frontend for src, desktop systems for
src-tauri, test infra for tests) as path instructions; add non-gating
pre-merge checks for the project's four hard rules (cross-platform
default parity, 21-locale i18n completeness, local-first guarantee,
backward compatibility); feed CLAUDE.md and docs into CodeRabbit's
knowledge base; mirror it all in greptile.json with customContext rules
and strictness tuning.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(setup): first-run install gate — nothing installs until the user confirms a plan
New `setup` module parks first runs in BootstrapStage::AwaitingSetup instead
of auto-installing. complete_setup validates the user's InstallPlan and only
then starts the existing bootstrap:
- install modes: installed (platform dirs) / portable (one folder next to
the exe / AppImage, config.json travels with it)
- user-chosen storage: env dir, data dir (OMNIVOICE_DATA_DIR), model cache
(OMNIVOICE_CACHE_DIR) — None = legacy default, byte-identical behavior
- minimum-space gate: per-volume free-space check (fs4 statvfs), grouped by
filesystem so dirs sharing a disk sum their requirements; install refused
when short (9 GiB env + 7 GiB models + 1 GiB data, measured + headroom)
- custom mirrors (PyPI index, HF endpoint, python-build-standalone) take
precedence over region presets in the venv/sync/backend env wiring
- ROCm torch variant selectable via config (env var still wins)
- existing installs migrate silently: venv present → setup_complete=true,
no questions re-asked; dev trees skip the gate entirely
19 unit tests (disk probing, space grouping, mirror validation, legacy
config compat).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): first-run setup screen — mode, storage with space gate, mirrors, compute
FirstRunSetup renders when the Rust side reports awaiting_setup (lazy-loaded;
regular launches pay nothing). One screen, defaults all work:
- language picker first (rest re-renders translated), 21 locales shipped
- Installed / Portable mode cards (portable disabled with reason when the
exe-adjacent folder isn't writable)
- storage rows with live per-path free-space probes (debounced
check_install_target), 'needs ~X / Y free' readouts, folder pickers
- client mirrors the Rust per-volume space gate: Start installation is
disabled with an explicit reason until every volume fits
- compute (CUDA-auto / ROCm), update channel, region + custom mirror URLs
- complete_setup errors surface inline; on success the normal bootstrap
progress UI takes over on the next status poll
Verified on a wiped machine: gate parks (no spawn, no downloads), screen
renders, 450 GB ≥ 17 GB requirement → Start enabled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): studio-console redesign of the first-run screen
The setup screen now reads as powering on studio hardware rather than a web
form — true to a voice studio, and self-sufficient offline (every font and
asset is bundled; a first run may be on a restricted network):
- breathing waveform masthead (CSS-only, deterministic speech-cadence
silhouette, staggered per-bar delays)
- Source Serif 4 display headline + engraved IBM Plex Mono panel labels +
Inter body — the three faces the app already ships
- rack-unit panels with corner screws, engraved title rules, serial plate
(OVS · vX.Y.Z)
- disk space as segmented LED capacity meters: lit = what the install
consumes, alarm-blink red on insufficient volumes
- mode cards with indicator LEDs; 'armed' Start button — LED lights and a
halo pulses only once every volume passes the space gate
- atmosphere: corner accent glows + SVG film grain; staggered rise-in
choreography on load
- all motion transform/opacity only; prefers-reduced-motion holds every
frame still; theme-token derived colors; focus-visible rings throughout
No logic changes: same IPC calls, same i18n keys, same space-gate math.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): wide desktop deck, hardware-aware Compute + Update channel cards
Three pieces of feedback addressed:
- width: the console is now a 1240px two-column deck (storage rail left,
decision rail right) that uses desktop real estate; collapses to one
column under 980px and stacks fully under 620px
- no outer chassis box: panels float directly on the atmospheric backdrop,
each carrying its own rack-unit treatment
- Compute and Update channel split into separate cards with real
information: get_setup_state now detects hardware (nvidia-smi → CUDA
name, /sys/class/drm vendor 0x1002 → AMD/ROCm, Apple Silicon → MPS,
CPU cores + RAM via sysinfo; best-effort, never blocks) — the Compute
card shows a live 'Detected: …' readout, badges the option that matches
the machine, and pre-selects ROCm on AMD boxes; both cards use LED
radio options with full descriptions (6 new i18n keys × 21 locales)
Also pins playwright-core as an explicit devDep — bun did not materialize
it through @playwright/test, breaking programmatic browser use.
20/20 Rust tests · vite build · CJK guard green. Verified live (gate
engaged, responsive single-column) and at 1600×1000 via mocked-IPC
browser shot (two-column deck).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): move network (region + mirrors) into the masthead with language
Language and download region are the two 'where am I' choices — they now
sit together top-right of the masthead, with the custom-mirrors disclosure
tucked beneath the subtitle. The Network panel is gone, leaving a balanced
deck: Install mode + Storage left, Compute + Update channel right.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): strip the boxes — fills and rules carry the structure
One design rule now: borders only where state demands them. Panels lose
their boxes entirely (engraved mono title + rule separates sections);
option cards, storage rows, selects/inputs, the hw readout, the version
plate and the ghost buttons are all flat fills; active options glow with
an accent tint + LED; blocked rows and errors use a red tint + 2px inset
edge bar instead of a border. The badge chip is fill-only too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): quiet pass — every element earns its visual weight
- waveform becomes a whisper: 22px trace, 2px bars, ~half opacity — an
ambient signature instead of a billboard
- storage readouts collapse to one mono line ('needs ~9 GB · 449 GB free');
the LED meter now appears only when it carries information (install
would consume >35% of free space, or the volume is blocked) — at 449 GB
free a bar was a meaningless sliver
- Change… buttons go text-quiet (transparent until hover)
- custom-mirrors disclosure right-aligns under the region select it
extends, instead of floating under the subtitle
- version plate moves to the footer next to the disk total — the masthead
keeps only title, subtitle, and the two locale/region selects
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): platform-matrix awareness — distro+arch detection, ROCm gated to Linux, no Windows console flash
The install matrix is OS family × distro × arch × GPU vendor, and the
setup screen now both shows it and only offers choices valid for it:
- HardwareInfo gains os_name (distro PRETTY_NAME from /etc/os-release on
Linux, macOS/Windows elsewhere) and arch (x86_64/aarch64) — the detected
line reads 'CachyOS x86_64 · NVIDIA RTX 4070 · 32×CPU · 31 GB RAM',
exactly what bug reports cite
- SetupState gains os; the ROCm option renders on Linux only (wheels
don't exist elsewhere) and complete_setup clamps rocm→auto on
non-Linux as the server-side backstop
- nvidia-smi probe gets CREATE_NO_WINDOW on Windows — no cmd flash on
the first screen a user ever sees
- Apple Silicon → MPS, Intel mac → CPU, ARM Linux → CPU: all matrix
cells resolve through the same base constructor
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): unify the whole first-run journey under the studio-console system
Setup → Installing → Model wizard now read as one continuous experience:
the same atmosphere, whisper waveform masthead, serif/mono type, LED
language and quiet fills across all three acts.
- Installing (BootstrapSplash): rebuilt in frs-* — segmented LED journey
meter (completed steps + live byte progress), LED step rail (done=green,
active=pulsing accent, pending=dim), engraved ACTIVITY panel with the
quiet mono log (collapse/copy as text-quiet actions), failure act with
red-tint error + hints + armed Retry. All logic untouched: stage poll,
event subscription + backfill, dedupe, hints, region/language selects.
- Model wizard (SetupWizard): same masthead with the step rail as engraved
mono LED steps top-right, welcome cards as option-card surfaces,
preflight as LED check rows (pass/warn/fail), frs nav buttons with armed
primaries, embedded Model Store / Engines / Dictation panels scroll
inside the act. Old 556-line stylesheet replaced by ~60 lines of glue;
BootstrapSplash.css reduced to a resolving stub.
- FirstRunSetup.css is now the journey's shared design system (step rails,
log panel, banners, hints, wizard chrome, check rows appended).
- 2 new strings (Installing / Activity) translated across all 21 locales.
Validated end-to-end on this machine: setup screen → Start installation →
real venv bootstrap (~10 min) → backend healthy on 3900 → model wizard.
20/20 Rust tests · vite build · CJK guard green · installing act verified
via mocked-IPC screenshot at stage=installing_deps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): --setup re-entry flag + make the install-plan screen un-stealable
The setup stage is first-run-only by design (completed installs skip it),
but it must be reachable on demand and must actually win the mount when
engaged. Three fixes:
- 'omnivoice-studio --setup' parks the bootstrap in AwaitingSetup on any
launch — checked before the attach-to-healthy-backend shortcut, so a
running backend can't skip past it
- App routing: awaiting_setup now outranks everything (a live backend
answering /setup/status used to route straight to the model wizard);
the wizard additionally requires stage === 'ready' so it can't mount
during the initial stage race
- useBootstrapStage: a transient IPC miss no longer permanently declares
'ready' (which killed the poll loop and silently skipped the setup /
progress screens) — it retries up to 5 ticks before conceding
Plus journey-wide titlebar clearance (content never sits under the GTK
headerbar / macOS traffic lights / Windows controls) and drag-region
mastheads on all three acts.
Verified: mocked-IPC harness with stage=awaiting_setup + a LIVE backend
answering /setup/status renders the setup screen, not the wizard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(setup): remove backdrop decoration — flat surface, state-only emphasis
The corner accent glows and SVG film grain rendered as visible banding /
noise artifacts on many panels — both gone; the journey now sits on a
clean flat chrome background. Also swept the remaining decorative bloom:
the active option card drops its glow shadow (flat accent tint + LED carry
the state), and the armed Start button loses its pulsing halo (the lit LED
already signals actionable). Remaining shadows are functional micro-detail
only: 6px LED glows, meter track inset, red edge bars.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): journey rail + verbosity diet — clean, smooth, elegant
The setup page is now visibly stage 1 of the install flow: a quiet
breadcrumb rail (SETUP → INSTALLING → MODELS & ENGINES) sits between the
waveform and the headline on both the setup and installing acts, LEDs
marking done/active/pending — one continuous story across the journey.
Verbosity halved without hiding information:
- option descriptions unfold (260ms ease) only on the selected card; the
page shows exactly one explanation per group, collapsed cards keep the
text as a tooltip
- storage rows drop their always-on caption (label + path + readout +
Change… on one line; caption lives in the row tooltip)
The whole page now fits a laptop window without scrolling.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): merge Models + Engines into one wizard act
Two tabs weren't necessary: models are the required gate, engines the
optional extras — now two stacked panels in a single 'Models & engines'
step (label reuses the journey-rail key, translated in 21 locales).
Wizard shrinks to 4 steps: Welcome → System check → Models & engines →
Dictation. Continue still gates on models_ready only; engines stay
optional. Welcome cards updated to the 3 remaining acts; static cards
keep their descriptions visible (the active-only fold is for radios).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(setup): wizard was skipped after first-run install — probe /setup/status on bootstrap ready
The models-needed probe started at mount with a ~30s retry ceiling. On a
first run, mount happens at the setup page — by the time the user reads
it and the multi-minute install finishes, the attempts were long burned,
so setupChecked landed as 'no wizard needed' and the studio rendered with
zero models on disk. The probe is now keyed on bootstrapStage and runs
when it hits 'ready' — the first moment a backend exists to answer.
Normal launches (backend up quickly) behave exactly as before.
Caught by running the full journey three times end-to-end: rounds 2–3
skipped Models & engines after install; with the fix the wizard mounts
with models_ready=false (Whisper large-v3 listed missing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): drop the Welcome step — wizard opens on System check
The welcome act had nothing left to say: the journey rail names the
stages, the setup page already oriented the user, and the cards repeated
both. The wizard is now three steps — System check (auto-runs on mount) →
Models & engines → Try dictation — landing the user directly on live
preflight results instead of a page about the pages to come.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): true unified library — models + engines as ONE list
'Merge them' meant one list, not two panels stacked — fair criticism.
The wizard's Models & engines act is now a purpose-built WizardLibrary:
every installable is a row of the same grammar (LED · name · chip ·
size · action):
- required models lead (REQUIRED chip, Download action, live SSE
progress bar + percent, green LED when installed) — they gate continue
- TTS engines follow (ENGINE chip): active engine glows accent,
available ones offer one-click Use (selectEngine), heavy installs
defer honestly to Settings ('install later in Settings' + reason
tooltip)
- the optional-model tail folds behind 'Show N optional models'
The full management surface (search, HF token, deletes, sorting) stays
in Settings — a first run needs a checklist, not a store. 9 new strings
× 21 locales. Verified against the live backend via the browser harness:
required/installed/engine/active/Use/defer states all render in one list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(diagnostics): local-first self-check, error journal, and bug-report pipeline (#296)
* feat(diagnostics): local self-check + scrubbed bug-report pipeline
Closes the gap between 'something broke' and 'a useful GitHub issue
exists' — entirely within the local-first constraint: the only outbound
path remains the user's own browser opening a prefilled issues/new URL.
Backend:
- core/scrub.py: privacy scrubber for anything leaving the machine —
env-var secret values (*TOKEN*|*KEY*|*SECRET*|*PASSWORD*), credential
shapes (hf_/ghp_/github_pat_/sk-), home dirs on all three OSes
- core/diagnose.py: 9-check self-check (device+GPU, ffmpeg, HF token,
disk, data-dir writability, RAM, engine registry, hub reachability),
pre-scrubbed, ASCII-safe output
- GET /system/diagnose + 'python main.py --diagnose' (exit 0/1)
- /system/info: hardware inventory (os_version, cpu_model, cpu_count,
ram_total_gb, gpu_name, vram_total_gb, disk_free_gb), cached statics
Frontend:
- utils/bugReport.js: single source for the prefilled-URL builder —
scrubText twin, hardware context capture, scrubbed error+stack embed,
URL-length cap; ReportBugButton refactored onto it
- ErrorBoundary 'Report this bug' action with the error attached
- utils/errorToast.jsx toastErrorWithReport(); wired into export toasts
- Settings > About 'Run self-check' with per-check status badges
Tests: 27 pytest (scrub, diagnose) + 15 vitest (bugReport); existing
suites green; verified live (--diagnose, TestClient, vite build).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(diagnostics): error journal, diagnostic bundle, crash notice, global handlers
Second slice of the bug-tracking work — still zero outbound paths beyond
the user's own browser/file manager.
- core/error_journal.py: deduped ring of recent unhandled backend errors
(fingerprint counts, error_class triage: GPU_OOM, HF_AUTH_FAILED,
PYANNOTE_LICENSE_REQUIRED, DISK_FULL, FFMPEG_MISSING, NETWORK_ERROR),
scrubbed, JSONL-persisted so the error that killed the last run survives
restart. Wired into the global exception handler; 500 bodies now carry
error_class; GET /system/errors/recent.
- core/diagnostic_bundle.py + POST /system/diagnostic-bundle + Settings >
About 'Save diagnostic bundle': zip of self-check report, error journal,
scrubbed log tails — drag onto a GitHub issue; bypasses the ~8k
prefill-URL ceiling.
- crash-on-next-launch: /system/notifications flags a crash logged before
this session started (size vs acked-size in prefs, mtime vs process
start); POST /system/crash/ack; LogsFooter acks on action click.
- utils/globalErrorHandlers.js: uncaught errors + unhandled rejections get
a throttled, noise-filtered 'Report this bug' toast.
- sidecar log parity fix: _tauri_log_candidates() now lists the Rust
sidecar's backend.log/backend_err.log on Linux (XDG state dir) and
Windows (LOCALAPPDATA) — sidecar crashes were only visible on macOS.
Tests: +19 pytest (journal, bundle); suite at 102 passed. Vitest 124
passed; vite build green. Live-verified: journal recorded and classified
a real HF 401 from the test run (HF_AUTH_FAILED, paths scrubbed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(diagnostics): breadcrumbs, deep self-check, report sweep, issue search
Final slice of the bug-tracking work.
- toastErrorWithReport adopted at the high-traffic failure sites: TTS
generation, dub upload/ingest/transcribe, engine install, engines-matrix
load, voice profile save/delete/test, batch enqueue/cancel/delete.
Validation toasts and cancellations stay plain on purpose.
- utils/breadcrumbs.js: local-only ring of the last 20 action names
(closed-set names only — never content or paths), embedded as a
'Recent actions' section in the prefilled report. Instrumented: view
changes, generate, dub pipeline, export, engine switch.
- deep self-check: /system/diagnose?deep=true and --diagnose --deep load
the active engine and synthesize a short utterance (num_step=4) —
catches 'installed but broken'. 180s time-box, skips during model load,
scrubbed failure detail. Verified live: cold-loaded omnivoice and
produced 2.2s of audio in 43.9s on CUDA.
- 'Search similar issues' action on the ErrorBoundary: scrubbed,
noise-stripped GitHub issue search URL — dedupe before filing.
- bug_report.md template now points at the diagnostic bundle and the
--diagnose CLI so manual reports arrive with the same evidence.
Tests: pytest 107 passed (4 new deep-check tests, CJK gate green);
vitest 218 passed (breadcrumbs + issue-search suites); vite build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(diagnostics): self-diagnosis section in troubleshooting + README pointer
Settings > About self-check / --diagnose / --deep / diagnostic bundle are
now the documented first step before the per-error entries — and the
support team's first ask on every issue.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): flush sticky action bar, global dbl-click maximize, open maximized
First-run polish on the studio-console journey:
- FirstRunSetup: fixed-footer / scrollable-middle layout — mast + decision grid
live in a dedicated .frs__scroll region; the install action bar is the last
flex item, so it sits flush at the window's bottom edge and nothing (e.g. an
expanded compute-option description) can render beneath it on small windows.
- Double-click-to-maximize on the custom borderless titlebar now works on EVERY
drag region (splash, first-run, wizard, main header) via one delegated
listener in main.jsx, on all platforms; removed App.jsx's redundant inline
handler so it doesn't double-toggle. Skips interactive controls in the bar.
- Window opens maximized to the available desktop size (tauri.conf.json).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(diagnostics): quiet Bandit on the journal hash and hub probe
The journal fingerprint is a dedup key, not a security boundary —
usedforsecurity=False. The hub reachability probe gets an explicit
https scheme guard on its constant URL so the urlopen sink is audited.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(setup): address PR #295 review findings — security, lifecycle, privacy, i18n
Security:
- setup.rs valid_mirror: reject plaintext http:// mirror URLs (MITM
supply-chain path into UV_PYTHON_INSTALL_MIRROR / UV_INDEX_URL /
HF_ENDPOINT); explicit http://localhost / 127.0.0.1 / [::1] exceptions
only. Tests extended incl. loopback-lookalike hosts.
- setup.rs detect_hardware: AMD vendor ID alone no longer maps to
kind="rocm" — a cheap ROCm userspace probe (/opt/rocm or rocminfo on
PATH) gates it; bare AMD GPUs report kind="amd" so the UI offers ROCm
without pre-selecting it ("matches this machine" only when verified).
Functional:
- lib.rs/setup.rs --setup re-entry: complete_setup now kills any backend
still serving on the port before retry_bootstrap, so changed
env/mirror/layout settings actually apply instead of re-attaching.
- setup.rs: nvidia-smi probe runs behind a 3 s timeout thread — a wedged
driver degrades to CPU instead of hanging the first-run IPC.
- setup.rs: is_first_run is now a pure read; the existing-install
migration write moved to migrate_existing_install_if_needed, invoked
only from the bootstrap thread (get_setup_state no longer writes).
- setup.rs complete_setup: config save errors now abort setup and surface
in the UI instead of bootstrapping into a stale on-disk layout.
- setup.rs complete_setup: logs default-vs-custom flags instead of the
user's absolute env/data/models paths (privacy rule).
- scrub.py + bugReport.js: also redact forward-slash Windows homes
(C:/Users/<name>, file:///C:/Users/...), ordered before the macOS
pattern so "C:~" residue can't form. Tests added on both sides.
- bugReport.js: context fetches bounded by a 2.5 s AbortController
timeout so report assembly degrades to partial context instead of
hanging on a stalled backend.
- system.py: crash ack is now {size, mtime} (legacy size-only ack still
honored) and /system/logs/clear drops the ack — truncation can no
longer permanently suppress 'crash-last-session'.
- system.py: Linux Tauri-log probe honors XDG_DATA_HOME.
- setup.ts/WizardLibrary.jsx: SetupProgressEvent type now documents the
full phase taxonomy actually emitted (per-file start/progress/done +
install_*/delete_* lifecycle); reducer verified correct against the
backend stream and annotated — a file-level 'done' must not clear the
repo row.
- SetupWizard.jsx: step rail clamps to the highest unlocked step
(preflight/models gates) — no more jumping straight to "Enter studio".
Polish:
- BootstrapSplash.jsx: Waveform heights wrapped in useMemo([bars]) like
its siblings.
- BootstrapSplash.jsx: detectHints returns i18n keys (bootstrap.hint_*)
rendered through t(); translated in all 21 locales.
- SetupWizard.jsx: step rail aria-label localized (setup.step_aria /
setup.step_completed) in all 21 locales.
- FirstRunSetup.css: deprecated word-break: break-word → overflow-wrap:
anywhere; reduced-motion override also stops the frs-hw-pulse LEDs
(.frs-step.is-active LED + .swiz-lib__led--busy).
Deferred (design-level, follow-up PR): --setup re-entry round-tripping of
custom dirs/mirrors into the form (setup.rs), and worker-thread leak on
timed-out deep checks (diagnose.py).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(i18n): translate back-filled keys in all 20 locales, drop inline fallbacks
The reconciliation merge back-filled 16 new keys (about.self_check*,
about.*bundle*, dub.num_speakers_*, errors.*) with English text in
every non-English locale — CodeRabbit flagged 9 locales; fixed all 20.
Interpolation tokens preserved and asserted during the rewrite. Also
removed the two inline English fallback strings in App.jsx
(firstrun.first_sound_*) so copy lives only in locales/*.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: mergetest <test@local>
On Windows 11 the dictation pill (Ctrl+Shift+Space) always reported
"Microphone access denied" even though OS-level mic permission was
granted (Voice Clone worked, backend transcribed fine). Root cause:
no WebView2 PermissionRequested handler was registered, so WebView2
fell back to its own permission UI — which the 300x64 transparent,
undecorated, deliberately-unfocused pill window can never host — and
getUserMedia() rejected with NotAllowedError.
Per-platform fixes:
- Windows (WebView2): register a PermissionRequested handler on both
the main and widget webviews that allows microphone/camera requests
in code, for the app's own origin only (tauri.localhost + dev
loopback). The Windows privacy toggle still applies on top.
- Linux (WebKitGTK): the media-stream enable + permission auto-grant
previously covered only the "main" window — the dictation widget is
a separate WebView and was silently denied. Now applied to both.
- macOS: already correct — NSMicrophoneUsageDescription ships in
src-tauri/Info.plist and wry grants media capture to the app origin;
documented in the shared helper.
Frontend: getUserMedia failures are now mapped by error name
(utils/micError.js) instead of one blanket "access denied" toast —
permission denials get a per-OS "where to re-enable it" hint
(Windows hint now mentions the desktop-apps mic toggle), missing
devices and busy devices get their own messages, and the previously
hardcoded English toast in useRecording goes through i18n. New keys
added to all 21 locales.
Tests: vitest unit tests for the error mapping (19 cases) and a Rust
unit test for the WebView2 origin allow-list; Windows handler code
cross-checked against webview2-com 0.38.2 / windows-core 0.61.2.
Fixes#323
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
`bun run desktop-prod` (and its :run/:upgrade/:pill/:run:pill variants)
invoked `bash scripts/desktop-prod.sh` directly. On Windows, cmd and
PowerShell have no `bash` on PATH unless Git Bash happens to be there,
so the documented from-source install path died with a cryptic spawn
failure before printing anything — the exact first step in issue #282's
repro.
Add scripts/desktop-prod.mjs, a tiny launcher (runs under bun or node):
- macOS/Linux: execs the bash script unchanged — zero behavior change.
- Windows: locates Git Bash via `where.exe bash`, well-known Git for
Windows install paths, or derived from git.exe's location; explicitly
skips C:\Windows\System32\bash.exe (the WSL launcher, which would run
the script inside Linux and wipe/launch the wrong paths).
- No usable bash: prints an actionable error (install Git for Windows,
use `bun run desktop`, or use the installer) instead of a spawn error.
All flags are forwarded untouched and the child's exit code is
propagated. scripts/desktop-prod.sh itself is unchanged, and
docs/install/windows.md now lists Git for Windows as a prerequisite
for from-source installs.
Refs #282
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Turns the ad-hoc 'attach a probe trace to the PR' habit into one script:
redacts credentials/home-dirs/emails/IPs from the HTML report, prints a
markdown digest, prunes old local reports, and only uploads (secret gist +
PR comment) behind an explicit --post --yes after human browser review.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Root cause, two layers:
1. tauri-plugin-updater's default comparator is plain semver
(remote > current). Preview builds are published as X.Y.Z-N
(e.g. 0.3.5-41 = main, 41 builds after the 0.3.5 tag), which semver
treats as a *pre-release* of X.Y.Z — so it sorts BELOW stable X.Y.Z.
Once stable 0.3.5 shipped, preview users were told "you already have
the latest version" forever.
2. The endpoint list [preview, stable] is not a "best of both" — the
plugin stops at the first manifest that parses and uses later
endpoints only as network fallbacks, so a reachable preview manifest
hid a newer stable release entirely.
Fix: for the preview channel, check BOTH manifests with a custom
version_comparator implementing cross-channel ordering (higher base
version wins; on equal base a suffixed preview build outranks the bare
stable it was built on; preview-vs-preview uses numeric-aware semver
pre-release comparison), then offer the newest candidate. A manifest
error is non-fatal while the other manifest answers. The stable channel
keeps the single endpoint and the plugin's default comparison —
default behavior unchanged on all platforms.
Adds 7 unit tests covering preview ahead of stable (the bug case),
stable passing preview, equal-base both directions, equal versions
(no ping-pong), numeric build-counter ordering, and base dominance.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Parity with the hosted omnivoice.app describe field, implemented fully
locally: a deterministic, ordered synonym-table mapper (no model, no
network, stdlib only) projects a natural-language description onto the
existing six-category design space (Gender/Age/Pitch/Style/EnglishAccent/
ChineseDialect). Every emitted token is validated at import time against
the engine taxonomy, so the mapper can never produce an instruct item the
engine validator would reject; Chinese token forms are derived from the
taxonomy, never hardcoded (the one functional pinyin->dialect mapping is
allowlisted in test_no_hardcoded_cjk.py with justification).
UI: a describe textarea in the Design tab fills the attribute picker live
(hand-tuning still possible afterwards); parts of the description the
taxonomy can't express are listed back to the user as 'ignored' instead
of failing silently. New i18n keys in all 21 locales.
Fixes#317
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(dub): re-dub honors transcript edits — fingerprints canonicalised, preview cache-busted, mux made atomic (#281)
Three symptoms, three causes:
1. Edited line, unchanged result: the dubbed preview-video URL was
identical across re-dubs, so the WebView kept serving the previous
dub. A generation nonce now cache-busts the preview after every
completed generation.
2. Preview stuck loading forever: overlapping preview requests ran
ffmpeg against the same output path and the mtime cache check saw
the half-written file as valid. The mux now runs under a per-path
lock, writes to a temp file, and os.replace()s into place.
3. One edit re-dubs all lines: server-side fingerprints were computed
from pydantic-parsed segments (defaults filled in) but recomputed
client-side from raw dicts (keys omitted), so every segment always
looked stale and incremental degraded to a full re-dub. Values are
now canonicalised on the backend and the frontend builds generation
inputs through one shared helper (utils/segments.js) for both the
generate request and the incremental plan.
Fixes#281
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Potential fix for pull request finding 'CodeQL / Uncontrolled data used in path expression'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* fix(dub): realpath containment for job-derived preview paths (CodeQL)
Request-supplied job_id/lang flowed into the preview mux output path.
Both now pass a realpath containment guard against DUB_DIR (the file's
existing per-segment pattern) and lang is allowlist-validated before it
lands in a filename.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dub): inline the containment guard — CodeQL can't track it through a helper
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
* fix(tts): /generate honors the selected TTS engine (#312)
The /generate route always ran the OmniVoice model directly, ignoring both
the Settings engine selection and any per-request override. It now resolves
the active backend (env var > Settings selection > default), supports an
explicit `engine` form field (same pattern as /ws/tts and /v1/audio/speech),
reuses the per-process engine instance cache, keeps inline [pause Nms]
markers working on every engine, and honors applies_own_mastering so studio
engines skip the broadcast mastering chain. The OmniVoice default path is
byte-identical to the old behavior — existing API consumers see no change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(312): resolve modules at run time, drop lifespan client — fixes full-suite isolation
tests/backend/** runs before tests/test_*.py and pollutes sys.modules
(re-imports the services tree), so module-level imports bound at pytest
collection pointed at a stale services.tts_backend — registry patches
landed on a dict the routes no longer read ('Unknown TTS engine' in CI).
Modules are now resolved through sys.modules inside each test. The client
fixture also drops the module-scoped lifespan context manager that bound
event_bus queues to this module's loop (teardown 'Queue bound to a
different event loop') — plain function-scoped TestClient, the
test_api.py pattern.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
A venv with no pyvenv.cfg (interrupted creation, half-deleted dir, or a
managed Python that was removed) made the backend exit 106 forever; the
only fix was manually deleting .venv. Bootstrap now (1) validates venv
structure before declaring it ready and (2) recognizes the broken-venv
death signature (exit 106 / 'No pyvenv.cfg file') after spawn — in both
cases it quarantines only the .venv itself (rename-aside if deletion
fails, never user data) and rebuilds through the normal setup path with
existing progress stages. Healing is attempted once per launch; a healthy
venv is never touched. The spawn+health-poll loop is extracted from
lib.rs and shared with the retry path.
Fixes#314
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Voice previews and synthesized outputs could overlap with no way to stop
them: playBlobAudio() fire-and-forgot a fresh Audio()/AudioContext per
call, and each component (Design demo grid, gallery, demo player) kept
its own uncoordinated audio handle.
- Add utils/playback.js: a global single-playback manager. claimPlayback()
stops whatever was playing before registering the new playback, returns
a release() for natural end, and exposes stopActivePlayback() plus a
usePlaybackSource() hook for UI affordances.
- Register every preview/output path with the manager: playBlobAudio
(Synthesize output, profile previews, dub segment previews),
DemoPresetGrid cards, VoiceGallery previews (archetypes / community /
imports), and the CloneDesignTab "Hear demo" player.
- Visible stop affordance: while a synthesized output is playing, the
Design/Clone footer CTA becomes a "Stop playback" button (new i18n key
clone.stop_playback in all 21 locales). Preview cards keep their
existing play/pause toggle, now wired through the manager.
- Tests: unit suite for the playback manager (claim/stop/release/
subscribe semantics) and two DemoPresetGrid regression tests for the
single-playback invariant and the stop toggle.
Fixes#316
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Lucide ships stroke-width 2 on a 24px grid; at the app's 11-16px render
sizes that weight reads heavy. One global rule (svg.lucide) re-weights
every icon to 1.5 with geometricPrecision shape-rendering — crisper,
lighter, no call-site churn. Hand-rolled SVGs (logo mark, batch spinner)
don't carry the .lucide class and keep their bespoke weights; the one
explicit per-icon strokeWidth (archetype icons) is dropped so the global
weight governs everywhere.
Native <input type="file"> chips are now themed via
::file-selector-button mirroring .ui-btn--subtle (chrome tokens, pill
radius, hover states). All current file inputs hide behind themed labels,
but any visible one — future panels, the LAN/share web view — no longer
renders the OS-default grey button.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- First-run action bar is now a pinned flex sibling below a dedicated
scroll region (.frs__scroll) — flush to the window's bottom edge, with
nothing rendering beneath it; only the content above scrolls.
- Double-click-to-maximize is wired once in main.jsx, delegated across
every data-tauri-drag-region (splash, first-run, wizard, main header)
on all platforms, skipping interactive controls. Replaces the
wizard-only handler in App.jsx.
- Main window opens maximized (tauri.conf.json).
- Setup wizard preflight checks flow into responsive columns on wide
windows instead of one tall single column.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Upgraded installs replaced backend/ + omnivoice/ sources from the bundle
but never refreshed pyproject.toml/uv.lock or re-ran uv sync, so any
dependency added after the user's venv was created was missing at import
time — e.g. a venv predating scalar-fastapi (added May 4) died on
startup with ModuleNotFoundError once v0.3.5 code landed on it.
- bootstrap.rs: refresh pyproject.toml + uv.lock from the bundle whenever
a healthy venv is reused; when the lockfile content changed, run
`uv sync --frozen --no-dev` so newly added deps land. On sync failure
(e.g. offline upgrade) keep the existing venv instead of bricking a
previously-working install.
- bootstrap.rs: the repair path now refreshes manifests first (it used to
sync against the stale lock from when the venv was created) and applies
the restricted-network HTTP env tuning it was missing.
- backend/main.py: scalar_fastapi import is now guarded — it only powers
/docs, so a venv without it must still boot; /docs returns 503 with an
actionable message instead.
Closes#307
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Voice cloning without a transcript fell through to OmniVoice's built-in
load_asr_model() — a transformers pipeline() load of
whisper-large-v3-turbo that fails outright on transformers 5.3 — even
when whisperx / faster-whisper / mlx-whisper were installed and working.
The dub pipeline already used the registry; the /generate clone path
never did.
- services/asr_backend.py: new transcribe_reference() resolves the
active registry backend (honoring auto-detect order and the
OMNIVOICE_ASR_BACKEND override), extracts text from either result
shape (top-level "text" or whisperx-style segments), and degrades to
None on any failure so the model fallback behaves exactly as before.
When the registry itself resolves to pytorch-whisper it defers to the
model's lazy load instead of building a second pipeline.
- api/routers/generation.py: transcript-less references get transcribed
in the GPU pool before inference.
- tests/test_transcribe_reference.py: covers both result shapes,
failure degradation, and the pytorch-whisper deferral.
The remaining half of #308 — pytorch-whisper itself being incompatible
with transformers 5.3 when it truly is the last resort — is tracked in
the issue.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The Timing control passed `options=` to <Segmented>, whose prop is
`items=` (defaulting to []), so the toggle group rendered as a single
empty pill with nothing to click — users had no way to pick
Concise / Stretch Video / Strict slot. Broken since the control was
introduced; every other Segmented call site already uses `items=`.
Closes#313
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(tts): let studio engines skip the broadcast mastering chain
apply_mastering() (HighpassFilter + Compressor + 8% Reverb) is tuned for
OmniVoice's 24 kHz clone output. The OpenAI-compatible /v1/audio/speech
route (_run_tts) runs it on every engine, including VoxCPM2 — whose native
48 kHz output is already studio-grade. There the compressor pump and the
reverb tail are audible degradation rather than polish.
Add an opt-out class flag TTSBackend.applies_own_mastering (default False,
so all existing engines are unchanged) and set it True on VoxCPM2Backend.
_run_tts() skips apply_mastering() when the active backend declares it.
Loudness normalisation still runs for every engine (benign peak scale).
* fix(tts): also skip mastering on the streaming route for studio engines
tts_stream.py is the other route that runs the *active* TTS backend
(get_active_tts_backend), so it needs the same applies_own_mastering guard
as openai_compat._run_tts — otherwise VoxCPM2 output is still pumped/reverbed
when streamed. The remaining apply_mastering() call sites (generation.py,
batch.py, batched_tts.py, dub_generate.py) run the OmniVoice model directly
via get_model(), never the active backend, so VoxCPM2 cannot reach them.
* docs(tts): mark OmniVoice-only mastering sites with TODO(#312)
Per review: instead of always-False guards on routes that never run the
active backend, leave a pointer so the applies_own_mastering guard is added
exactly when those routes become engine-aware (issue #312).
* fix: disable tqdm on non-TTY to prevent OSError on Windows (#283)
When running as a Tauri backend (non-TTY stdout), tqdm tries to write
terminal control characters which fails with Errno 22 on Windows.
Set TQDM_DISABLE=1 when stdout is not a TTY during model loading.
* fix: guard sys.stdout against None and fix import ordering (#283)
- Add None check before calling isatty() to prevent AttributeError
- Fix import ordering (sys after re alphabetically)
Dictation via the global shortcut transcribed fine but the text never reached
the target app on macOS, due to two stacked bugs (diagnosed, patched, and
verified by @geektf in #287):
1. The ShortcutState::Pressed handler called win.set_focus(), making the
widget frontmost — the simulated ⌘V from simulate_paste() landed in the
widget instead of the app being dictated into. Skip set_focus() on macOS
(same #[cfg(not(target_os = "macos"))] guard the other widget call sites
already use).
2. With the widget unfocused, the WebView clipboard APIs
(navigator.clipboard.writeText / execCommand('copy')) fail silently in
WKWebView, so ⌘V pasted whatever was previously on the clipboard.
simulate_paste now takes Option<String> and writes the transcript to the
clipboard natively (arboard) before sending the keystroke — no window
focus required. CaptureWidget passes the transcript; copyText() stays as
best-effort for browser (non-Tauri) mode, and the optional param keeps
any text-less call sites working.
cargo check clean (the unreachable_code warning in setup.rs is pre-existing
from #286); frontend node:test suite passes. End-to-end behavior verified by
the reporter on macOS 26 / M4 Pro with both patches applied.
Fixes#287
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three reported issues in the dubbing editor:
1. Dark video after YouTube ingest: the preview mounted while yt-dlp was
still finalizing the media file — the first load failed (MediaError 2
network / 4 non-media body) and the once-only error handler declared
the source dead, leaving a black box until the project was reloaded.
The error handler now retries with backoff (up to 6× over ~21s) before
giving up; decode errors (3) stay terminal.
2. Responsive/resizable layout: min-width:0 on the split-grid columns
(the classic shrink trap), settings-bar fields get real shrink room
instead of locked min-widths, bulk selects flex, prep-bar overlays are
viewport-bounded, and the segment table's fixed rails narrow at
1100px and collapse speaker/gain entirely below 760px so the text
column keeps usable width at any size.
3. Toolbar: Save / Reset / Export are icon-only with hover tooltips
(+ aria-labels); Generate Dub keeps its label as the primary verb.
Skeleton header matches.
Vitest 196/196 green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The unified library dropped the inline HF-token field the old
ModelStoreTab embed used to provide — so onboarding produced installs
with no token, and users hit the 'speaker diarization disabled' wall on
their first multi-speaker dub. Restored as a quiet disclosure at the
bottom of the Models & engines act: password input → POST
/system/set-env HF_TOKEN (same durable persistence Settings uses),
saved/error states, Enter-to-save. Copy names the concrete benefit
(pyannote diarization) and the local-first promise (token stays on this
machine). 6 strings × 21 locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The test asserted the component renders nothing when demo clips 404 —
the exact behavior #294 deliberately removed (it blanked the wizard's
Try-dictation act on every real install). New contract under test: the
script cards are asset-gated and disappear; the hotkey card (shortcut +
press-to-verify, zero assets needed) stays.
This was the single failure breaking CI on main since #294 merged
(34 files / 196 tests green with the fix).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fair criticism taken: vertically centering variable-height content meant
every act and step reflowed the page around its own center, and selecting
an option pushed everything below it. The journey now has one stable
scaffold — only the content region changes:
- deck is top-anchored (waveform opens the page right under the titlebar;
the centering dead-zone is gone) and fills the viewport
- footer (serial plate, totals, armed action) is sticky at the bottom
with a soft fade — never scrolls out of view, hugs the bottom when
content is short
- variable text gets reserved space: masthead subtitles hold two lines;
option descriptions move out of the cards into a fixed two-line caption
slot per radio group (aria-live), so switching options swaps text in
place with zero shift — cards themselves are title-only
- description tooltips retained on every card
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): first-sound ending + accessibility pass
First sound — onboarding ends with the product doing the thing: the
moment the studio mounts after the wizard, one short line is generated
locally and played ('Welcome to your studio. Every word you hear was
generated on this machine, just now.' — localized, 21 locales), with a
toast naming what just happened. sessionStorage handoff so it fires only
on the run that completed the wizard; every failure path is silent — a
first impression must never surface an error.
Accessibility:
- WAI-ARIA radio pattern on all option groups: roving tabindex (selected
option owns the tab stop) + Arrow-key navigation, selection follows
focus; groups get aria-labels
- aria-live='polite' on the installing act's stage label so screen
readers hear stage transitions
- contrast: quiet text raised from 0.45–0.55 to 0.6–0.68 opacity — small
visual change, real WCAG gain
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(backend): orphaned backend couldn't load models — EPIPE-safe stdio
Caught in the wild by the in-app diagnostic report: when the desktop
shell that spawned the backend dies but the backend survives, its
stdout/stderr pipes close — and transformers' tqdm weight-loading bar
crashes the entire model load with BrokenPipeError on the next write.
Fix: wrap sys.stdout/stderr in utils.hf_progress.SafeFileWrapper (the
same EPIPE-swallowing wrapper the patched hub tqdm already uses) at
startup. Logs are best-effort for a server process; model loading is
not. Progress bars stay alive — they feed the loading-progress UI via
hf_progress listeners, so disabling them was not an option.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three small high-leverage additions from the onboarding audit:
- trust line on the setup page footer — 'Everything runs and stays on
this machine — no account, no cloud, no telemetry.' The product's
thesis, stated at the moment the user decides.
- resume reassurance on the installing act and (while downloading) in the
model library — 'Interrupted downloads resume automatically — closing
the app is safe.' Kills unnecessary Clean&Retry panic; uv and the HF
hub both genuinely resume.
- ETAs on the long waits: the installing act derives an EMA byte-rate
from successive bootstrap-progress events; library rows aggregate the
per-file rates already on the SSE stream. Shown as '~3m left', only
while a total is known and progress is mid-flight.
3 new strings × 21 locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The wizard's final act rendered nothing on installs without the
build_demos.sh sample WAVs (they aren't committed or shipped — every
real install hits this). DictationDemo returned null whenever the asset
probe 404'd, hiding the hotkey card too, even though that card teaches
real things with zero assets: the registered shortcut and live
press-to-verify via the tray-dictate events.
Now only the replayable script cards gate on the bundled WAVs; the
hotkey card always renders, with a hotkey-only lede ('hold, speak,
release — press it now to verify') translated across 21 locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the AGPL relicense (abae6e2): the in-app Commercial License page
strings were updated in English only, leaving 20 locales describing the old FSL
model ("free for internal use, license required for competing products",
"converts to Apache-2.0 in two years" — now false).
- Translate the 5 reworded strings (enterprise.hero_desc/hero_note,
enterprise_faq.a_internal_tools/a_try_before/a_watermark) into all 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, reusing each locale's existing terminology (Settings → Privacy
path names, formality register).
- Remove the now-orphaned q_apache/a_apache keys everywhere (the renderer block
was already removed app-wide in 07479be's follow-up), restoring
enterprise_faq key parity with en.json across all locales.
- README: one-line macOS first-launch note under the download badges
(right-click → Open / Settings → "Open Anyway", no Terminal) linking to
docs/install/macos.md#gatekeeper-quarantine.
Translations are AI-generated and tone-matched to each locale's existing
strings — native-speaker review welcome.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Windows preview build dies in WiX with 'optional pre-release
identifier in app version must be numeric-only and cannot be greater
than 65535 for msi target' because the stamp was BASE-preview.N. Drop
the word: BASE-N is still a valid semver prerelease (sorts below the
stable BASE for the updater channel), unique per run, and MSI-legal.
Failed run: 27096586578 (Windows x64; macOS + Linux built fine but the
publish job was skipped).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): first-run install gate — nothing installs until the user confirms a plan
New `setup` module parks first runs in BootstrapStage::AwaitingSetup instead
of auto-installing. complete_setup validates the user's InstallPlan and only
then starts the existing bootstrap:
- install modes: installed (platform dirs) / portable (one folder next to
the exe / AppImage, config.json travels with it)
- user-chosen storage: env dir, data dir (OMNIVOICE_DATA_DIR), model cache
(OMNIVOICE_CACHE_DIR) — None = legacy default, byte-identical behavior
- minimum-space gate: per-volume free-space check (fs4 statvfs), grouped by
filesystem so dirs sharing a disk sum their requirements; install refused
when short (9 GiB env + 7 GiB models + 1 GiB data, measured + headroom)
- custom mirrors (PyPI index, HF endpoint, python-build-standalone) take
precedence over region presets in the venv/sync/backend env wiring
- ROCm torch variant selectable via config (env var still wins)
- existing installs migrate silently: venv present → setup_complete=true,
no questions re-asked; dev trees skip the gate entirely
19 unit tests (disk probing, space grouping, mirror validation, legacy
config compat).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): first-run setup screen — mode, storage with space gate, mirrors, compute
FirstRunSetup renders when the Rust side reports awaiting_setup (lazy-loaded;
regular launches pay nothing). One screen, defaults all work:
- language picker first (rest re-renders translated), 21 locales shipped
- Installed / Portable mode cards (portable disabled with reason when the
exe-adjacent folder isn't writable)
- storage rows with live per-path free-space probes (debounced
check_install_target), 'needs ~X / Y free' readouts, folder pickers
- client mirrors the Rust per-volume space gate: Start installation is
disabled with an explicit reason until every volume fits
- compute (CUDA-auto / ROCm), update channel, region + custom mirror URLs
- complete_setup errors surface inline; on success the normal bootstrap
progress UI takes over on the next status poll
Verified on a wiped machine: gate parks (no spawn, no downloads), screen
renders, 450 GB ≥ 17 GB requirement → Start enabled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): studio-console redesign of the first-run screen
The setup screen now reads as powering on studio hardware rather than a web
form — true to a voice studio, and self-sufficient offline (every font and
asset is bundled; a first run may be on a restricted network):
- breathing waveform masthead (CSS-only, deterministic speech-cadence
silhouette, staggered per-bar delays)
- Source Serif 4 display headline + engraved IBM Plex Mono panel labels +
Inter body — the three faces the app already ships
- rack-unit panels with corner screws, engraved title rules, serial plate
(OVS · vX.Y.Z)
- disk space as segmented LED capacity meters: lit = what the install
consumes, alarm-blink red on insufficient volumes
- mode cards with indicator LEDs; 'armed' Start button — LED lights and a
halo pulses only once every volume passes the space gate
- atmosphere: corner accent glows + SVG film grain; staggered rise-in
choreography on load
- all motion transform/opacity only; prefers-reduced-motion holds every
frame still; theme-token derived colors; focus-visible rings throughout
No logic changes: same IPC calls, same i18n keys, same space-gate math.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): wide desktop deck, hardware-aware Compute + Update channel cards
Three pieces of feedback addressed:
- width: the console is now a 1240px two-column deck (storage rail left,
decision rail right) that uses desktop real estate; collapses to one
column under 980px and stacks fully under 620px
- no outer chassis box: panels float directly on the atmospheric backdrop,
each carrying its own rack-unit treatment
- Compute and Update channel split into separate cards with real
information: get_setup_state now detects hardware (nvidia-smi → CUDA
name, /sys/class/drm vendor 0x1002 → AMD/ROCm, Apple Silicon → MPS,
CPU cores + RAM via sysinfo; best-effort, never blocks) — the Compute
card shows a live 'Detected: …' readout, badges the option that matches
the machine, and pre-selects ROCm on AMD boxes; both cards use LED
radio options with full descriptions (6 new i18n keys × 21 locales)
Also pins playwright-core as an explicit devDep — bun did not materialize
it through @playwright/test, breaking programmatic browser use.
20/20 Rust tests · vite build · CJK guard green. Verified live (gate
engaged, responsive single-column) and at 1600×1000 via mocked-IPC
browser shot (two-column deck).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): move network (region + mirrors) into the masthead with language
Language and download region are the two 'where am I' choices — they now
sit together top-right of the masthead, with the custom-mirrors disclosure
tucked beneath the subtitle. The Network panel is gone, leaving a balanced
deck: Install mode + Storage left, Compute + Update channel right.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): strip the boxes — fills and rules carry the structure
One design rule now: borders only where state demands them. Panels lose
their boxes entirely (engraved mono title + rule separates sections);
option cards, storage rows, selects/inputs, the hw readout, the version
plate and the ghost buttons are all flat fills; active options glow with
an accent tint + LED; blocked rows and errors use a red tint + 2px inset
edge bar instead of a border. The badge chip is fill-only too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): quiet pass — every element earns its visual weight
- waveform becomes a whisper: 22px trace, 2px bars, ~half opacity — an
ambient signature instead of a billboard
- storage readouts collapse to one mono line ('needs ~9 GB · 449 GB free');
the LED meter now appears only when it carries information (install
would consume >35% of free space, or the volume is blocked) — at 449 GB
free a bar was a meaningless sliver
- Change… buttons go text-quiet (transparent until hover)
- custom-mirrors disclosure right-aligns under the region select it
extends, instead of floating under the subtitle
- version plate moves to the footer next to the disk total — the masthead
keeps only title, subtitle, and the two locale/region selects
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): platform-matrix awareness — distro+arch detection, ROCm gated to Linux, no Windows console flash
The install matrix is OS family × distro × arch × GPU vendor, and the
setup screen now both shows it and only offers choices valid for it:
- HardwareInfo gains os_name (distro PRETTY_NAME from /etc/os-release on
Linux, macOS/Windows elsewhere) and arch (x86_64/aarch64) — the detected
line reads 'CachyOS x86_64 · NVIDIA RTX 4070 · 32×CPU · 31 GB RAM',
exactly what bug reports cite
- SetupState gains os; the ROCm option renders on Linux only (wheels
don't exist elsewhere) and complete_setup clamps rocm→auto on
non-Linux as the server-side backstop
- nvidia-smi probe gets CREATE_NO_WINDOW on Windows — no cmd flash on
the first screen a user ever sees
- Apple Silicon → MPS, Intel mac → CPU, ARM Linux → CPU: all matrix
cells resolve through the same base constructor
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): unify the whole first-run journey under the studio-console system
Setup → Installing → Model wizard now read as one continuous experience:
the same atmosphere, whisper waveform masthead, serif/mono type, LED
language and quiet fills across all three acts.
- Installing (BootstrapSplash): rebuilt in frs-* — segmented LED journey
meter (completed steps + live byte progress), LED step rail (done=green,
active=pulsing accent, pending=dim), engraved ACTIVITY panel with the
quiet mono log (collapse/copy as text-quiet actions), failure act with
red-tint error + hints + armed Retry. All logic untouched: stage poll,
event subscription + backfill, dedupe, hints, region/language selects.
- Model wizard (SetupWizard): same masthead with the step rail as engraved
mono LED steps top-right, welcome cards as option-card surfaces,
preflight as LED check rows (pass/warn/fail), frs nav buttons with armed
primaries, embedded Model Store / Engines / Dictation panels scroll
inside the act. Old 556-line stylesheet replaced by ~60 lines of glue;
BootstrapSplash.css reduced to a resolving stub.
- FirstRunSetup.css is now the journey's shared design system (step rails,
log panel, banners, hints, wizard chrome, check rows appended).
- 2 new strings (Installing / Activity) translated across all 21 locales.
Validated end-to-end on this machine: setup screen → Start installation →
real venv bootstrap (~10 min) → backend healthy on 3900 → model wizard.
20/20 Rust tests · vite build · CJK guard green · installing act verified
via mocked-IPC screenshot at stage=installing_deps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): --setup re-entry flag + make the install-plan screen un-stealable
The setup stage is first-run-only by design (completed installs skip it),
but it must be reachable on demand and must actually win the mount when
engaged. Three fixes:
- 'omnivoice-studio --setup' parks the bootstrap in AwaitingSetup on any
launch — checked before the attach-to-healthy-backend shortcut, so a
running backend can't skip past it
- App routing: awaiting_setup now outranks everything (a live backend
answering /setup/status used to route straight to the model wizard);
the wizard additionally requires stage === 'ready' so it can't mount
during the initial stage race
- useBootstrapStage: a transient IPC miss no longer permanently declares
'ready' (which killed the poll loop and silently skipped the setup /
progress screens) — it retries up to 5 ticks before conceding
Plus journey-wide titlebar clearance (content never sits under the GTK
headerbar / macOS traffic lights / Windows controls) and drag-region
mastheads on all three acts.
Verified: mocked-IPC harness with stage=awaiting_setup + a LIVE backend
answering /setup/status renders the setup screen, not the wizard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(setup): remove backdrop decoration — flat surface, state-only emphasis
The corner accent glows and SVG film grain rendered as visible banding /
noise artifacts on many panels — both gone; the journey now sits on a
clean flat chrome background. Also swept the remaining decorative bloom:
the active option card drops its glow shadow (flat accent tint + LED carry
the state), and the armed Start button loses its pulsing halo (the lit LED
already signals actionable). Remaining shadows are functional micro-detail
only: 6px LED glows, meter track inset, red edge bars.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): journey rail + verbosity diet — clean, smooth, elegant
The setup page is now visibly stage 1 of the install flow: a quiet
breadcrumb rail (SETUP → INSTALLING → MODELS & ENGINES) sits between the
waveform and the headline on both the setup and installing acts, LEDs
marking done/active/pending — one continuous story across the journey.
Verbosity halved without hiding information:
- option descriptions unfold (260ms ease) only on the selected card; the
page shows exactly one explanation per group, collapsed cards keep the
text as a tooltip
- storage rows drop their always-on caption (label + path + readout +
Change… on one line; caption lives in the row tooltip)
The whole page now fits a laptop window without scrolling.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): merge Models + Engines into one wizard act
Two tabs weren't necessary: models are the required gate, engines the
optional extras — now two stacked panels in a single 'Models & engines'
step (label reuses the journey-rail key, translated in 21 locales).
Wizard shrinks to 4 steps: Welcome → System check → Models & engines →
Dictation. Continue still gates on models_ready only; engines stay
optional. Welcome cards updated to the 3 remaining acts; static cards
keep their descriptions visible (the active-only fold is for radios).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(setup): wizard was skipped after first-run install — probe /setup/status on bootstrap ready
The models-needed probe started at mount with a ~30s retry ceiling. On a
first run, mount happens at the setup page — by the time the user reads
it and the multi-minute install finishes, the attempts were long burned,
so setupChecked landed as 'no wizard needed' and the studio rendered with
zero models on disk. The probe is now keyed on bootstrapStage and runs
when it hits 'ready' — the first moment a backend exists to answer.
Normal launches (backend up quickly) behave exactly as before.
Caught by running the full journey three times end-to-end: rounds 2–3
skipped Models & engines after install; with the fix the wizard mounts
with models_ready=false (Whisper large-v3 listed missing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): drop the Welcome step — wizard opens on System check
The welcome act had nothing left to say: the journey rail names the
stages, the setup page already oriented the user, and the cards repeated
both. The wizard is now three steps — System check (auto-runs on mount) →
Models & engines → Try dictation — landing the user directly on live
preflight results instead of a page about the pages to come.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(setup): true unified library — models + engines as ONE list
'Merge them' meant one list, not two panels stacked — fair criticism.
The wizard's Models & engines act is now a purpose-built WizardLibrary:
every installable is a row of the same grammar (LED · name · chip ·
size · action):
- required models lead (REQUIRED chip, Download action, live SSE
progress bar + percent, green LED when installed) — they gate continue
- TTS engines follow (ENGINE chip): active engine glows accent,
available ones offer one-click Use (selectEngine), heavy installs
defer honestly to Settings ('install later in Settings' + reason
tooltip)
- the optional-model tail folds behind 'Show N optional models'
The full management surface (search, HF token, deletes, sorting) stays
in Settings — a first run needs a checklist, not a store. 9 new strings
× 21 locales. Verified against the live backend via the browser harness:
required/installed/engine/active/Use/defer states all render in one list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the Functional Source License with the GNU Affero General Public
License v3 across the project, with a paid commercial license retained for
proprietary/closed-source use without AGPL obligations (open-core, like
Firecrawl).
- LICENSE: verbatim AGPL-3.0 text under an AGPL Notice + Scope header;
drops the FSL "Competing Use" framing and the 2-year Apache-2.0 conversion.
The bundled omnivoice/ TTS model stays Apache-2.0 upstream (AGPL-compatible).
- Manifests now declare SPDX AGPL-3.0-only: pyproject.toml, Cargo.toml
(normalized from bare AGPL-3.0), and both package.json (added license field).
- README.md / README_CN.md: badge, pricing, commercial-use FAQ, License section.
- en.json: in-app Commercial License copy reworded to AGPL; the false
"converts to Apache 2.0" FAQ removed (+ its renderer block in SupportPage.jsx).
Non-English locale strings still describe the old FSL model and are left for a
follow-up translation pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lets users insert pauses in the transcript: `[pause]` (350ms default),
`[pause 500ms]`, `[pause 1s]`, `[pause 1.5s]`. Requester confirmed the
`[pause Nms]` syntax (fits the existing marker style).
Implementation is fully opt-in and model-free:
- `omnivoice/utils/text.parse_pause_markers()` splits the text into
`(span, pause_ms_after)` tuples (case-insensitive; bare number = ms; `s`
suffix = seconds; adjacent markers sum; clamped to 10s). Text with no marker
returns unchanged, so existing behavior is untouched.
- `_run_inference` synthesizes each span as today and stitches a `torch.zeros`
silence buffer between them at the `[pause]` points (matching channel
dims/dtype/device); DSP/mastering then runs once over the combined audio.
An explicit overall `duration` isn't split across spans (left to the model
per span).
Tests (no TTS model loaded): tests/test_pause_markers.py covers the parser
(ms/s/default/clamp/leading/trailing/adjacent/round-trip) and the silence
stitching with a fake gen fn (lengths + zeroed regions). Full pause + CJK guard
+ router smoke suites pass (39).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a clip has multiple speakers, pyannote's auto-detect sometimes collapses
them into a single "Speaker 1" — so the transcript merges turns and the dub
mixes voices. The diarization-consumption side is correct (overlap-weighted,
distinct Speaker N ids — pinned by a new test), so the collapse comes from
auto-detect itself.
Add an optional speaker-count hint (the reporter's own suggestion):
- backend: `/dub/transcribe-stream/{job_id}?num_speakers=N` (clamped 1–20;
None → auto-detect) threaded to `diar_pipe(audio, num_speakers=N)`. Omitted
entirely when unset so we don't depend on the kwarg in every pyannote build.
- frontend: `dubNumSpeakers` store field + a compact "Speakers" number input
in the dub panel (placeholder "Auto") + i18n; `transcribeStreamUrl` appends
the param; the SSE hook reads the hint at stream-open time.
Tests: tests/test_assign_speakers_from_diarization.py (multi-speaker split,
overlap weighting, label robustness, empty-result safety) +
dub.transcribeUrl.test.ts (param appended only for a positive int). Full
backend diarization + frontend suites pass; CJK i18n guard passes.
Does NOT close#274 — pending the reporter confirming that setting the count
resolves the collapse on their video (can't verify pyannote behaviour without
a CUDA box + the clip).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Patch release. Version bumped across all sources + lock files; [0.3.5] CHANGELOG.
Ships:
- #270 — speaker diarization fixed on PyTorch >=2.6 (weights_only=True rejected
the pyannote checkpoint's TorchVersion global); the loader now registers the
shared safe-globals allowlist before loading.
Tagging v0.3.5 triggers release.yml (desktop) + docker.yml.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On torch>=2.6, `Pipeline.from_pretrained("pyannote/speaker-diarization-3.1")`
fails with "Weights only load failed ... Unsupported global: GLOBAL
torch.torch_version.TorchVersion" — PyTorch 2.6 flipped torch.load's default to
weights_only=True and its secure unpickler rejects the checkpoint's metadata
globals. This broke diarization on torch>=2.6 even when the license IS accepted
(reported on v0.3.4, RTX 4070 Ti, license accepted).
The WhisperX VAD load already solved this via
`WhisperXBackend._allow_vad_pickle_globals()` (allowlists TorchVersion,
omegaconf nodes, pyannote metadata, builtins, numpy, …). `get_diarization_pipeline`
just never called it. Reuse it before the diarization load — idempotent,
per-process, verified to register TorchVersion on torch 2.8.
Graceful fallback (silence-gap heuristic) is preserved if anything still fails.
Tests: tests/test_diarization_weights_only.py (allowlist runs before load;
no-token short-circuit). Existing diarization classification tests still pass.
Cross-platform (the torch 2.6 weights_only change affects all platforms).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Patch release. Version bumped across all sources + lock files; [0.3.4] CHANGELOG.
Ships:
- #255 — PyTorch-Whisper backend works as a standalone fallback (no cuDNN 8,
no OMNIVOICE_PRELOAD_TTS_ASR=1), unblocking Windows+NVIDIA users hitting the
cudnn_ops_infer64_8.dll error.
Tagging v0.3.4 triggers release.yml (desktop) + docker.yml.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Windows + NVIDIA users hit `Could not locate cudnn_ops_infer64_8.dll`:
WhisperX/faster-whisper run on CTranslate2, which needs cuDNN 8, but PyTorch
2.8 ships cuDNN 9 and the side-loaded `cudnn8_compat` libs were missing from
the venv. The PyTorch-Whisper backend should have been the fallback, but it
errored "set OMNIVOICE_PRELOAD_TTS_ASR=1" because it only worked when the TTS
model preloaded an ASR head.
- `PyTorchWhisperBackend._ensure_pipe()` now builds its OWN transformers ASR
pipeline on demand (PyTorch stack → cuDNN 9, no CTranslate2/cuDNN-8), without
loading the full TTS model and without the preload env var. A constructor-
passed pipe (when the TTS model already has one) is still reused. Model is
overridable via OMNIVOICE_PYTORCH_ASR_MODEL.
- dub_core transcribe preflight no longer hard-rejects pytorch-whisper when no
pipe is preloaded — it lazy-loads; any failure surfaces per-chunk with the
real cause.
So a Windows box without cuDNN 8 can switch ASR backend to "PyTorch Whisper"
in Settings → Models and transcription works. Docs: troubleshooting entry.
Tests: tests/test_pytorch_whisper_fallback.py (lazy standalone build, reuse of
a passed pipe, no get_model() call, env override). Full tests/ suite: 700 pass.
Does NOT close#255 — pending the reporter confirming the fallback works on
their machine; the cuDNN-8 install gap (faster-whisper path) is a follow-up.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Patch release. Bumps version across all sources + lock files; adds [0.3.3]
CHANGELOG.
Ships:
- #262 — Settings → About now shows the server's CPU architecture (was the
client browser's platform, e.g. "Win32", in Docker).
- Validates the bash-3.2 checksum CI fix on a real release (the macOS
SHA256SUMS should now upload automatically).
Tagging v0.3.3 triggers release.yml (desktop) + docker.yml (GHCR
:0.3.3/:0.3/:latest).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Settings → About → Architecture rendered `navigator.platform` — the *client
browser's* OS. In the Docker/web build that's the remote machine (e.g. "Win32"
when browsing from Windows), not the container, which is misleading.
Expose the server's `platform.machine()` as `arch` on /system/info and render
that instead, so the row reflects the machine OmniVoice actually runs on — for
both the desktop app (local backend) and Docker.
Note: the *blank* version/GPU/RAM/VRAM in the same report were the loopback-gate
403s fixed in v0.3.2 (#261); this PR fixes the remaining architecture row.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Compute SHA-256 checksums" step used `mapfile -t` (a bash 4+ builtin) but
macOS GitHub runners execute `shell: bash` as /bin/bash 3.2, which has no
`mapfile`. The step exited 127 ("mapfile: command not found") on the macOS leg,
so `SHA256SUMS-macOS Apple Silicon.txt` was never produced/uploaded for v0.3.1
and v0.3.2 (the binaries themselves shipped fine; only the macOS checksum file
was missing and had to be regenerated by hand each time).
Replace `mapfile` with a portable `while IFS= read -r … done < <(find … | sort)`
loop (works on bash 3.2). Verified on bash 3.2.57: builds the array correctly,
handles spaces in bundle filenames. Linux/Windows legs are unaffected.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Patch release. Bumps the version across all sources + lock files and adds the
[0.3.2] CHANGELOG section.
Ships:
- #261 — "Loopback origin required" 403s in the Docker admin UI (and blank
version): the image now runs in OMNIVOICE_SERVER_MODE so the loopback gate
is relaxed for the headless deployment; desktop loopback boundary unchanged.
Tagging v0.3.2 triggers release.yml (desktop) and docker.yml (GHCR
:0.3.2/:0.3/:latest).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In Docker the loopback origin gate (`require_loopback`) is unenforceable:
Docker's NAT rewrites `request.client.host` to the bridge gateway (e.g.
172.17.0.1) even for a localhost-only `-p 127.0.0.1:3900:3900` mapping, so every
request looks non-loopback. The gate then 403s the operator out of the routes
the web UI needs — `/system/*` (incl. `/system/info`, which left the version
blank, re-breaking #249 in Docker) and `/api/settings/*` (HF-token entry) —
surfacing as "Loopback origin required" all over the UI.
Fix: add an explicit, opt-in `OMNIVOICE_SERVER_MODE` flag. When set,
`require_loopback` becomes a no-op; exposure is then governed by the operator's
port mapping plus the optional share PIN (NetworkAccessMiddleware still 401s
unauthenticated non-loopback clients whenever a PIN is set). The Docker image
sets `OMNIVOICE_SERVER_MODE=1` (Dockerfile + documented in compose).
Security: the desktop build NEVER sets this, so its loopback boundary is
unchanged — LAN share guests are still denied the admin/system routes. New
unit tests lock the contract (strict 403 by default incl. the PR #81 vectors;
relaxed only under the flag). Existing non-loopback 403 tests still pass.
Docs: docker.md troubleshooting entry for "Loopback origin required".
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First tagged build of the 0.3 line off main. Bumps the version across all
sources (pyproject + frontend package.json + Tauri conf + Cargo + both lock
files + the source-checkout fallback in core/version.py) and adds the [0.3.1]
CHANGELOG section.
Ships:
- #256 — browser/Docker file-export crash (invoke undefined)
- #249 — version surfaced in web/Docker UI + desktop-only updater hidden
- #255 — transcribe stream now surfaces the real ASR/model-load failure
Tagging v0.3.1 triggers release.yml (desktop binaries) and docker.yml
(GHCR :0.3.1 / :0.3 / :latest).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(dub): surface real ASR/model-load failures instead of dropping the stream (#255)
When the transcribe SSE stream died before emitting any event, the UI showed a
misleading generic "Transcribe stream dropped before emitting any segments.
Likely ASR backend failed to load" — hiding the real cause (e.g. a faster-
whisper/CTranslate2 cuDNN load failure, or a missing pkg_resources).
The per-chunk transcribe was already wrapped, but two preflight/setup calls in
the stream generator were not — if either raised, the connection dropped with
no structured error event:
- `get_model()` (preflight) — now wrapped; failures emit a structured `error`
event built via `core.failure.build_failure` (sanitized reason + actionable
hint, e.g. the pkg_resources→setuptools hint).
- `offload_tts_for_asr()` — now non-fatal; an offload hiccup logs and continues
rather than killing the stream.
- The empty-segments guard now sanitizes each chunk error (no home-path/token
leakage) and appends the recognized-failure-class hint.
Adds a regression test: a raising `get_model()` must yield a structured `error`
SSE event carrying the real message, not a dropped connection.
Does NOT close#255 — this makes the underlying cause visible (pending the
reporter's backend log) rather than asserting a specific Windows-CUDA fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(dub): drive transcribe-stream gen directly (avoid cross-loop teardown)
The regression test for #255 hit the SSE streaming endpoint through TestClient,
whose lifespan created an asyncio Queue bound to a different event loop than the
streaming request — erroring at teardown in the full-suite run. Drive the
route's async generator directly instead: the preflight-error path yields a
single event with no executor/Queue, so it stays isolated from any app loop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
* 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>
* 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>
* 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>
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>
* 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>
* 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>
* 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>
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>
* 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>
* 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>
- 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>
* 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>
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>
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>
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>
- 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>
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>
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>
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>
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>
#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>
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>
* 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>
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>
* 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>
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>
* 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
1118 changed files with 185767 additions and 28275 deletions
**Usage**: Token-optimized CLI proxy for shell commands.
## Rule
Always prefix shell commands with `rtk` to minimize token consumption.
Examples:
```bash
rtk git status
rtk cargo test
rtk ls src/
rtk grep "pattern" src/
rtk find "*.rs" .
rtk docker ps
rtk gh pr list
```
## Meta Commands
```bash
rtk gain # Show token savings
rtk gain --history # Command history with savings
rtk discover # Find missed RTK opportunities
rtk proxy <cmd> # Run raw (no filtering, for debugging)
```
## Why
RTK filters and compresses command output before it reaches the LLM context, saving 60-90% tokens on common operations. Always use `rtk <cmd>` instead of raw commands.
description:Suggest an improvement or a new capability.
title:"[Feature] "
labels:["enhancement"]
body:
- type:checkboxes
id:preflight
attributes:
label:Before filing
options:
- label:I searched [existing issues](https://github.com/debpalash/OmniVoice-Studio/issues?q=is%3Aissue) and [discussions](https://github.com/debpalash/OmniVoice-Studio/discussions) for this idea.
required:true
- type:textarea
id:problem
attributes:
label:What problem does this solve?
description:The use case / friction this addresses ("When I … I can't …").
validations:
required:true
- type:textarea
id:solution
attributes:
label:Proposed solution
validations:
required:true
- type:textarea
id:alternatives
attributes:
label:Alternatives considered
- type:dropdown
id:area
attributes:
label:Area
options:
- Voice cloning
- Voice design
- Video dubbing
- Real-time dictation
- Audiobook / Stories (long-form)
- TTS/ASR engines
- Install / setup / packaging
- Other
validations:
required:true
- type:markdown
attributes:
value:|
> OmniVoice is **local-first** — features must work fully offline with no accounts,
API keys, or cloud calls, and behave identically on macOS/Windows/Linux. Proposals
description:Support OmniVoice and (optionally) claim a logo slot. Not for bugs or feature requests.
title:"Sponsorship inquiry: "
labels:["sponsor"]
body:
- type:markdown
attributes:
value:|
Thanks for considering sponsoring **OmniVoice Studio** 💛
OmniVoice is free, local-first, and AGPL-3.0 — sponsorship keeps development going.
See **[SPONSORS.md](https://github.com/debpalash/OmniVoice-Studio/blob/main/SPONSORS.md)** for tiers, placements, and logo guidelines.
Prefer to just donate? [Ko-fi](https://ko-fi.com/debpalash) (recurring) or [PayPal](https://paypal.me/palashCoder) (one-time) — you don't need this form for that.
- type:input
id:name
attributes:
label:Name or organization
description:How you'd like to be credited (person or company).
validations:
required:true
- type:input
id:website
attributes:
label:Website / link
description:The URL your name or logo should link to (homepage, product page, profile…).
placeholder:https://example.com
- type:input
id:logo
attributes:
label:Logo URL (optional)
description:Link to your logo (SVG preferred, else 2× PNG, transparent background). You can also attach it in the description below.
placeholder:https://example.com/logo.svg
- type:dropdown
id:tier
attributes:
label:Tier you're interested in
description:See SPONSORS.md for what each tier includes. Not sure? Pick "Not sure yet".
options:
- Backer
- Bronze
- Silver
- Gold
- Not sure yet — let's talk
- Custom / annual arrangement
validations:
required:true
- type:dropdown
id:method
attributes:
label:How you'd like to support
options:
- Ko-fi (recurring)
- Ko-fi (one-time)
- PayPal (one-time)
- Not sure yet — let's discuss
validations:
required:true
- type:input
id:contact
attributes:
label:How should we reach you?
description:Email or another contact. (GitHub will also notify you on this issue.)
validations:
required:true
- type:textarea
id:notes
attributes:
label:Anything else?
description:Questions, constraints, timeline, or context. Attach your logo here if you didn't link it above.
- type:checkboxes
id:ack
attributes:
label:Acknowledgements
options:
- label:I understand sponsorship is a thank-you, not a paywall — OmniVoice stays fully free and AGPL-3.0, and sponsors don't get gated features.
required:true
- label:If I provide a logo, I have the right to use it and grant OmniVoice permission to display it in the README, the app, and the project website.
- [ ] No local machine paths, logs, or personal env details in this PR
- [ ] Version files are in sync (if version bump): `pyproject.toml`, `package.json`, `tauri.conf.json`, `Cargo.toml`
- [ ] If this PR changes runtime behavior, the regression fixture at `tests/fixtures/omnivoice_data/` still loads green on the `smoke-matrix` CI job (macOS + Windows + Linux)
- [ ] If this is part of a release, I've read the "Release cadence" section below and confirmed this PR targets the right RC
## Release cadence (read once per RC)
## Release cadence
OmniVoice ships every minor on a **two-RC cadence**:
-`vX.Y.0-rc1` — cut from `main` once all GATE-* requirements pass; clean-VM exercise on 4 OSes (per `REL-01`)
- 48-hour soak (no new commits to release branch except fix-forward)
-`vX.Y.0` — promotion if rc1 is clean
If your PR touches install / bootstrap / CI, it MUST land before rc1 cut, not between rc1 and the promotion. During a soak, any merge needs explicit OK from the release captain.
## Screenshots
<!-- If applicable, add screenshots or recordings. -->
OmniVoice ships **continuous-to-main** — no release candidates, no soak windows.
Every merged PR is immediately part of the rolling preview (`main`, Docker
`:latest`, the desktop Preview channel). Versioned releases are tagged from
`main` when it's ready; `main` then bumps to the next patch automatically.
Users who want stability pin a release tag / Docker `:stable` / the desktop
echo "> 🧪 **Rolling preview build from \`main\`** — newest features, less tested. Opt in via **Settings → About → Update channel → Preview**; switch back to Stable any time."
echo ""
echo "$NOTES"
echo ""
echo "$CONTRIB"
} > /tmp/preview-notes.md
# --prerelease re-asserts the flag every run: a non-prerelease
# `preview` release is eligible to become GitHub's "Latest", which is
# the exact URL the Stable updater channel reads — so it must never
> **Superseded:** This chose `ModelsLab/omnivoice-singing` for singing, but that
> model has **no melody (F0/MIDI) conditioning** — it sings its own melody and
> cannot follow the *source song* a dub must preserve. SoulX-Singer (arXiv
> 2602.07803, published after this decision) provides F0/MIDI conditioning and is
> selected in plan-06. This ADR stays valid only if reframed as an
> expressive-TTS styling toggle, not melody-matched dubbing.
## Context
`ModelsLab/omnivoice-singing` (HuggingFace, 1,053 downloads/month, verified 2026-05-18) is a finetune of `k2-fsa/OmniVoice` — same Apache-2.0 license, same Qwen3-0.6B backbone, same Higgs Audio v2 codec at 24 kHz mono, same `omnivoice` PyPI library (0.1.5, 2026-04-28) already shipping in OmniVoice Studio v0.2.7. Trained on additional singing + emotion-tagged data and activated by a `[singing]` text control tag at generation time.
- "Xet is the default download backend and is provably engaged: /system/info reports fast_download.xet_enabled=true with the hf_xet version, and a Xet-backed repo downloads via parallel chunk range-gets (not single-stream LFS)."
- "Before any bytes flow, the UI shows an accurate denominator: total bytes to download, bytes already cached (skipped), and file count — sourced from snapshot_download(dry_run=True), not guessed from the first tqdm bar."
- "During a download the UI shows ONE overall progress bar with instantaneous speed (sampled over a window, not a single file's rate), bytes downloaded / bytes remaining, and ETA — accurate even while Xet fetches many chunks/files in parallel."
- "hf_transfer is NOT used or enabled anywhere (deprecated, breaks progress); the fast path is Xet only."
- "The custom segmented downloader is OPT-IN (default off), only engages for non-Xet/legacy-LFS repos, never forwards the HF Authorization header to the redirected CDN host, verifies the downloaded file against its expected size/etag before marking complete, resumes a partial .part file, and can be cancelled mid-flight."
- "Default download behavior is identical on macOS, Windows, Linux (Xet path, pure-Python). Every accelerator/mirror/high-perf knob is behind an explicit opt-in (Settings toggle or env var) per the cross-platform-parity strict rule — no bundled per-OS binary, no platform-divergent default."
- "No new on-disk model-state format; existing HF cache layout and already-installed models are untouched; the segmented downloader writes into the same HF cache blob/snapshot structure (or hands off to it) so a model it fetches is indistinguishable from one snapshot_download fetched."
to:"DownloadAggregator -> single 'aggregate' SSE event"
via:"tqdm_class forwards bytes into the aggregator; segmented path calls aggregator.add() directly"
pattern:"aggregate"
- from:"dispatch in install_model"
to:"segmented_download vs snapshot_download"
via:"prefs accelerator toggle AND repo-is-LFS classification (FDL-00 helper)"
pattern:"segmented_download"
- from:"system_info (system.py:245)"
to:"fast_download status block"
via:"probe hf_xet import + version + HF_XET_HIGH_PERFORMANCE"
pattern:"fast_download"
---
<objective>
Make model downloads as fast as possible AND show accurate speed / downloaded / remaining / ETA.
**Framing (validated by research — see 260613-fdl-RESEARCH below):** HuggingFace's **hf-xet** backend ALREADY implements the "IDM/uGet technique" — content-defined chunking, parallel byte-range fetches with adaptive concurrency, dedup, and automatic resume — and does it auth-safely. It ships by default in modern `huggingface_hub` and `hf_xet` is already installed here (huggingface_hub 1.7.2). HF closed the multi-connection-downloader feature request as "solved by Xet." So we do NOT build a custom segmented downloader as the default path; that would be redundant and would violate the cross-platform-parity rule.
What's actually missing:
1.**We don't drive Xet well.**`install_model` calls `snapshot_download(**dl_kwargs)` with no `max_workers`, no `tqdm_class`, no `dry_run`, and no explicit dependency pin — progress rides on a global tqdm monkeypatch.
2.**No pre-flight total**, so "downloaded/remaining" has no denominator until files appear, and aggregate speed is summed frontend-side from per-file events (inaccurate under parallel fetch).
3.**Legacy non-Xet (LFS) repos get zero intra-file parallelism** — this is the one place a real IDM-style multi-connection fetch still helps, so we add it as an OPT-IN accelerator.
Five waves, in order (each independently shippable, continuous-to-main per v0.3.0 cadence):
- **Wave 0 — Spike/gate (FDL-00):** classify every catalog repo Xet vs LFS. Sizes Wave 3's value; if ~all repos are Xet-backed, Wave 3 is low-priority polish.
- **Wave 1 — Maximize + guarantee Xet (FDL-01..04):** pin deps, drive snapshot_download explicitly, surface fast-download status, opt-in high-perf/HDD knobs. No new deps, all platforms.
- **Wave 2 — Accurate progress (FDL-05..07):** dry_run preflight + backend aggregate tracker + overall UI bar (speed/remaining/ETA). The biggest user-visible win.
- **Wave 3 — Opt-in segmented accelerator (FDL-08..09):** custom httpx Range downloader for LFS repos. Default OFF, opt-in toggle.
For every repo in backend/config/models.yaml (25 entries), determine whether it's Xet-backed or legacy Git-LFS. Use huggingface_hub: `HfApi().repo_info(repo_id, files_metadata=True)` and inspect each LFS blob for xet info, OR call the model-info endpoint and check the `xetEnabled`/blob `xet` field. For gated/unavailable repos, record "unknown (gated/offline)".
Write 260613-fdl-SPIKE.md: a table repo_id | role | backend (xet|lfs|unknown) | size, plus a one-line GO/LOW-PRIORITY verdict for Wave 3:
- If the majority of *user-facing default* models (OmniVoice TTS, the default ASR) are Xet-backed -> Wave 3 is LOW priority (xet already fast); still build it for the LFS long tail.
- If many defaults are still LFS -> Wave 3 is HIGH priority.
This is read-only network classification — do not download anything (use repo_info, not snapshot_download).
Today huggingface_hub arrives transitively (1.7.2) and hf_xet is present but unpinned. Add explicit runtime pins so the fast path can never silently disappear on a resolve:
- huggingface_hub>=1.7 (keep compatible with transformers>=5.3.0 already in deps)
- hf-xet>=1.1 (the Xet backend; 64-bit only — fine for all OmniVoice targets)
Do NOT add hf_transfer. Run `uv sync` then `uv tree huggingface_hub` to confirm a single resolved version (no duplicate). If a transitive constraint conflicts, prefer the higher version and note it in the SUMMARY.
In install_model's _do() (line ~148), build dl_kwargs with explicit, intentional args instead of the bare call:
- tqdm_class=<the TrackedTqdm class> so progress is deterministic and xet-aware rather than relying solely on the global monkeypatch. Expose TrackedTqdm from hf_progress (add a getter, e.g. hf_progress.tracked_tqdm_class()).
- max_workers: keep default 8 (don't crank — xet does intra-file parallelism; high max_workers multiplies buffer pressure). Make it prefs-overridable: prefs.resolve("download_max_workers", env="OMNIVOICE_DOWNLOAD_MAX_WORKERS", default=8).
- Keep the existing 5-retry backoff, heartbeat, and _validate_snapshot_has_weights.
Do not remove the global monkeypatch (other libs — transformers/mlx_whisper — still rely on it); this task just makes the install path drive its own tqdm_class explicitly.
- FDL-03: add a fast_download block to GET /system/info (system.py:245): {xet_enabled: bool, xet_version: str|None, high_performance: bool}. Probe by importing hf_xet (xet_enabled), reading its version, and reading the HF_XET_HIGH_PERFORMANCE env/pref. Must never throw (system_info is called on every Settings load). Log the same line once at startup ("fast download: Xet on (hf_xet X.Y), high_perf=...").
- FDL-04: opt-in knobs via prefs, applied at process/download setup (env wins):
When set, export the corresponding HF_XET_* env before the snapshot/segmented download runs. Both default OFF (high-perf can hurt low-RAM machines — surface that as a tooltip in Wave 2 UI).
Before the real download in install_model, run snapshot_download(repo_id, dry_run=True, endpoint=...) on the worker thread. From the returned per-file info compute: total_bytes, cached_bytes (files already present), to_download_bytes, n_files, n_cached. Emit a new phase event:
This gives the UI an accurate denominator and a "M GB already cached, N GB to download" line BEFORE bytes flow. Wrap dry_run in try/except — if it fails (older/gated repo), emit install_plan with totals=None and fall back to today's behavior (denominator fills in as files appear). dry_run must respect the 'resolving' heartbeat (it can take a couple seconds).
New backend/utils/download_aggregator.py: a per-repo DownloadAggregator that owns the TRUTH for overall progress, so the frontend stops summing potentially-misrouted per-file events.
- Seeded by the install_plan totals (total_bytes, n_files).
- Emits one throttled (~0.3-0.5s) phase:"aggregate" event via hf_progress.emit().
Wire it: hf_progress's TrackedTqdm._emit_progress already has per-file (filename, downloaded, total) — also feed those into the active repo's aggregator (look up by current_repo_id). The segmented downloader (Wave 3) calls aggregator.add() directly. Keep the per-file events too (UI detail view) — aggregate is additive, not a replacement.
</action>
<verify>
<automated>uv run python -c "from utils.download_aggregator import DownloadAggregator as A; a=A('r',total_bytes=100,files_total=2); a.set_file('f1',50,50); a.set_file('f2',25,50); s=a.snapshot(); print(s['bytes_done'], s['total_bytes'])"</automated>
</verify>
<done>DownloadAggregator sums bytes across parallel files, samples rate over a window, emits a single 'aggregate' event; fed by both tqdm and the segmented path.</done>
- setup.ts: extend SetupProgressEvent phase union with "install_plan" | "aggregate" and their fields (total_bytes, cached_bytes, to_download_bytes, n_files, n_cached, bytes_done, rate, eta_seconds, files_done, files_total).
- Settings.jsx ModelStoreTab: when an aggregate event arrives for a repo, render ONE overall progress row: a bar (bytes_done/total_bytes), instantaneous speed (format rate as MB/s), "X.X GB of Y.Y GB" downloaded/remaining, and ETA (mm:ss from eta_seconds). Seed the denominator from install_plan (show "M GB cached, N GB to download" before bytes flow). Keep the existing per-file rows as a collapsible "details" section instead of the primary display. Show a small "⚡ fast download" badge when /system/info fast_download.xet_enabled is true.
- Prefer the backend aggregate's rate/eta over the frontend's own per-file ETA computation (Settings.jsx ~614-631) — replace that local ETA math with the aggregate fields; keep a fallback if no aggregate event has arrived yet.
</action>
<verify>
<automated>cd frontend && bun run typecheck 2>&1 | tail -15</automated>
<done>UI shows one overall bar with live speed + downloaded/remaining + ETA from the aggregate event; per-file detail collapsible; fast-download badge; typecheck passes.</done>
New backend/services/segmented_download.py — an OPT-IN multi-connection Range downloader for ONE file (the IDM/uGet technique) used only for legacy-LFS repos where Xet gives no intra-file parallelism. httpx is already a dep.
1. HEAD (or GET Range: bytes=0-0) the resolve URL to learn size + Accept-Ranges + the redirect target. If server doesn't honor Range (Accept-Ranges != bytes) -> fall back to a single streamed GET (still works, just not parallel).
2. AUTH SAFETY (critical): send Authorization: Bearer <token> ONLY to the huggingface.co host. When the resolve URL 302-redirects to the CDN (cloudfront/etc.), do NOT forward Authorization to the CDN host — the presigned URL already carries auth. Follow redirects manually so you control header propagation per-host.
3. Split expected_size into num_connections ranges; download each with Range: bytes=start-end concurrently (asyncio + httpx.AsyncClient). Write to dest+".part" at the right offsets (preallocate, or per-range temp files then concat).
4. RESUME: if dest+".part" exists with a sidecar manifest of completed ranges, skip completed ranges.
5. CANCEL: check cancel_event between chunks; on cancel, leave the .part for resume and raise CancelledError.
6. VERIFY: after assembly, check size == expected_size and (if given) sha256/etag matches; only then atomically rename .part -> dest. On mismatch, raise (caller's retry/validate handles it).
Tests (use a local mock HTTP server / httpx MockTransport): honors Range + parallel assembly == single-GET bytes; falls back when Accept-Ranges absent; does NOT send Authorization to a different host on redirect; resumes from a partial .part; cancels and leaves resumable state; size/etag mismatch raises.
</action>
<verify>
<automated>uv run pytest tests/backend/services/test_segmented_download.py -q 2>&1 | tail -20</automated>
<done>segmented_download fetches a file via parallel ranges, is auth-safe across the CDN redirect, resumes, cancels, and verifies size/etag before commit; all tests pass.</done>
</task>
<task type="auto">
<name>Task 8 (FDL-09): Dispatch — accelerator for LFS repos only</name>
if accelerator_on and is_xet_backed(repo_id) is False:
-> resolve each LFS file's URL via hf_hub_url + HfApi file metadata, download via segmented_download into the HF cache layout (or download to a temp dir then place via the cache API so the result is a normal cache entry), feeding the same DownloadAggregator. Run _validate_snapshot_has_weights at the end.
else:
-> existing snapshot_download path (xet).
IMPORTANT: the segmented result MUST land in the same HF cache structure so /models install-state, delete, and is_cached() all keep working (truth: "indistinguishable from snapshot_download"). If matching the blob/snapshot symlink layout is too fiddly, the safe fallback is: segmented-download to a temp file, then hand the bytes to huggingface_hub so it finalizes the cache entry. Document the chosen approach in SUMMARY.
Default OFF -> zero behavior change unless the user opts in.
<automated>uv run pytest tests/ -k "download or install or model" -q 2>&1 | tail -20</automated>
</verify>
<done>With the toggle ON, LFS repos download via the segmented path into the normal HF cache; Xet repos and the default (toggle OFF) use snapshot_download; install-state/delete unaffected.</done>
- FDL-10: the endpoint= wiring from Task 2 already reads prefs hf_endpoint. Surface it as a setting and document (Task 10) that a mirror routes through the CLASSIC LFS path (no Xet) — so it pairs naturally with the FDL-08 segmented accelerator for speed on mirrors. No process-wide HF_ENDPOINT mutation; per-call endpoint only.
- FDL-11: add POST /models/install/cancel {repo_id} that sets the repo's cancel_event (segmented path) and, for the snapshot path, best-effort marks the install cancelled (snapshot_download isn't trivially cancellable mid-file — at minimum stop retries and emit install_cancelled). Compose with MM2-06: on success OR cancel, clear the _install_cooldowns entry so a cancelled download isn't rate-limited. Emit phase:"install_cancelled".
Per the docs-sync hard rule, document the user-facing surface introduced here:
- How fast downloads work (Xet on by default; what the ⚡ badge means; how to check via Settings/system info).
- Advanced toggles: high-performance mode (warn: needs RAM/bandwidth, can hurt low-RAM machines), HDD sequential-write, max workers, segmented accelerator (opt-in, for legacy-LFS repos), and the mirror/restricted-network HF_ENDPOINT setting (note: mirror = classic LFS, no Xet; pair with the accelerator).
- A short troubleshooting section (slow downloads, stuck at resolving, restricted networks/China).
Add a one-line pointer from README.md to docs/downloading-models.md. Do NOT enable any opt-in by default in docs examples.
<done>docs/downloading-models.md covers speed, status, all opt-in knobs, mirror/restricted-network, troubleshooting; README links it; no opt-in shown as default.</done>
</task>
</tasks>
<verification>
Gate per wave; full set before the last PR:
1.`uv run pytest tests/backend/setup/test_download_preflight.py tests/backend/services/test_segmented_download.py tests/ -k "download or install or model" -q` — green.
2. Live smoke (backend running): an install emits install_plan (accurate total/cached/remaining) THEN aggregate events with rising bytes_done + a non-zero rate + decreasing ETA; on completion bytes_done == total_bytes.
3. /system/info reports fast_download.xet_enabled=true with a version.
4. Auth-safety unit test proves Authorization is NOT sent to a non-huggingface.co host on redirect.
5. Default-off proof: with no opt-in set, an install uses snapshot_download (xet) — `OMNIVOICE_SEGMENTED_DOWNLOAD` unset means the segmented path is never taken.
6.`uv tree huggingface_hub` shows one version; no hf_transfer anywhere (`grep -ri hf_transfer backend/` is empty).
7.`cd frontend && bun run typecheck` passes.
8. Cross-platform parity: the default path (Xet, pure-Python) is identical on all 3 OSes; every accelerator/mirror/high-perf knob is opt-in (Settings/env). No bundled binary added.
</verification>
<success_criteria>
- Fast: Xet is pinned, engaged, and driven with explicit args; high-perf/HDD knobs available opt-in; legacy-LFS repos can use the opt-in segmented accelerator for real multi-connection speed.
- Accurate: UI shows pre-flight total/cached/remaining, then one overall bar with live speed + downloaded/remaining + ETA sourced from a backend aggregate (not frontend guesswork).
- Safe & compatible: no hf_transfer; segmented downloader is opt-in, auth-safe, resumable, verified, cancellable, and lands in the normal HF cache; default behavior identical on all 3 OSes; no new on-disk model state; existing installs untouched.
- All listed tests + typecheck pass; docs updated in the same PR (docs-sync rule).
</success_criteria>
<risks>
- **Segmented downloader auth leak (FDL-08) — highest risk.** Forwarding the HF Authorization header to the CDN host on redirect would leak the token. Mitigation: manual redirect handling, per-host header allow-list (Authorization only to huggingface.co), and a dedicated unit test asserting no Authorization on the CDN hop. This is a must-have truth, not optional.
- **Cache-layout mismatch (FDL-09).** If the segmented path writes files outside the HF cache blob/snapshot structure, /models install-state + delete + is_cached() break. Mitigation: prefer the temp-file-then-hand-to-huggingface_hub finalization approach over hand-rolling the symlink/blob layout; assert is_cached(repo_id) is true after a segmented install in a test.
- **dry_run cost/availability (FDL-05).** dry_run adds a metadata round-trip and may not exist for gated/older repos. Mitigation: try/except -> totals=None fallback to current fill-in-as-you-go behavior; keep the resolving heartbeat so the UI isn't blank during preflight.
- **Aggregate vs per-file double-count (FDL-06).** Feeding both tqdm per-file events and the aggregator risks the UI showing two competing numbers. Mitigation: aggregate is the single source of truth for the overall bar; per-file events only drive the collapsible detail view; the frontend's old per-file ETA math is removed (Task 6).
- **High-performance mode hurting low-RAM machines (FDL-04).** HF_XET_HIGH_PERFORMANCE can need ~tens of GB RAM. Mitigation: default OFF, opt-in only, tooltip warning in the UI.
- **Mirror + Xet confusion (FDL-10).** Users may expect Xet speed through a mirror; mirrors fall back to classic LFS. Mitigation: document explicitly; that's exactly why the segmented accelerator pairs with the mirror path.
- **Scope: do not let the segmented path become default.** It's opt-in for LFS repos only. Xet stays the default; making it default would regress dedup + violate the parity rule.
</risks>
<output>
Write 260613-fdl-SPIKE.md (Task 0) and 260613-fdl-SUMMARY.md when done. SUMMARY must record: the Xet-vs-LFS catalog breakdown and how it changed Wave 3 priority; the cache-finalization approach chosen for the segmented path (and the is_cached-after-segmented test result); the exact new SSE event shapes (install_plan, aggregate); which opt-in prefs keys + env vars were added; and the auth-safety test output. Note any "use judgment" decision an executor made.
Docs-sync (CLAUDE.md hard rule): docs/downloading-models.md + README pointer ship in the SAME PR as the user-facing toggles (Task 10). If the Settings UI gains the new toggles, the docs describing them land together.
As of mid-2026 the fast path is **hf-xet, on by default** in modern `huggingface_hub`. Xet is itself a chunk-level, content-defined, massively-parallel downloader with adaptive concurrency — it **is** the "IDM/uGet-style segmented download," done for you and dedup-aware. `hf_transfer` is **deprecated**. Rolling your own segmented downloader or bridging to aria2 is **not worth it as a default**; the only thing we must build is (a) better driving + progress UI and (b) an **opt-in** segmented path for the legacy-LFS long tail (repos Xet doesn't back).
Installed in this repo: `huggingface_hub 1.7.2`, `hf_xet` present. `snapshot_download` here supports `max_workers`, `tqdm_class`, `endpoint`, `dry_run` (confirmed via inspect).
## 1. hf-xet — USE (default, no action needed beyond pinning)
Content-defined chunks grouped into blocks ("xorbs") in a content-addressable store; download = send file SHA256 → get reconstruction metadata + presigned URLs → fetch needed xorb ranges **in parallel** → reassemble; already-present chunks skipped (dedup). Auto-used by `snapshot_download`/`hf_hub_download` for Xet-backed repos since huggingface_hub 0.32. 2–3× over Git-LFS, up to ~1 GB/s.
Knobs (defaults already tuned): `HF_XET_NUM_CONCURRENT_RANGE_GETS` (16), adaptive concurrency ON (max 64), `HF_XET_DATA_MAX_CONCURRENT_FILE_DOWNLOADS` (8), chunk cache disabled by default (better for pure download), `HF_XET_HIGH_PERFORMANCE=1` (opt-in max throughput, needs RAM/bandwidth), `HF_XET_RECONSTRUCT_WRITE_SEQUENTIALLY=1` (HDD). **64-bit only.**
`HF_HUB_ENABLE_HF_TRANSFER` flagged deprecated; Xet supersedes it. Historically **broke tqdm progress / had no callbacks** — directly conflicts with the accurate-progress goal. Successor for max throughput is `HF_XET_HIGH_PERFORMANCE=1`.
## 3. huggingface_hub native concurrency — USE defaults
`snapshot_download(max_workers=...)` = parallel FILES (default 8), orthogonal to Xet's intra-file chunk parallelism. For OmniVoice's 1–few-large-file models the win is mostly Xet's intra-file parallelism; don't crank max_workers (multiplies buffer pressure). Resume is automatic via cache + ETag (no `resume_download` flag to manage).
## 4. Custom IDM-style Range downloader — AVOID as default, BUILD as opt-in for LFS
`/resolve/<rev>/<file>` 302-redirects to CDN (Cloudfront) which honors Range + parallel byte-ranges. Catch: follow redirect, **do NOT forward Authorization to the CDN host** (presigned URL carries auth), verify ETag/sha256, auth on first hop only. Redundant vs Xet for Xet-backed repos (HF closed issue #3232 as "use Xet"), **but genuinely helps non-Xet/legacy-LFS repos** which get no intra-file parallelism. → our Wave 3 opt-in.
`aria2c -x16 -s16 -c --header="Authorization: Bearer <token>"` is 3–5× on plain LFS, but: no dedup (worse than Xet for Xet repos), per-OS GPLv2 binary to package (parity burden — would have to be opt-in anyway), stdout/RPC progress scraping. The custom httpx path covers the same need with no binary. → not bundled.
`HF_ENDPOINT=https://hf-mirror.com` redirects Hub traffic (standard for China). **Xet CAS/presigned URLs point at HF infra → mirrors generally don't serve the Xet protocol → traffic falls back to classic LFS** (no dedup, no Xet parallelism). So mirror and Xet fast-path are mutually exclusive; the realistic China stack is mirror + LFS + (our opt-in) segmented accelerator. → our Wave 4 opt-in, per-call `endpoint=` not process-wide.
Unlike hf_transfer, **Xet reports progress through the same tqdm interface**; huggingface_hub aggregates per-file/thread bytes into a shared bar and feeds the `tqdm_class` you pass. So `snapshot_download(tqdm_class=...)` yields reliable aggregate bytes/total/rate/ETA even under parallel fetch. `snapshot_download(dry_run=True)` returns per-file sizes + cached flags → use for "will download X of Y, N GB" preflight. Speed sampling tunable via `HF_XET_DATA_PROGRESS_UPDATE_INTERVAL` (200ms).
Pin `huggingface_hub>=1.7` + `hf-xet`; let Xet be the default (it IS the IDM technique). Drive `snapshot_download(repo_id, tqdm_class=OmniVoiceProgress, max_workers=8, endpoint=<opt-in mirror>)`; `dry_run=True` first for total/remaining; aggregate bytes in a backend tracker → one overall bar (speed/remaining/ETA). Opt-in only: `HF_XET_HIGH_PERFORMANCE` (max speed), `HF_XET_RECONSTRUCT_WRITE_SEQUENTIALLY` (HDD), a custom httpx **segmented downloader for legacy-LFS repos**, and an `HF_ENDPOINT` mirror (classic-LFS fallback). Never enable hf_transfer; never bundle aria2; never make the segmented path the default.
# SPIKE — FDL-00: Catalog Xet vs LFS classification
**Date:** 2026-06-13 · **Method:** HF API `GET /api/models/{repo}?expand[]=xetEnabled` (authoritative).
## Result: 25 / 25 catalog repos are Xet-backed
| backend | count |
|---------|-------|
| xet | 25 |
| lfs | 0 |
| unknown | 0 |
Every repo in `backend/config/models.yaml` — including both first-run defaults (`k2-fsa/OmniVoice` TTS, `Systran/faster-whisper-large-v3` ASR) — returns `xetEnabled: true`. Full list: all entries under TTS / ASR / Diarisation (k2-fsa, Systran×5, mlx-community×9, openai, nvidia×2, UsefulSensors×2, pyannote, OpenMOSS, KittenML, deepdml).
## Detection caveat (important for the executor)
The installed client is **huggingface_hub 1.7.2**, whose `repo_info(..., files_metadata=True)` siblings expose only `blob_id, lfs, rfilename, size` — **no `xet_file`, and no `xet_enabled` on the info object.** A first pass that inferred backend from siblings wrongly reported "0/25 xet, all LFS." Do **not** classify Xet status from `repo_info` siblings on this client version. The reliable signal is the Hub API `xetEnabled` expand field (used here) or `hf_xet` actually engaging at download time. Re-check after any `huggingface_hub` bump — newer versions surface `xet_enabled` directly.
## Verdict for Wave 3 (segmented accelerator): LOW priority
Because the entire current catalog is Xet-backed and `hf_xet` is installed, Xet already provides chunked parallel range-gets (the IDM/uGet behavior) for **every** model we ship. The custom segmented downloader (Wave 3) is therefore **not needed to speed up any current default model** — it remains valuable only for:
- the **mirror / restricted-network path** (Wave 4: `HF_ENDPOINT` falls back to classic LFS, no Xet), and
- any **future non-Xet repo** a user adds.
**Recommendation:** proceed with W1 (maximize/guarantee Xet) and W2 (accurate progress) as the real wins for today's catalog; keep W3 as opt-in, build it alongside W4's mirror path where it actually pays off. This matches the PLAN's original framing — confirmed, not changed.
## Consequence for W1/W2 framing
W1 "guarantee the Xet fast path" is correctly the primary lever: these repos download via Xet **only if** the client engages it (hf_xet installed ✓ + huggingface_hub recent ✓). The W2 live smoke test should confirm Xet is actually used (fast parallel aggregate progress on a real install), since `xetEnabled=true` is a Hub-side capability, not proof the client took the Xet path.
**W0 — spike (FDL-00).** Classified all 25 `models.yaml` repos via the HF API `xetEnabled` field → **25/25 Xet-backed** (incl. both first-run defaults). See `260613-fdl-SPIKE.md`. Verdict: Wave 3 (segmented accelerator) is **LOW priority** — Xet already gives parallel chunked transfer for every shipped model. Detection caveat recorded: `repo_info` siblings on hf_hub **1.7.2** expose no xet metadata; classify via the `xetEnabled` API field, not siblings.
**W1 — maximize + guarantee Xet (FDL-01..04).**
-`pyproject.toml`: pinned `huggingface_hub>=1.7` + `hf-xet>=1.1` explicitly (was transitive/unpinned); no `hf_transfer`. Resolves to hf_hub 1.7.2 / hf-xet 1.4.2, single version.
-`system.py`: `/system/info` now returns `fast_download {xet_enabled, xet_version, high_performance}`; logged once at startup.
**W2 — accurate downloaded/remaining + speed (FDL-05..07).**
- Preflight `snapshot_download(dry_run=True)` → `compute_plan()` → `install_plan` SSE event with `total_bytes / cached_bytes / to_download_bytes / n_files / n_cached`**before bytes flow**. Degrades to totals=None on gated/older repos.
- New `utils/download_aggregator.py`: one source of truth for overall progress. Fed by a byte-sink on the patched tqdm; distinguishes byte bars (unit 'B', keyed by bar id) from the "Fetching N files" count bar; emits one throttled `aggregate` event (bytes_done/total/windowed rate/eta/files).
- Frontend `Settings.jsx` + `setup.ts`: overall bar driven by the aggregate; bar % = `max(byte%, file%)`; shows cached-skip + files-progress; `⚡ fast download` badge from `/system/info`. i18n keys added to `en.json`.
## Rebase reconciliation (main disabled Xet)
Rebasing onto latest main surfaced that main now sets **`HF_HUB_DISABLE_XET=1`** (main.py) — a deliberate choice to force the classic LFS path because Xet's progress bypasses the tqdm hook (the exact limitation found here). Reconciled rather than fought:
-`fast_download` status now reports the **runtime truth**: `xet_installed` + `xet_active` (active = installed AND not disabled) + `xet_enabled` alias. Default `xet_active=false`; the ⚡ badge only shows when Xet actually runs. Startup log: `downloads: Xet disabled → legacy LFS …`.
- Docs rewritten: default backend is **legacy LFS for accurate progress**; Xet is opt-in via `HF_HUB_DISABLE_XET=0` (coarser progress). The hf-xet pin stays (harmless; ready for a future Xet progress hook).
- Net: W2's progress is the value either way; W1's "maximize Xet" is dormant by main's design, not removed.
## Decisions / "use judgment" notes
- **Xet progress limitation (verified by live smoke).** Under Xet + hf_hub 1.7.2 the per-file **byte** bars never advance `n` and never `close()` through our tqdm (Xet fetches chunks out-of-band). Only the **file-count bar** is live. So: mid-download the overall bar is **file-granular** (moves 0→N files), and `complete()` flushes `bytes_done` to the exact preflight total on success (verified: final `74420620/74420620`, files 4/4). True live byte-speed is only available on classic-LFS/mirror repos (W4). This is a real constraint, not a bug — documented here and worth surfacing in W4 docs.
- Per-file detail kept inline (existing single-line summary, now aggregate-sourced) rather than a new collapsible panel — limited risk; can revisit.
## Drive-by fix
-`download.py` imported no `os`, but `_validate_snapshot_has_weights` uses `os.walk` → latent `NameError` on every install. Added `import os`.
- **Mirror (FDL-10):** `snapshot_download(endpoint=…)` honours prefs `hf_endpoint` / env `HF_ENDPOINT` on both preflight and download — per-call, no process-wide mutation. Documented as the classic-LFS (non-Xet) path that restores continuous byte-speed.
- **Cancel (FDL-11):** `POST /models/install/cancel {repo_id}` sets a cancel flag checked at each retry boundary → emits `install_cancelled`, clears the cooldown (cancel ≠ failure). Limitation: an in-flight single-file fetch isn't interruptible in hf_hub 1.7.2; cancel lands at the next retry boundary. Frontend treats `install_cancelled` as a terminator (clears row + refetch).
Reprioritised from LOW to HIGH after the rebase: since main forces Xet off, the default path is single-stream legacy LFS, so a segmented downloader is the way to get **both** parallel speed and live byte progress.
-`services/segmented_download.py`: async multi-connection Range downloader for one file — parallel byte-ranges, resume (`.part` + manifest), per-segment short-read truncation guard, optional sha256/etag verify, cancel, single-stream fallback when the server won't range. **Auth-safe**: the HF `Authorization` header goes only to `huggingface.co`/`hf.co`; never forwarded to a CDN host on redirect (unit-tested).
- Dispatch (`download.py`): opt-in via prefs `segmented_downloader` / env `OMNIVOICE_SEGMENTED_DOWNLOAD` (default OFF). When on and Xet inactive, fetches each repo file into the HF cache mirroring `hf_hub_download` (blobs + snapshot symlinks + `refs/main`), feeding **real bytes** to the aggregator. Any failure falls back to `snapshot_download` — the accelerator can never break a correct install.
- Verified live (accelerator ON): real mid-download byte progress (1.5 KB → 71 MB, rate ramping to **16.6 MB/s**), final `bytes_done == total`, `/models` shows `installed: True`, delete frees the right bytes.
- Fixed a `complete()` double-count (was adding a full total on top of accumulated segmented bytes → 2×); now replaces byte bars so the sum is exactly total.
- Tests: `tests/backend/services/test_segmented_download.py` (7 cases) covering parallel range reassembly, single-stream fallback, the auth header reaching only the HF host (never a CDN), size/truncation rejection, cancellation, and byte-callback totals — plus an aggregator double-count regression.
- MM2-06 # _install_cooldowns bounded (evict on success + TTL); no unbounded growth
- MM2-07 # Snapshot weight validation is per-role, not one 5 MB magic number
- MM2-08 # Subprocess sidecars self-report VRAM in pong; panel shows real MB, not 0
- MM2-09 # scan_cache_dir() -> disk-walk fallback logs WHY it fell back (WinError#117/#118)
must_haves:
truths:
- "Switching the active TTS backend in Settings releases the outgoing engine's VRAM before the new one loads — verified by asserting the outgoing instance's unload() was called exactly once on switch."
- "TTSBackend.unload() is overridden by OmniVoiceBackend (drops model ref + free_vram) and by every SubprocessBackend subclass (routes to unload_sidecar); all overrides are idempotent and safe before first generate()."
- "/model/loaded never reports a model as loaded with a misleading unloadable flag: the ASR row's unloadable reflects whether it can actually be released independently of the TTS lifecycle."
- "services.model_lifecycle is the single import surface for list_loaded()/unload(id)/unload_all()/free_vram(); system.py routers call it instead of re-enumerating models inline."
- "Idle timeouts for the in-process model and subprocess sidecars resolve through core.prefs.resolve(... env=...) so an env var still wins and the Settings store can override; no module duplicates IDLE_TIMEOUT_SECONDS by hand."
- "_install_cooldowns cannot grow without bound: entries are removed on successful install and stale entries are evicted by TTL."
- "A live subprocess sidecar reports a non-zero vram_mb in /model/loaded when it actually holds GPU memory (pong carries the figure); CPU-only sidecars report 0 truthfully."
- "When scan_cache_dir() raises and the code falls back to the on-disk walk, the reason is logged at WARNING with the exception type (the #117/#118 WinError-448 path is no longer silent)."
- "uv run pytest tests/test_engines.py tests/backend/services/test_model_lifecycle.py tests/backend/services/test_subprocess_reaper.py tests/test_model_load_timeout.py passes."
- "No on-disk model state changes; no new runtime dependency added; behavior degrades gracefully (not errors) on MPS/CPU where VRAM APIs are sparse."
artifacts:
- path:"backend/services/tts_backend.py"
provides:"Active-instance reuse + unload-on-switch in get_active_tts_backend(); per-engine unload() overrides"
contains:"_active_instance AND (def unload)"
- path:"backend/services/model_lifecycle.py"
provides:"Facade owning list_loaded/unload/unload_all/free_vram across in-process + subprocess models"
contains:"def list_loaded AND def unload_all"
- path:"backend/api/routers/system.py"
provides:"Thin /model/loaded + /model/unload routers delegating to model_lifecycle"
via:"ping reply carries allocated VRAM measured inside the sidecar process"
pattern:"vram_mb"
---
<objective>
Clean up OmniVoice's model-management subsystem ("v2"). Today load / unload / list / free-VRAM each behave differently across three worlds — the in-process model (`model_manager.py`), the TTS backend registry (`tts_backend.py`), and subprocess sidecars (`subprocess_backend.py`) — with no single lifecycle owner. This produces one real user-facing bug (VRAM leak on engine switch), inaccurate VRAM/unloadable reporting, an unbounded cooldown dict, and a silent cache fallback.
This is **cleanup + correctness, not a rewrite.** The Wave 13 idle-reaper and the SubprocessBackend primitive are sound and stay. The `TTSBackend.unload()` contract already exists as a documented default no-op (`tts_backend.py:149`) explicitly deferred to "Phase 2"; this plan *is* that Phase-2 follow-through — wire the registry to call it, override it per engine, and unify the surrounding surface.
Three tiers, executed in order (each independently shippable, continuous-to-main per the v0.3.0 cadence):
Output: PRs on branches off `main` (one per wave is fine), each green on the listed pytest selection. No push until the orchestrator merges; tests added with each wave.
Out of scope (call out, do not touch): GPU-pool per-engine sizing (`model_manager.py:42`, `_GPU_VRAM_PER_JOB_GB`) and torch.compile tuning — those are performance, not cleanup, and carry regression risk against #278/#315.
The base-class `unload()` no-op already exists (tts_backend.py:149) with a documented contract. Override it where it matters. Do this BEFORE Task 2 — the registry switch (Task 2) calls these.
- OmniVoiceBackend (line 162): override `unload(self)`. Drop the local model ref (`self._model = None`) and, because OmniVoice shares the singleton owned by model_manager, also release that: `import services.model_manager as mm; mm.model = None; mm.free_vram()`. Idempotent — guard on `mm.model is not None` before free_vram(). Safe before first generate() (no-op when nothing loaded).
- Every SubprocessBackend subclass: implement `unload(self)` on the SubprocessBackend base (subprocess_backend.py — the duck-typed `_is_subprocess_isolated` class) so all subclasses inherit it. It must call `unload_sidecar(self.id)` (force-shut this engine's sidecar; busy sidecars are skipped, never interrupted — existing semantics). Idempotent: unload_sidecar on a non-running engine returns 0, no raise.
- In-process non-OmniVoice engines that hold their own model (e.g. KittenTTS/VoxCPM2 keep refs in __init__): override unload() to drop the ref + best-effort empty_cache via the existing free_vram() helper if they used GPU. Where an engine genuinely holds nothing resident, leave the base no-op (and note it in the SUMMARY so the future CI gate knows it's intentional, not missed).
Honor the contract comment verbatim: idempotent, synchronous, safe pre-load.
- OmniVoiceBackend.unload() drops both self._model and mm.model and calls free_vram(), guarded for idempotency.
- SubprocessBackend.unload() routes to unload_sidecar(self.id); inherited by all subprocess engines.
- Calling unload() twice, and before any generate(), never raises.
</done>
</task>
<task type="auto">
<name>Task 2 (MM2-01): Registry reuses one active instance + unloads on switch</name>
<files>backend/services/tts_backend.py</files>
<action>
Fix the leak at get_active_tts_backend() (line 1235). Today it builds a fresh instance every call with no teardown of the prior engine — switching engines (or repeated synth) leaks VRAM until GC. This is the root cause behind the #278 comment thread.
- Add a module-level cache: `_active_instance: TTSBackend | None = None` and `_active_instance_id: str | None = None`.
- In get_active_tts_backend(): resolve `bid = active_backend_id()`. If `_active_instance is not None` and `_active_instance_id != bid`, call `_active_instance.unload()` (best-effort, wrap in try/except so a bad unload can't block the switch — log on failure) before discarding it.
- Build the new instance, store it as `_active_instance` + `_active_instance_id = bid`, return it.
- IMPORTANT subtlety: OmniVoiceBackend takes `model=`. When `model=` is passed (the caller already has a loaded model), do NOT cache that instance as the shared `_active_instance` blindly — it's a per-call view over the shared singleton. Keep current behavior for the `model=` path (return a fresh OmniVoiceBackend(model=model)) but still trigger unload() of a *different* outgoing engine first. Pick the simplest correct rule: the cache tracks the configured backend id; passing model= for the SAME id reuses, switching id always unloads the previous. Document the rule in a comment.
- Add a module-level `reset_active_backend()` helper that unloads + clears the cache, for app shutdown and tests.
- Switching backend id calls the outgoing instance's unload() exactly once before the new instance is built.
- A bad/raising unload() is caught + logged, never blocks the switch.
- reset_active_backend() exists and is idempotent.
- The model= fast-path for OmniVoice still works (no double-load).
</done>
</task>
<task type="auto">
<name>Task 3 (MM2-03): Honest /model/loaded + /model/unload for ASR</name>
<files>backend/api/routers/system.py</files>
<action>
The ASR row (system.py:166-175) is reported as unloadable:False, vram_mb:0 even when loaded on GPU, and /model/unload doesn't expose the offload-to-CPU path. Make reporting truthful WITHOUT changing the ASR<->TTS lifecycle coupling (that coupling is intentional — offload_tts_for_asr/restore_tts_after_asr).
- ASR row: keep unloadable reflecting reality. If ASR truly cannot be released independently of TTS, keep unloadable:False but add a `note` field ("released with TTS") so the UI explains it rather than showing a dead button. Do not invent a separate ASR unload that breaks the WhisperX large-v3 offload path.
- vram_mb: if ASR currently runs on CPU (device "cpu" in the row), 0 is correct — leave it but make the device value derive from where the pipe actually is, not a hardcoded "cpu".
- This task is intentionally small; the bigger restructure is Task 4 (facade). Land MM2-03 as the honest-reporting fix, then Task 4 moves the enumeration into the facade.
</action>
<verify>
<automated>uv run pytest tests/test_engines.py -q 2>&1 | tail -15</automated>
</verify>
<done>
- No row reports loaded-but-with-a-misleading-unloadable flag; ASR carries an explanatory note when unloadable:False.
- Device field reflects the actual device of the ASR pipe.
Add tests proving the leak fix and the unload contract:
- test_switching_backend_unloads_previous: monkeypatch two fake backends into _REGISTRY, set active to A (get_active_tts_backend), switch prefs to B, assert A.unload() was called exactly once before B is returned.
- test_unload_is_idempotent_and_preload_safe: OmniVoiceBackend().unload() twice + before generate() never raises.
- test_reset_active_backend_clears_cache: after reset_active_backend(), the next get_active_tts_backend() builds fresh.
Reuse the existing fixture style in tests/test_engines.py (it already monkeypatches the registry / availability). Keep tests CPU-only (no real model load).
</action>
<verify>
<automated>uv run pytest tests/test_engines.py -q 2>&1 | tail -20</automated>
</verify>
<done>All three new tests pass; existing test_engines.py tests still green.</done>
Create backend/services/model_lifecycle.py as the single owner of cross-world model lifecycle. It composes the existing pieces — it does NOT reimplement loading.
Public surface:
- list_loaded() -> list[dict]: returns the unified rows currently assembled inline in system.py:129-207 (TTS, ASR, diarization, subprocess sidecars). Move that logic here verbatim first, then improve (MM2-03 note field, MM2-08 sidecar vram once Task 8 lands).
- unload(model_id: str) -> dict: the dispatch currently inline in system.py:210-242 ("tts" | "diarization" | "sidecar:<id>" | "sidecars"). Move here; keep async-lock semantics for the in-process model (mm._model_lock).
- unload_all() -> dict: unload every releasable model (in-process TTS + diar + all sidecars). New convenience used by app shutdown.
- free_vram(): thin re-export of model_manager.free_vram() so callers have one import.
Keep the "never let sidecar enumeration break the panel" try/except guard.
</action>
<verify>
<automated>uv run python -c "import services.model_lifecycle as ml; print([f for f in ('list_loaded','unload','unload_all','free_vram') if hasattr(ml,f)])"</automated>
</verify>
<done>model_lifecycle exposes list_loaded/unload/unload_all/free_vram; logic moved out of system.py (not duplicated).</done>
- Replace the bodies of GET /model/loaded (line 129) and POST /model/unload/{model_id} (line 210) with calls to model_lifecycle.list_loaded() / model_lifecycle.unload(model_id). Preserve the exact response shapes (frontend hooks.ts useModelStatus/useFlushMemory + the flush dropdown depend on {models, count} and {unloaded, success, ...}). The 400 on unknown model_id stays.
- New tests/backend/services/test_model_lifecycle.py: list_loaded with nothing loaded returns {models:[], count:0}; unload("tts") when not loaded returns success:False reason:"not loaded"; unload("sidecars") with no sidecars returns count:0; unknown id raises/400 path. Mock model_manager + subprocess_backend so no real models load.
</action>
<verify>
<automated>uv run pytest tests/backend/services/test_model_lifecycle.py -q 2>&1 | tail -20</automated>
- model_manager.py: remove the duplicated `_IDLE_TIMEOUT_SECONDS = IDLE_TIMEOUT_SECONDS` (line 114). Resolve at use-site in idle_worker() via prefs: `prefs.resolve("idle_timeout_seconds", env="OMNIVOICE_IDLE_TIMEOUT_S", default=IDLE_TIMEOUT_SECONDS)`. Keep core.config.IDLE_TIMEOUT_SECONDS as the default source.
- subprocess_backend.py: replace the env-only `SIDECAR_IDLE_TIMEOUT_S` (line 107) read with prefs.resolve("sidecar_idle_timeout_seconds", env="OMNIVOICE_SIDECAR_IDLE_TIMEOUT_S", default=300.0). Preserve "<=0 disables reaping" semantics and the existing reaper-start guard (line 222). Resolve lazily (function call), not at import, so a test/setting change takes effect — but keep a sensible cached default for the hot reaper loop.
- Both must keep env precedence (env wins over store) — that's exactly what prefs.resolve already does.
Sidecar VRAM is reported as 0 (system.py:192-203 / list_live_sidecars) because the parent can't measure a child's GPU memory. Have the child measure itself.
- In the sidecar worker's ping handler (the code that answers {"op":"ping"} with {"op":"pong"} — find it in the sidecar entry-point module), include `vram_mb`: measure inside the child via torch.cuda.memory_allocated() (CUDA) or torch.mps.driver_allocated_memory() (MPS, guarded), else 0. Same degrade-gracefully pattern as system.py:147-156.
- Parent: in the health-check ping/pong path (subprocess_backend.py:435-438), capture reply["vram_mb"] and stash it on the sidecar record so list_live_sidecars() (line 181) can surface it. Refresh opportunistically on each successful ping; default to last-known or 0 if never measured.
- Keep the contract that enumeration never breaks the panel.
This is CUDA/MPS-aware and degrades to 0 on CPU — honoring cross-platform parity (default behavior identical; the number is just more accurate where the API exists).
<automated>uv run pytest tests/backend/services/test_subprocess_reaper.py -q 2>&1 | tail -15</automated>
</verify>
<done>list_live_sidecars() exposes a vram_mb sourced from the child's own measurement; 0 only when truly CPU/unmeasured; reaper tests still green.</done>
- MM2-06: _install_cooldowns (line 27) grows unbounded. On a successful install, delete the repo's cooldown entry. Add a TTL sweep: when reading/writing the dict, evict entries older than a fixed window (reuse the existing cooldown window constant; pick the larger of cooldown-window and e.g. 1h). Keep it simple — a dict + timestamps, swept on access. No new dep.
- MM2-07: _validate_snapshot_has_weights (line 55) + _MIN_WEIGHT_BYTES 5 MB (line 45) is one magic number for all roles. Make the threshold per-role/per-extension: safetensors/bin/ckpt expect the existing floor; .onnx models (kittentts, supertonic, sherpa) can be legitimately smaller — set a lower, role-aware floor so a valid small ONNX model isn't flagged as truncated. Keep the #352 truncation-catch intent (catch a 0-byte / KB-sized partial), just stop false-positiving small-but-complete models.
The scan_cache_dir() -> _scan_cache_on_disk() fallback (~line 268-280, helper at line 177) silently swallows the exception — this is the #117/#118 Windows WinError-448 path. Wrap the fallback so it logs at WARNING with the exception type and a one-line reason ("scan_cache_dir failed (%s); falling back to on-disk walk of %s") before walking. Do not change the fallback behavior itself — just stop it being invisible in logs. Keep it from ever raising out (the panel must still render).
<done>The disk-walk fallback logs a WARNING naming the exception type; behavior otherwise unchanged; never raises out.</done>
</task>
</tasks>
<verification>
Full-suite gate after each wave (run the relevant subset per wave, full set before the last PR):
1.`uv run pytest tests/test_engines.py tests/backend/services/test_model_lifecycle.py tests/backend/services/test_subprocess_reaper.py tests/test_model_load_timeout.py tests/test_model_manager_preload.py -q` — all green.
2.`uv run pytest tests/ -k "download or install or model or engine" -q` — green (Tier 3 touch points).
3. Response-shape guard: GET /model/loaded still returns {models, count}; POST /model/unload returns {unloaded, success, ...}; 400 on unknown id. (Covered by test_model_lifecycle.py.)
4. No new runtime dependency: `git diff pyproject.toml uv.lock` is empty.
- Tier 1: switching the active backend releases the previous engine's VRAM (unload() called once on switch); contract overridden for OmniVoice + all subprocess engines; ASR reporting is honest. (MM2-01..03)
- Tier 2: services.model_lifecycle is the single lifecycle surface; system.py routers are thin delegations with unchanged response shapes; idle/timeout config flows through prefs.resolve with env precedence and no duplicated constants. (MM2-04..05)
- Tier 3: cooldown dict bounded; weight validation is per-role; sidecars self-report real VRAM; cache-fallback logs its reason. (MM2-06..09)
- All listed pytest selections pass; no on-disk model-state change; no new dep; cross-platform default behavior identical (VRAM numbers degrade gracefully on MPS/CPU).
</success_criteria>
<risks>
- **unload() correctness for the shared OmniVoice singleton (MM2-01/02):** OmniVoiceBackend shares model_manager's `model` global. unload() must release the shared singleton, but the idle_worker() + offload_tts_for_asr() paths also touch it. Risk: a switch during an in-flight ASR offload double-frees or races. Mitigation: take mm._model_lock around the shared release in unload(); guard on `mm.model is not None`; keep unload best-effort (try/except) so it can never wedge a switch. Add the idempotency test (Task 4).
- **Response-shape drift (MM2-04):** Moving /model/loaded + /model/unload bodies into the facade risks changing the JSON the frontend depends on (hooks.ts, flush dropdown). Mitigation: move verbatim first, assert shapes in test_model_lifecycle.py, only then layer MM2-03/08 improvements.
- **Sidecar protocol change (MM2-08):** Adding vram_mb to pong touches the parent/child wire format. Older sidecars (a long-running session mid-upgrade) won't send it. Mitigation: treat vram_mb as optional in the parent (`reply.get("vram_mb", <last-known or 0>)`); never require it; never break the existing pong==success check.
- **prefs.resolve at import time (MM2-05):** Resolving timeouts at import freezes them; the reaper loop reads SIDECAR_IDLE_TIMEOUT_S. Mitigation: resolve lazily inside the reaper tick / idle_worker tick (cheap) so a settings change takes effect, while keeping the import-time default for the start-guard.
- **Per-role weight floor (MM2-07):** Lowering the ONNX floor could let a genuinely-truncated ONNX through (#352 regression). Mitigation: keep a non-zero floor for every role (e.g. ONNX floor still >> a partial KB), key on extension, and keep the "largest file" heuristic — only the threshold becomes role-aware.
- **Scope creep into perf:** GPU-pool sizing and torch.compile are explicitly out of scope. If an executor is tempted, stop — those regress #278/#315.
</risks>
<output>
Write `.planning/quick/260613-mm2-clean-model-management-v2/260613-mm2-SUMMARY.md` when done (per wave or once at the end), documenting: which engines got real unload() overrides vs intentional no-ops (for the future CI gate), the exact response shapes preserved on the two endpoints, the per-role weight-validation thresholds chosen, and the pytest output for the verification selection. Note any decision an executor made where the plan said "use judgment."
Docs-sync check (CLAUDE.md hard rule): this is internal lifecycle cleanup with no user-facing install/Docker/versioning change, so no README/docs edit is expected. If MM2-05 surfaces the new idle-timeout settings keys in the Settings UI, add them to the relevant settings doc in the same PR.
**Date:** 2026-06-13 · **Scope:** all 3 tiers (MM2-01..09). Backend-only; no frontend, no on-disk model-state change, no new deps.
## Tier 1 — correctness
- **MM2-01 (VRAM leak on engine switch):** `get_active_tts_backend()` now caches one instance per configured backend id and calls the outgoing engine's `unload()` before switching. Added `reset_active_backend()` for shutdown/tests. The `model=` OmniVoice fast-path still returns a fresh view over the shared singleton (no double-load) but a switch *away from* another engine still releases it. `tts_backend.py`.
- **MM2-02 (per-engine unload()):** `OmniVoiceBackend.unload()` drops the local ref + the shared `model_manager.model` singleton + `free_vram()` (idempotent, preload-safe, best-effort — no async lock from the sync path). `SubprocessBackend.unload()` routes to `unload_sidecar(self.id)` (busy sidecars skipped) and is inherited by every subprocess engine.
- **MM2-03 (honest ASR row):** `/model/loaded` ASR row now reports the pipe's actual device and carries a `note: "released with the TTS model"` so the disabled unload button is explained rather than silent.
## Tier 2 — single lifecycle surface
- **MM2-04 (`services/model_lifecycle.py`):** new facade owns `list_loaded()` / `unload(id)` / `unload_all()` / `free_vram()` across in-process TTS+ASR, diarization, and sidecars. `system.py``/model/loaded` + `/model/unload` are now thin delegations; **response shapes preserved exactly** (`{models,count}`, `{unloaded,success,...}`, 400 on unknown id) — frontend untouched.
- **MM2-05 (unified idle config):** removed the duplicated `_IDLE_TIMEOUT_SECONDS`; the in-process idle timeout and the sidecar idle timeout both resolve per-tick via `prefs.resolve(... env=...)` (env wins, settings can tune without restart). New keys: `idle_timeout_seconds` (`OMNIVOICE_IDLE_TIMEOUT_S`), `sidecar_idle_timeout_seconds` (`OMNIVOICE_SIDECAR_IDLE_TIMEOUT_S`). `<=0` still disables sidecar reaping.
## Tier 3 — robustness & observability
- **MM2-06 (bounded cooldowns):** `_install_cooldowns` is swept (TTL 1h) on each install check and cleared on success — can no longer grow unbounded.
- **MM2-07 (per-role weight floor):** `_validate_snapshot_has_weights` uses per-extension floors (tensor formats keep 5 MB; `.onnx` floor 64 KB) **OR** the original ≥5 MB catch — strictly more lenient, so a small-but-complete ONNX model is no longer false-flagged as truncated while a 0/KB partial is still rejected (#352 intact).
- **MM2-08 (sidecar VRAM self-report):** the parent can't see a child's VRAM, so the GPU sidecar (`engines/indextts`) now reports `vram_mb` in its `pong` (CUDA/MPS-aware, 0 on CPU); the parent stashes the last-known figure and `list_live_sidecars()` surfaces it. CPU/absent sidecars honestly report 0.
- **MM2-09 (cache-fallback logging):** the `is_cached``scan_cache_dir → on-disk` fallback now logs at WARNING with the exception type (was DEBUG/invisible) — the #117/#118 WinError-448 path is triagable from logs.
## Out of scope (as planned, not done)
GPU-pool per-engine sizing (`_GPU_VRAM_PER_JOB_GB`) and torch.compile tuning — perf, not cleanup; risk regressing #278/#315.
MM2 tests live at top-level `tests/` (not `tests/backend/`) on purpose: adding files under `tests/backend/` reorders collection and can expose a pre-existing `sys.modules`-isolation leak in other backend fixtures (the issue debugged in the FDL PR). Top-level placement keeps `tests/backend/` order identical.
## Docs-sync
The new idle-timeout settings keys are internal env/prefs knobs with no UI surface, so no README/docs change is required by the docs-sync rule. If a future Settings panel exposes them, document there.
| [voicebox](https://github.com/jamiepine/voicebox) | ~40.2k | MIT | Very active, post-viral triage debt | **Direct competitor** — same stack, same pitch, 10x the audience |
## 1. Real-Time-Voice-Cloning — the retired ancestor
The 2019 SV2TTS implementation ("clone a voice in 5 seconds") that created the
DIY voice-cloning category. Explicitly retired: the maintainer said in 2020 he
won't develop it again; the README now calls itself old and redirects users to
OmniVoice Studio is an open-source, fully-local ElevenLabs alternative — a desktop app for voice cloning, voice design, video dubbing, and real-time dictation across 646 languages. It runs entirely on the user's machine (CUDA/MPS/ROCm/CPU auto-detect), with no API keys, no accounts, and no cloud dependencies. Today it's a v0.2.7 active beta with a growing user base who hit it with real workloads (50-video batches, multi-engine setups, edge-OS platforms) and report friction in GitHub Issues and Discord.
OmniVoice Studio is an open-source, fully-local ElevenLabs alternative — a desktop app for voice cloning, voice design, video dubbing, and real-time dictation across 646 languages. It runs entirely on the user's machine (CUDA/MPS/ROCm/CPU auto-detect), with no API keys, no accounts, and no cloud dependencies. It's an active beta with a growing user base who hit it with real workloads (50-video batches, multi-engine setups, edge-OS platforms) and report friction in GitHub Issues and Discord. The current version lives in `frontend/package.json` (the single source of truth — see Versioning); the latest stable tag is on the [Releases page](https://github.com/debpalash/OmniVoice-Studio/releases/latest). With `AUTO_VERSION_BUMP` off (the current owner setting), `main` holds at the released version between releases.
**Core Value:****A first-run that actually works.** A user who downloads the installer (or clones the repo) should reach a working voice-cloning or dubbing output without hitting a wall — and when something does go wrong, the error or docs should tell them exactly what to do.
@@ -16,184 +16,42 @@ Everything else (new engines, fancy features) is downstream of "the thing instal
- **Default features must work on every platform (strict rule, 2026-05-20):** A feature that ships in default mode — out-of-the-box, no user customization, no opt-in toggle — must behave identically on macOS, Windows, and Linux. Platform-specific *implementation code* is allowed for OS APIs / shells / packaging, but the user-visible *default behavior* cannot diverge. Platform-only features (e.g., a macOS-only global shortcut, a Windows-only path picker) must go behind explicit user opt-in: Settings toggle, env var, or CLI flag. When a default doesn't work on a platform, that's a P0 bug — either fix it on the missing platform or move it behind opt-in. No third option.
- **Backward-compatible project data**: Existing `omnivoice_data/` (user voices, projects, settings) must keep working without manual migration. Any DB schema change goes through alembic with a tested upgrade path.
- **Local-first guarantee preserved**: Auto bug reporting (new addition) must be **opt-in**, must submit only to GitHub Issues (no third-party telemetry endpoint), and the app must remain fully functional with reporting disabled. No required cloud calls, accounts, or API keys.
- **Beta release cadence (no RC, no ceremony — strict rule, 2026-05-20):** v0.3.0 has **no release candidates, no 48h soak, no formal release ceremony**. Every fix goes continuous-to-main. Tag `v0.3.0` once when the user calls "actually useful" — a qualitative bar, not a checklist. No `v0.3.0-rc1`. No phased release. No `v0.4` deferrals while v0.3.0 is open — every open issue and every open community PR gets absorbed into the v0.3.0 line or explicitly declined. Users follow `main` for previews; users wanting stable stay on `v0.2.7`. ROADMAP.md's Phase 6 "Release/Verify/Retro" entries are obsolete unless the user revives them.
- **Beta release cadence (no RC, no ceremony — strict rule, 2026-05-20):** the v0.3.x line has **no release candidates, no 48h soak, no formal release ceremony**. Every fix goes continuous-to-main; the owner tags a patch (`v0.3.Z`) from main whenever the current state is worth cutting. No `-rc` tags. No phased release. No `v0.4` deferrals while the v0.3.x line is open — every open issue and every open community PR gets absorbed into the v0.3.x line or explicitly declined. Users follow `main` for previews; users wanting stable stay on the latest tagged release. ROADMAP.md's Phase 6 "Release/Verify/Retro" entries are obsolete unless the user revives them.
| `huggingface_hub` (already pinned transitively by `transformers>=5.3.0`) | `≥1.12.x` (latest 2026) | Auth + cache + token storage | Canonical, used by every HF library already in the stack. `HfFolder` is **superseded** in v1.x by the higher-level `login()` / `auth_list()` / `auth_switch()` API. |
| `keyring` (Python) | `≥25.x` | Optional OS-keychain backing | Only adopt if a future hardening pass wants Keychain/Credential-Manager/SecretService. **Not recommended for this milestone** — adds a native dep (`dbus`, `pywin32`) per platform with no real security win over `0600` file storage in `HF_HOME`. |
| GitHub REST API `POST /repos/{owner}/{repo}/issues` | `2026-03-10` API version | Server-side issue creation | Official, stable. Requires auth. |
| **Prefilled-URL pattern** (`github.com/{owner}/{repo}/issues/new?title=…&body=…&labels=…`) | n/a | Zero-auth fallback | **This is the recommended primary path for v0.3.x.** No token needed, no GitHub App registration needed, user's browser opens with a prefilled form, they review and click Submit. They own the issue, the OSS project gets the report, and OmniVoice never holds a credential. |
| `gh-app-jwt` + GitHub App (Rust crate `octocrab` or Python `pygithub`) | only if we later want fully-automated submission | Programmatic posting under an app identity | **Defer to a later milestone.** Requires registering a public GitHub App, hosting a token-exchange endpoint, and managing rate-limit quotas — disproportionate for stabilization scope. |
| `platform`, `psutil`, `torch.cuda` (already in deps) | already pinned | Capture OS, CPU/GPU/VRAM info | No new deps. |
| `httpx` (already in `dev-dependencies`, promote to runtime if needed) | `≥0.28.1` | HTTP for the API call path (if/when we add auth) | Modern async-first, already used in test suite. |
- ✓ No token storage in OmniVoice → no security surface
- ✓ Opt-in by definition (user has to click Submit on github.com)
- ✓ User owns the issue → can be replied to, edited, closed by them
- ✓ Zero infra cost — no proxy, no app, no rate-limit management
- ✓ Works identically on macOS / Windows / Linux via Tauri's `shell.open`
- ✓ Survives our project being forked (just change the URL)
- OS name + version (`platform.platform()`)
- Python version (`sys.version`)
- OmniVoice version (`pyproject.toml`)
- Backend git SHA (if installed from source) or installer build ID
- CPU model, RAM (`psutil.cpu_count()`, `psutil.virtual_memory()`)
- File paths containing `/Users/<name>/` (strip home dir → `~/`)
- HF token, OpenAI keys, any env var matching `*TOKEN*|*KEY*|*SECRET*`
- [GitHub URL query parameters for issues](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/creating-an-issue#creating-an-issue-from-a-url-query) — HIGH confidence
- [sindresorhus/new-github-issue-url](https://github.com/sindresorhus/new-github-issue-url) — HIGH (widely used reference impl)
- [GitHub REST API: Create an issue](https://docs.github.com/en/rest/issues/issues#create-an-issue) — HIGH confidence (for the future auto-submit path)
- [sentry-tauri](https://github.com/timfish/sentry-tauri) — reviewed, **rejected for milestone** due to local-first constraint
| `UV_PYTHON_INSTALL_MIRROR` env var | uv `0.4.x`+ | Override python-build-standalone download URL | **Official, current.** Replaces `https://github.com/astral-sh/python-build-standalone/releases/download/...` in download URL construction. No built-in fallback if mirror fails. |
| `UV_PYTHON_PREFERENCE=only-system` (or CLI flag `--python-preference only-system`) | uv `0.4.x`+ | Skip the python-build-standalone download entirely; use the user's system Python | **The reliable escape hatch** when no mirror works. Requires a compatible Python `>=3.11` to already be on PATH. |
| `UV_HTTP_TIMEOUT`, `UV_HTTP_CONNECT_TIMEOUT`, `UV_HTTP_RETRIES` | uv `0.4.x`+ | Tune retry behavior for flaky links | Defaults are 30s / 10s / 3 — bump to 120s / 30s / 5 for restricted networks. |
# Pseudocode for the bootstrap
# Final fallback: don't download Python at all
-`UV_DEFAULT_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple` (Tsinghua — fastest in China)
- Russia: no major government-blessed PyPI mirror; users typically tunnel via VPN. Document this honestly rather than ship a broken default.
- [uv environment variables reference](https://docs.astral.sh/uv/reference/environment/) — HIGH (official)
- [uv issue #5224 — python-build-standalone mirror support](https://github.com/astral-sh/uv/issues/5224) — HIGH (the feature was added)
- [uv issue #14187 — venv on Chinese network](https://github.com/astral-sh/uv/issues/14187) — HIGH (confirms real user pain, no built-in fallback)
- [uv python-versions concepts](https://github.com/astral-sh/uv/blob/main/docs/concepts/python-versions.md) — HIGH (documents `python-preference` semantics)
- [dautovri/mirrors-china](https://github.com/dautovri/mirrors-china) — MEDIUM (community-maintained mirror list; verify each URL still works before shipping)
### Capability 4 — Supertonic-3 TTS Engine
| Technology | Version | Purpose | Why Recommended |
| `supertonic` (PyPI) | `1.3.1` (latest, May 18 2026 — Phase 3 Wave 1 to verify constructor signature before bump) | Official Supertonic-3 inference SDK | Authoritative wrapper from Supertone Inc. Wraps the ONNX session orchestration so we don't have to. |
| `onnxruntime` | `≥1.17.x` (any recent) | ONNX inference runtime | Already a transitive dep of WhisperX (via CTranslate2 path is separate, but `onnxruntime` itself ships for kittentts and audioseal). Verify with `uv tree` after adding — should resolve cleanly. |
| `huggingface_hub` (already pinned) | `≥1.12.x` | Model weight download (~400 MB on first use) | Reuses existing HF token + cache infrastructure. The user's existing `HF_TOKEN` (Capability 1) works for the Supertonic model download too. |
| `numpy`, `soundfile` (already pinned) | already pinned | Audio I/O + array math | No new deps. |
| Plain Markdown in `docs/` + GitHub-rendered (current state) | n/a | Install tutorial, troubleshooting | Zero new infra. Renders inline on GitHub for issue-replies. No build step to break. |
| Existing `scripts/smoke-test.sh` + Playwright `tests/` (already in `package.json`) | already pinned | Verify install paths actually work | **This is the real solution to "docs drift."** If smoke-test exercises the install path described in docs, docs that drift will break CI. |
| **Future** (defer): Astro Starlight | `≥0.30` | Standalone docs site at `docs.omnivoice.studio` | Adopt only when docs exceed ~20 markdown files and need search/versioning. Tauri, the framework OmniVoice already depends on, uses Starlight — well-traveled choice. Material for MkDocs entered maintenance mode in November 2025 per Docsio's 2026 review — **avoid** for new docs. |
| Project | What they do |
|---------|--------------|
| **OBS Studio** | Docs at `obsproject.com/docs` (Sphinx, separate repo). Install paths in README, wiki for community-contributed. CI doesn't gate on docs drift. |
| **Audacity** | Manual at `manual.audacityteam.org` (MediaWiki). README is minimal. Install path = "use the installer." No automated sync. |
| **Tauri** | Docs at `v2.tauri.app` (Astro Starlight, separate repo `tauri-apps/tauri-docs`). README is minimal. Heavy reliance on community contributions and PR review. |
| **VS Code** | Docs at `code.visualstudio.com/docs` (separate repo, Markdown). README is minimal. Manual sync; docs team is staffed. |
- [Tauri docs (Astro Starlight)](https://github.com/tauri-apps/tauri-docs) — HIGH (reference for "if we ever move off README")
- [OBS Studio docs](https://docs.obsproject.com/) — HIGH (Sphinx, separate site reference)
- [Audacity Manual](https://manual.audacityteam.org/) — HIGH (MediaWiki reference)
- [Docsio: Material for MkDocs 2026 review (maintenance mode)](https://docsio.co/blog/mkdocs-material) — MEDIUM (third-party review, but signal aligns with project's own GitHub activity)
- [Docsio: Starlight 2026 review](https://docsio.co/blog/starlight-docs) — MEDIUM
## Installation
# No new Python dependencies needed for Capabilities 1, 2, 3, 5.
# Only Capability 4 adds a runtime dep:
# Verify no regressions:
# Should show single versions of each; no duplicates.
## Alternatives Considered
| Recommended | Alternative | When to Use Alternative |
| HF token via in-app Settings → `huggingface_hub.login()` | OS keyring via `keyring` package | Only if a security hardening milestone later demands OS-native credential storage. Not worth the cross-platform native-dep cost for v0.3.x. |
| Prefilled-URL GitHub Issues | GitHub App + device flow + authenticated POST | When milestone budget can afford registering a public GitHub App and hosting a token-exchange function. Defer. |
| Prefilled-URL GitHub Issues | Sentry / `sentry-tauri` | Never — violates the "no third-party telemetry endpoint" constraint in PROJECT.md. |
| `UV_PYTHON_INSTALL_MIRROR` chain + `only-system` fallback | Bundle Python in the Tauri installer | Adds ~30 MB to every installer for ~5% of users. Revisit if the bootstrap is still a top complaint in v0.4. |
| In-repo Markdown docs | Astro Starlight standalone site | When docs grow past ~20 pages and need full-text search. Tauri provides a precedent if/when we get there. |
| In-repo Markdown docs | MkDocs / Material for MkDocs | **Avoid** for new sites — Material for MkDocs is in maintenance mode as of Nov 2025. |
## What NOT to Use
| Avoid | Why | Use Instead |
|-------|-----|-------------|
| `HfFolder.save_token()` directly | Older API; v1.x `login()` does the same plus git-credential integration and is the documented path | `huggingface_hub.login(token=val, add_to_git_credential=False)` |
| Setting `HF_TOKEN` via shell rc files as the *only* persistence mechanism | Different per OS, fragile, opaque to the user, breaks in installer-launched processes that don't source shell rc | Write to `$HF_HOME/token` via `login()`. Document env var as override only. |
| `setx` for HF token persistence | Doesn't propagate to current shell; common source of "I set it but it's empty" bug reports | `[Environment]::SetEnvironmentVariable(...,"User")` in PowerShell, or the in-app Settings field |
| PAT-based GitHub Issues posting from OmniVoice | Would require shipping or asking for a token; breaks local-first promise | Prefilled-URL pattern (user submits from their browser) |
| `hf_transfer` for downloads | Deprecated in favor of `hf-xet` per HF docs | Default `huggingface_hub` (uses `hf-xet` automatically when available) |
| `--python-preference managed` (default) without mirror config in restricted-network installers | Hits GitHub CDN, times out, user sees raw `uv` error | Configure `UV_PYTHON_INSTALL_MIRROR` + retry chain + `only-system` final fallback |
| Material for MkDocs as a *new* docs choice | Entered maintenance mode November 2025 | If docs site is eventually needed, use Astro Starlight (Tauri precedent) |
## Stack Patterns by Variant
- Set `UV_PYTHON_INSTALL_MIRROR` to one of the gh-proxy URLs at install time
- Set `UV_DEFAULT_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple` (China) or document VPN requirement (Russia)
- Fall back to `UV_PYTHON_PREFERENCE=only-system` if all mirrors fail
- Default path: in-app Settings field → `login()` → file at `$HF_HOME/token`
- Power-user path: `export HF_TOKEN=...` in shell rc (documented but not promoted)
- Both paths are read at HF library import time; env var wins on conflict
- Default path: in-app "Report a bug" → prefilled GitHub Issues URL → user reviews + submits in browser
- All optional capture toggles default ON except "include reproduction file" (privacy)
- No path posts to any URL except `github.com/{owner}/{repo}/issues/new` (rendered locally as a URL, opened via `shell.open`)
-`uv add supertonic` → new TTSBackend subclass in `backend/services/tts_backend.py`
- Auto-detected and added to the engine picker in Settings
- ~400 MB model download on first synthesize call, cached in `$HF_HUB_CACHE`
- Existing IndexTTS/CosyVoice/etc. installs are untouched (no shared model weights)
## Version Compatibility
| Package A | Compatible With | Notes |
|-----------|-----------------|-------|
| `supertonic@1.3.1` | `onnxruntime>=1.17`, `numpy>=1.24`, `huggingface_hub>=0.20` | All deps already satisfied transitively by current `pyproject.toml`. |
| `huggingface_hub>=1.12` | `transformers>=5.3.0` (current pin) | `HfFolder` retained as deprecated alias; `login()`/`get_token()` are the canonical APIs. |
| `uv>=0.5` | `UV_PYTHON_INSTALL_MIRROR`, `UV_PYTHON_PREFERENCE` | Both env vars stable since uv 0.4.x. |
| Tauri v2 + `@tauri-apps/api/shell` | `shell.open()` for the prefilled-URL pattern | Already in the desktop app; no new permission needed beyond what the existing "open external link" plugin grants. |
## Sources
- [Hugging Face Hub environment variables](https://huggingface.co/docs/huggingface_hub/en/package_reference/environment_variables) — HIGH (verified against v1.12.1 docs, current 2026)
- [Hugging Face Hub authentication API](https://huggingface.co/docs/huggingface_hub/en/package_reference/authentication) — HIGH (verified `login()` is the canonical 1.x API)
- [Microsoft `setx` reference](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/setx) — HIGH (confirms "current shell" gotcha)
- [PowerShell `about_Environment_Variables`](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_environment_variables) — HIGH
- [uv environment variables reference](https://docs.astral.sh/uv/reference/environment/) — HIGH (verified all mirror + retry env vars)
- [uv issue #14187 — venv on Chinese network](https://github.com/astral-sh/uv/issues/14187) — HIGH (confirms user pain, justifies fallback chain)
- [uv `python-preference` semantics](https://github.com/astral-sh/uv/blob/main/docs/concepts/python-versions.md) — HIGH
- [Supertone/supertonic-3 model card](https://huggingface.co/Supertone/supertonic-3) — HIGH (official, 99M params, 31 languages, OpenRAIL-M)
- [supertone-inc/supertonic GitHub](https://github.com/supertone-inc/supertonic) — HIGH (official inference API)
- [supertonic 1.3.1 on PyPI](https://pypi.org/project/supertonic/) — HIGH (released 2026-05-18, MIT code license; bumped from 1.2.3 after Phase 3 research)
- [onnx-community/Supertonic-TTS-ONNX](https://huggingface.co/onnx-community/Supertonic-TTS-ONNX) — HIGH (ONNX file structure)
- [GitHub Docs: Authenticating to the REST API](https://docs.github.com/en/rest/authentication/authenticating-to-the-rest-api) — HIGH
- [GitHub Docs: Generating a user access token for a GitHub App](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app) — HIGH (device flow reference)
- [sindresorhus/new-github-issue-url](https://github.com/sindresorhus/new-github-issue-url) — HIGH (canonical prefilled-URL reference impl)
- [sentry-tauri](https://github.com/timfish/sentry-tauri) — MEDIUM (reviewed, rejected on PROJECT.md constraint, not on quality)
- [dautovri/mirrors-china](https://github.com/dautovri/mirrors-china) — MEDIUM (community-maintained, verify URLs are still live before pinning in production)
- [Tauri 2 docs (Astro Starlight reference)](https://v2.tauri.app/) — HIGH (precedent for docs framework if we ever migrate)
- [Docsio: Material for MkDocs entered maintenance mode Nov 2025](https://docsio.co/blog/mkdocs-material) — MEDIUM (third-party review, but signal aligns with the project's own GitHub commit activity)
The May-2026 stack research that used to live here served five capabilities that have all since shipped (HF-token Settings panel, prefilled-URL bug reporting, uv mirror fallback for restricted networks, the Supertonic-3 engine, in-repo Markdown docs). Follow the patterns in the code itself; the durable *don'ts* that research established:
- **No third-party telemetry endpoints, ever** (`sentry-tauri` was evaluated and rejected) — bug reporting stays opt-in via prefilled GitHub-issue URLs.
- **No PAT/token-based GitHub posting from the app** — the user submits from their own browser.
- **Don't recommend `setx` for env vars on Windows** (silent truncation, no current-shell propagation) — use the in-app Settings panel or PowerShell `[Environment]::SetEnvironmentVariable`.
- **Don't adopt Material for MkDocs** for any future docs site (maintenance mode since Nov 2025) — Astro Starlight is the precedent if docs ever outgrow the repo.
- **`hf_transfer` is deprecated** — default `huggingface_hub` (hf-xet) handles downloads.
For anything new: prefer what's already pinned in `pyproject.toml` / `frontend/package.json`, and check `uv tree` for conflicts before adding a dependency.
**Versioning (hard rule):** Everything ships on `v0.3.0`. Never mention, suggest, or label anything with a version bump — no v0.4, no RCs, no "defer to next version", no future-version labels — unless the user explicitly asks to bump. Zero unprompted version chatter.
**Versioning (hard rule, owner-set 2026-06-11; single-source 2026-06-16):** main is always **latest release + 1 patch**. **`frontend/package.json` is the SINGLE SOURCE OF TRUTH for the app version** — vite injects `__APP_VERSION__` from it (first-run footer + every auto bug report), and `frontend/src-tauri/tauri.conf.json` reads its bundle version from it (`"version": "../package.json"`, so the MSI/dmg/updater version can't drift from the UI). Three toolchain-required **mirrors** are kept equal to it and bumped in lockstep — `frontend/src-tauri/Cargo.toml` + `pyproject.toml` (cargo/uv need a literal) and `backend/core/version.py`'s `_FALLBACK_VERSION` (the frozen-backend last resort; at runtime the backend reads its version from package metadata via `importlib.metadata`, which `backend.spec`'s `copy_metadata('omnivoice')` makes work in the frozen build too). Never hand-edit any mirror or re-hardcode a literal in `tauri.conf.json`. Guarded by `tests/test_app_version.py` (`test_all_version_files_in_lockstep` + `test_tauri_version_derives_from_package_json`). The moment `vX.Y.Z` is released, bump `package.json` (+ the mirrors) to `X.Y.(Z+1)`. Consequences:
- Every PR and preview build identifies as the **next** version. Preview builds stamp `X.Y.(Z+1)-N` (run number), which semver-sorts **above** the last stable `X.Y.Z` — the updater ordering is natural, no comparator tricks needed.
- Releasing = tag `vX.Y.(Z+1)` from main (version files already match), then immediately bump main to `X.Y.(Z+2)`. **Owner override (2026-07-01): the post-release bump is now MANUAL — the `version-bump` job in release.yml is opt-in behind the `AUTO_VERSION_BUMP` repo variable (default off), so `main` stays at the released version until the owner explicitly asks to bump.** (Historically the bump auto-ran; re-enable that by setting `AUTO_VERSION_BUMP=true`.) When pinned, `main` == the released tag; preview-build ordering and "release + 1" only resume once a bump is requested.
- Docker: `ghcr.io/debpalash/omnivoice-studio:latest` = **main** (rolling preview); `:X.Y.Z` + `:X.Y` + `:stable` = tagged releases. `:latest` is the preview channel by design — stable users pin `:stable` or a version tag.
- Do not bump minor/major or invent RCs/codenames without the owner asking. No "defer to next version" labels — scope is absorbed or declined, never re-versioned.
**Docs-sync (hard rule, owner-set 2026-06-11):** any change that alters something these docs describe — README.md, CONTRIBUTING.md, SECURITY.md, SUPPORT.md, LICENSE, or `docs/**` (install flows, Docker tag semantics, platform support, versioning/release behavior, review process, supported versions) — must update those docs **in the same PR** as the change. If a doc impact is discovered after merge, the docs fix is the immediate next commit, not backlog. Stale docs are treated as bugs.
**Release notes / changelog (hard rule, owner-set 2026-06-16):** every tagged release gets a **high-quality, user-facing `## [X.Y.Z] — DATE` section in `CHANGELOG.md`** before (or in the same hour as) the tag — never the "Auto-generated release for vX.Y.Z…" fallback. `release.yml` extracts that section verbatim as the GitHub Release body (the `Extract CHANGELOG section for tag` step), so a missing/empty section ships a bare release. Quality bar = the existing house style: a one-paragraph headline, then `### Added` / `### Fixed` / `### Changed` / `### License` / `### CI` subsections; each entry is a **bold one-line lead** (what the user gets), 1–3 lines of plain-English why, and the `(#NNN)` issue/PR ref — grouped by theme, written for users, **not** raw commit dumps. This applies to **preview builds too**: preview release notes summarize what's new on `main` since the last stable, in the same style. Workflow: as features merge, keep `## [Unreleased]` current; at release time rename it to the version + date. If a release was already cut with the fallback body, the next action is to backfill `CHANGELOG.md`**and**`gh release edit <tag>` the live body — not backlog.
**Localization (hard rule):** No hardcoded non-English (CJK) **user-facing text** anywhere in the codebase except the translation layer (`frontend/src/i18n/`). All UI strings go through i18n (`t('...')` keys in `locales/*.json`); native language names live in `i18n/index.ts` (`LANGUAGES`). Functional CJK is allowed and tracked via the allowlist in `tests/test_no_hardcoded_cjk.py` — text-processing regexes, model/engine vocabulary & identifiers (e.g. CosyVoice speaker IDs), localized error matching, demo/eval data, and test fixtures. CI fails on any hardcoded CJK outside the allowlist; to add legitimate functional CJK, extend `_ALLOWED_FILES` there with a justification.
**Fix quality (hard rule, owner-set 2026-06-16):** Fix issues *properly* and future-maintenance-proof — don't stop at the symptom. Root-cause fully, fix the whole **class** of the bug (not just the one reported instance), add a fail-before/pass-after regression test, and harden against recurrence (e.g. if a lockfile drift only fails in Docker, also make CI catch it). Go the extra mile where it durably pays off. Be token-efficient about it — extra **effort**, not extra **verbosity**: no padding, no redundant re-checks, the smallest correct change that is also recurrence-proof. Don't be shy to spend the effort a proper fix needs; do be shy about wasting tokens.
**Keep main green (hard rule, owner-set 2026-06-16):** A merge must **never break `main`'s CI**. Before a change lands, verify the *full* CI matrix would pass — every workflow in `.github/workflows/`**and**`deploy/Dockerfile`, not only the checks you happened to run. Dependency / lockfile / config changes must be validated against **all** consumers. Specifically: `frontend/` is a bun **workspace monorepo** — the lockfile is the repo-root `bun.lock`, and `deploy/Dockerfile` runs `bun install --frozen-lockfile`, so any `frontend/package.json` change requires regenerating root `bun.lock` and confirming `bun install --frozen-lockfile` passes (plain `bun install` in `ci.yml` silently tolerates drift, so CI-green ≠ Docker-green). Likewise re-check CodeQL/Security on code changes and the Tauri `cargo` build on Rust/dep changes.
Other conventions not yet established. Will populate as patterns emerge during development.
<!-- GSD:conventions-end -->
@@ -210,16 +68,9 @@ No project skills found. Add skills to any of: `.claude/skills/`, `.agents/skill
<!-- GSD:skills-end -->
<!-- GSD:workflow-start source:GSD defaults -->
## GSD Workflow Enforcement
## Workflow
Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.
Use these entry points:
-`/gsd-quick` for small fixes, doc updates, and ad-hoc tasks
-`/gsd-debug` for investigation and bug fixing
-`/gsd-execute-phase` for planned phase work
Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it.
Direct repo edits are authorized (owner decision, 2026-07-08). The GSD command gate that used to live here referenced `/gsd-quick` / `/gsd-debug` / `/gsd-execute-phase` skills that are not installed in this environment; the owner chose to keep working directly rather than restore them. The working conventions that matter are in **Conventions** above — versioning, docs-sync, changelog, localization, fix quality, keep-main-green — plus: gate every merge on the "Tests (backend + frontend)" check passing and the PR being MERGEABLE, and check the open-PR queue before implementing any community-reported fix (contributors may have already submitted one).
bun run desktop # dev: hot-reload Tauri shell + backend
bun run desktop-prod # production: builds, bundles the backend, then launches
```
Both run `uv sync` first (so the Python backend env is set up) and start the
backend automatically — you do **not** start it separately. Use the exact script
names: there is no `desktop=prod` (note the **hyphen** in `desktop-prod`).
`desktop-prod` is Windows-aware (auto-detects bash/git; see `scripts/desktop-prod.mjs`).
Requires [Rust](https://rustup.rs/) and platform-specific Tauri dependencies — see the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
If the app opens but stays on the **setup splash with no buttons**, the Python
backend didn't finish starting — the splash surfaces the stall reason, a log
panel, and a **Retry** button (and Settings → Logs → Backend has the full trace).
The most common from-source cause is `uv` or Python not being on your PATH.
---
## Project Structure
@@ -148,7 +160,7 @@ class MyEngineBackend(TTSBackend):
- **Components**: Functional components with hooks
- **State**: Zustand stores in `src/stores/`, organized by slice
- **CSS**: Vanilla CSS in component-level files — no Tailwind
- **CSS**: **Utilities-first + shadcn/ui, one stylesheet.** UI is built on the shadcn/ui primitives in `src/components/ui/` (wrapped by the `src/ui/` barrel, themed to the OmniVoice palette), composed with Tailwind v4 utility classes. **All styling now lives in a single file — `src/index.css`**: the `@theme` / `[data-theme]` token foundation plus the irreducible set utilities can't express (`@keyframes`, glassmorphism/`backdrop-filter`, pseudo-elements, `:has()`, unlayered cascade overrides, and styling hooks on library-generated DOM like virtualized rows / WaveSurfer). The per-component `.css` files were eliminated in the CSS→Tailwind/shadcn migration — **do not create new ones.** Reach for shadcn primitives + utilities; if a rule is genuinely irreducible, add it to `src/index.css` with a provenance comment. (The only other `.css` is the test-only visual harness. See `docs/shadcn-migration.md`.)
- **Naming**: `PascalCase` for components, `camelCase` for hooks and utils
### Rust (Tauri)
@@ -158,6 +170,30 @@ class MyEngineBackend(TTSBackend):
---
## Frontend file structure & size limits
Frontend code stays modular so an edit loads one small file, not a 1900-line
<p>Real-time dictation, zero-shot voice cloning, and cinematic video dubbing — all on your desktop.<br/>Open-source, no API keys, fully local. <b>646 languages.</b></p>
<img src=".github/assets/social-preview.png" alt="OmniVoice Studio — The open-source ElevenLabs alternative" width="100%"/>
<img src="docs/screenshot-launchpad.png" alt="OmniVoice Studio — Launchpad" width="100%"/>
</div>
> **Your voice is the most personal data you have. So why rent it back from a cloud?** Every mainstream voice tool ships your audio to someone else's server and bills you monthly for the privilege. OmniVoice Studio flips that: clone, design, dub, and dictate on your own hardware — 646 languages, no meter running, nothing leaving your machine.
<div align="center">
| 🔑 No API keys | 🙅 No accounts | ☁️ No cloud | 💳 No subscription |
|:---:|:---:|:---:|:---:|
| nothing to paste in | nothing to sign up for | your audio stays home | it's your computer |
</div>
> [!WARNING]
> **OmniVoice Studio is in active beta.** Things may break between releases. For the latest features and fixes, clone the repo and run from source rather than using pre-built installers. Bug reports and PRs are very welcome — [open an issue](https://github.com/debpalash/OmniVoice-Studio/issues) or [join Discord](https://discord.gg/bzQavDfVV9).
> **OmniVoice Studio is in active beta.** Things may break between releases — for the latest features and fixes, clone the repo and run from source rather than the pre-built installers. Bug reports and PRs are very welcome: [open an issue](https://github.com/debpalash/OmniVoice-Studio/issues) or [join Discord](https://discord.gg/bzQavDfVV9).
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Download Windows MSI" /></a>
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download Linux AppImage" /></a>
<br/>
<sub><b>macOS:</b> first launch needs a one-time approval — right-click → <b>Open</b> (or System Settings → Privacy & Security → <b>"Open Anyway"</b> on macOS 15). No Terminal needed. <a href="docs/install/macos.md#gatekeeper-quarantine">Why?</a></sub>
<br/>
<sub><b>Intel Macs are not supported for the local backend:</b> the app UI installs, but the Python backend cannot run because PyTorch no longer ships Intel-Mac (x86_64) wheels (<a href="https://github.com/debpalash/OmniVoice-Studio/issues/889">#889</a>) — see <a href="docs/install/macos.md">docs/install/macos.md</a>.</sub>
</div>
Stuck? See [docs/install/troubleshooting.md](docs/install/troubleshooting.md)
for the top 10 install errors. The in-app error UI deeplinks to those entries
| **Python** | 3.10+ (managed by `uv`) | 3.11–3.12 |
| **GPU** | Optional — CPU works | NVIDIA CUDA · Apple Silicon MPS · AMD ROCm |
| **GPU** | Optional — CPU works | NVIDIA CUDA · Apple Silicon MPS · AMD ROCm (Linux only) |
> [!TIP]
> On GPUs with **≤8 GB VRAM**, OmniVoice automatically offloads TTS to CPU during transcription — no config needed. A dedicated GPU is not required; the entire pipeline runs on CPU (just slower).
### TTS Engines
> [!NOTE]
> **AMD GPUs:** ROCm acceleration is **Linux-only and opt-in** — pick **"AMD GPU (ROCm)"** on the first-run setup screen or set `OMNIVOICE_TORCH_VARIANT=rocm` ([docs/install/linux.md](docs/install/linux.md#amd-gpu-rocm)). **On Windows, AMD GPUs (incl. Ryzen AI iGPUs) run CPU-only**: PyTorch has no Windows ROCm wheels, so Windows GPU acceleration is NVIDIA/CUDA-only ([docs/install/windows.md](docs/install/windows.md#gpu-support)).
OmniVoice ships a multi-engine TTS backend. The default engine (OmniVoice) is always available; additional engines are opt-in and auto-detected. Switch engines in **Settings → TTS Engine** or via the `OMNIVOICE_TTS_BACKEND` env var.
> [!IMPORTANT]
> **macOS Intel (x86_64) is unsupported for the local backend:** the app UI installs, but the Python backend cannot run because PyTorch no longer ships Intel-Mac wheels ([#889](https://github.com/debpalash/OmniVoice-Studio/issues/889)). Intel-Mac users can still point the UI at a remote backend on another machine — see [docs/install/macos.md](docs/install/macos.md).
<a id="tts-engines"></a>
### 🗣️ TTS Engines
**14 engines, one picker.** OmniVoice (default, 600+ languages) is always available; CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, and Sherpa-ONNX are opt-in and auto-detected — plus six lazy-installed heavyweights (IndexTTS 2, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS). Switch in **Settings → TTS Engine** or via the `OMNIVOICE_TTS_BACKEND` env var — the selection applies everywhere synthesis happens: single-clip generation, Voice Cloning, Video Dubbing, and Batch TTS.
<details>
<summary><b>📊 The full matrix</b> — 14 engines × platform × clone/instruct × license</summary>
<br/>
| Engine | Languages | Clone | Instruct | Linux | macOS ARM | Windows | License |
> **CUDA** = GPU-accelerated · **MPS** = Apple Silicon Metal · **CPU** = runs everywhere, slower for large models · KittenTTS and MOSS-TTS-Nano run realtime on CPU · MLX-Audio is Apple Silicon only.
> **CUDA** = GPU-accelerated · **MPS** = Apple Silicon Metal · **CPU** = runs everywhere, slower for large models · KittenTTS and MOSS-TTS-Nano run realtime on CPU · MLX-Audio is Apple Silicon only · ⚡ = lazy-registered (installed on first use)
>
> **Clone** matters beyond single-clip generation: Video Dubbing (and any Batch job with a pinned voice) needs reference-audio cloning to preserve speaker identity, so picking a Clone-less engine (KittenTTS, Sherpa-ONNX, Supertonic 3) as the active engine fails those jobs up front with an actionable message instead of silently falling back to OmniVoice.
>
> **MOSS-TTS-v1.5** (8B, ~16 GB weights) and **dots.tts** (2B, ~9 GB weights) are heavyweight opt-in engines that run in their own isolated venv from a local clone — see [MOSS-TTS-v1.5](docs/engines/moss-tts-v15.md) and [dots.tts](docs/engines/dots-tts.md). Neither claims Apple-Silicon **MPS** (upstream is CUDA/CPU only; on a Mac they run on CPU). dots.tts upstream is Linux/macOS only — no Windows path. **Confucius4-TTS** (14-language cross-lingual zero-shot cloning) is similar — its own Python 3.10 venv from a clone; CUDA recommended, CPU validated end-to-end (slow, ~17× realtime; no MPS — tested slower than CPU); see [Confucius4-TTS](docs/engines/confucius4-tts.md).
</details>
<a id="asr-engines"></a>
### 🎧 ASR Engines
**10 engines** — they power dictation, video dubbing, and subtitles. **WhisperX** is the cross-platform default (~100 languages, word-level timing); the rest are opt-in and auto-detected. Switch in **Settings → Engines** (the ASR Engines table — same picker TTS has), or pin one with the `OMNIVOICE_ASR_BACKEND` env var (the env var wins over the Settings pick). Nine run fully on-device; one (OpenAI-compatible) is an optional remote client for pointing at Qwen3-ASR or another compatible server — see below.
<details>
<summary><b>📊 The full lineup</b> — 10 engines, what each is best at, and compute-type notes</summary>
<br/>
| Engine | `OMNIVOICE_ASR_BACKEND` | Languages | Best for |
| **Faster-Whisper** | `faster-whisper` | ~100 | Fast transcription on Linux / macOS / Windows (CTranslate2) |
| **Faster-Whisper (isolated)** | `faster-whisper-isolated` | ~100 | Same as Faster-Whisper but crash-isolated in a subprocess — an ASR crash won't take down the app |
| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | 25 EU + 90+ | Live, faster-than-real-time dictation — small streaming/offline ONNX models (Parakeet TDT v3/v2, streaming Zipformer & Paraformer, Whisper Tiny), CPU, identical on macOS / Windows / Linux. Picked per-model in **Settings → Voice**. |
| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | A path to **Qwen3-ASR** today (self-hosted server, no transformers wait), any OpenAI-compatible transcription endpoint, or OpenAI's own API — no install, configure in **Settings → Models**. Audio leaves your machine to whatever server you point it at; see [docs/engines/openai-compatible-asr.md](docs/engines/openai-compatible-asr.md). |
> Whisper-family engines cover ~100 languages; **FunASR / SenseVoice** adds an all-in-one multilingual path with built-in voice-activity detection and inline speaker diarization. **sherpa-onnx** powers the live dictation model picker — you talk and text appears as you speak. Every engine runs on-device — no API keys, no cloud.
> **GPU without efficient float16?** On older NVIDIA GPUs (Maxwell/Pascal, GTX 16xx) or after a CTranslate2/cuDNN mismatch, the CTranslate2 ASR engines (WhisperX, Faster-Whisper) can't run `float16` and OmniVoice automatically retries on `int8` — no config needed. If transcription still fails, pin the compute type with the `ASR_COMPUTE_TYPE` env var (escape hatch): `ASR_COMPUTE_TYPE=int8` (or `float32` for CPU). Set it to `int8` and restart the backend.
Already have a script, agent, or tool that speaks OpenAI's audio API? Point it at `http://localhost:3900/v1` — no key needed, no code changes. The backend ships a drop-in surface for the audio endpoints, wired to whichever TTS/ASR engine you have active (and yes, `voice` accepts your cloned voice-profile IDs).
| Endpoint | What it does |
|---|---|
| `POST /v1/audio/speech` | TTS — text in; `mp3` / `wav` / `flac` / `opus` / `pcm` out. `tts-1` / `tts-1-hd` map to your active engine; OpenAI voice names (`alloy`, …) are accepted. |
| `POST /v1/audio/transcriptions` | STT — audio file in; `json`, `text`, `verbose_json`, `srt`, or `vtt` out. `whisper-1` maps to your active ASR engine. |
| `GET /v1/audio/voices` | OmniVoice extension — lists every voice profile and engine, so clients can discover your clones. |
```sh
curl http://localhost:3900/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{"model": "tts-1", "voice": "alloy", "input": "Generated on my own hardware.", "response_format": "wav"}' \
--output speech.wav
```
```python
from openai import OpenAI
client = OpenAI(base_url="http://localhost:3900/v1", api_key="none") # any string works — nothing checks it
result = client.audio.transcriptions.create(model="whisper-1", file=open("clip.wav", "rb"))
print(result.text)
```
Want the whole surface (100+ endpoints)? The full REST API reference is embedded in the app — **Settings → OpenAPI Reference** (Scalar-powered), or the `{}` button in the footer.
### 📓 Run on Google Colab (community)
No local GPU? A community member ([@shakib30](https://github.com/shakib30)) maintains a working Colab notebook: [shakib30/OmniVoice-Studio-google-colab](https://github.com/shakib30/OmniVoice-Studio-google-colab). Community-maintained — issues with the notebook go there; issues with OmniVoice itself come here.
### 🤝 Agent Skills
Teach your AI agent (Claude Code, Cursor, Codex, …) to use OmniVoice with one command:
```sh
npx skills add debpalash/omnivoice-studio
```
Ships two [skills](https://skills.sh): **`omnivoice`** — speak and transcribe through your local install (including your cloned voices) from any agent, free and offline; and **`oss-maintainer`** — the maintainer methodology this project is run with, for anyone running their own OSS project with an agent.
OmniVoice Studio is built by one developer using Claude Code and AI agents — and the agent bills are real. Over the last three months I've spent thousands of dollars on Claude subscriptions to keep the features shipping, the bugs fixed, and your issues answered. If OmniVoice has created value for you, helping cover those bills means I can keep developing full-time.
<sub>Every dollar goes directly to agent bills — keeping OmniVoice development continuous.</sub>
</div>
<a id="sponsors"></a>
### 🌟 Sponsors
OmniVoice is **free** and **AGPL-3.0** — no paid tier, no SaaS revenue. Sponsors keep development going, and in return get a logo slot here, in the app, and (for top tiers) on the project website. It's a thank-you, never a paywall. **[See tiers & become a sponsor →](SPONSORS.md)**
<div align="center">
<!-- SPONSORS:START — logo slots are filled here as sponsors come aboard; see SPONSORS.md -->
**Your logo here** — [become a sponsor](SPONSORS.md)
<!-- SPONSORS:END -->
</div>
<sub>💡 GitHub also shows a **Sponsor** button at the top of this repo, wired to the same links via <a href=".github/FUNDING.yml"><code>.github/FUNDING.yml</code></a>.</sub>
| `#announcements` | Release notes, breaking changes, early access |
| `#announcements` | Release news and the big moments — new versions land here first |
| `#releases` + `#changelog` | Every build and exactly what's inside it |
| `#issues` | Bug reports as forum posts — triaged straight into GitHub issues |
| `#ideas` | Feature requests, discussed and voted on |
| `#discuss-ideas` | Design talk before things get built |
| `#general` | Setup help, GPU troubleshooting, and showing off your dubs |
**[→ Join the Discord](https://discord.gg/bzQavDfVV9)** — we respond to setup questions within hours, not days.
</details>
---
## Contributing
<a id="contributing"></a>
We welcome contributions of all kinds — bug fixes, new TTS engine adapters, UI improvements, docs, and translations.
## 🤝 Contributing
Yes please — bug fixes, new TTS engine adapters, UI improvements, docs, translations. All of it.
- 📖 Read the **[Contributing Guide](CONTRIBUTING.md)** for setup, code style, and PR workflow
- 🐛 Browse [good first issues](https://github.com/debpalash/OmniVoice-Studio/labels/good%20first%20issue)
@@ -318,18 +534,26 @@ We welcome contributions of all kinds — bug fixes, new TTS engine adapters, UI
---
## FAQ
## ❓ FAQ
<details>
<summary><b>Is this really as good as ElevenLabs?</b></summary>
<br/>
For voice cloning and dubbing, yes — OmniVoice uses a state-of-the-art diffusion TTS model with 646 languages (ElevenLabs supports 32). Quality is comparable for most use cases. Where ElevenLabs wins is in their polished cloud API and pre-made voice library. OmniVoice wins on privacy, cost, language coverage, and customizability.
Honest answer: <b>it depends on what you're doing.</b>
<b>Where OmniVoice is genuinely competitive:</b> voice cloning from a clean reference clip (state-of-the-art open diffusion TTS), language coverage (646 languages vs. their 32), and everything structural — no per-character billing, no usage caps, no audio leaving your machine, full pipeline customizability (10 TTS engines, 10 ASR engines, your choice of translation).
<b>Where ElevenLabs still wins:</b> out-of-the-box consistency and polish, especially for English TTS. Their one model is heavily tuned; our quality depends on which engine you pick, your hardware, and — for cloning — the reference audio (a dry, close-mic clip clones dramatically better than a noisy or echoey one).
<b>For dubbing specifically:</b> a dub is a chain — transcription → translation → cloning → synthesis — and the output is only as good as its weakest link on <i>your</i> source material. Noisy or accented source audio degrades transcription, which degrades everything downstream; some language pairs translate better than others. If parts of a dub come out incoherent, check the segment table's <i>original</i> text first: if the transcription is already wrong there, switch the ASR engine (Settings → Engines) or use cleaner source audio — that's usually the fix, not the voice.
Try it on your real material — it's free and takes one download. Many users find it replaces ElevenLabs outright; some keep both for different jobs. Both outcomes are fine with us.
</details>
<details>
<summary><b>Does it work on Apple Silicon (M1/M2/M3/M4)?</b></summary>
<br/>
Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are available for faster transcription on Apple hardware.
Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are available for faster transcription on Apple hardware. <b>Intel Macs are not supported</b>: the app UI installs, but the local Python backend cannot run because PyTorch no longer ships Intel-Mac wheels (<a href="https://github.com/debpalash/OmniVoice-Studio/issues/889">#889</a>) — an Intel Mac can only be used with a remote backend.
</details>
<details>
@@ -341,7 +565,7 @@ Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are availab
<details>
<summary><b>Can I use this commercially?</b></summary>
<br/>
Personal, educational, internal-team, and non-commercial use is free under <a href="https://fsl.software/">FSL-1.1-ALv2</a>. Building a competing product or service on top of OmniVoice Studio requires a commercial license — see <a href="#license">License</a>. Pricing tiers coming soon. Each release converts to Apache 2.0 two years after publication.
<b>Yes — commercial use is free.</b> OmniVoice Studio is free and open-source under the <a href="https://www.gnu.org/licenses/agpl-3.0.html">GNU AGPL-3.0</a>. So personal, educational, research, <b>and commercial / business use are all free</b>: run it, sell the audio you make with it, dub your own or a client's videos, deploy it across your team. Because AGPL is a <b>network copyleft</b> license, if you <b>modify</b> OmniVoice Studio and make that modified version available to others over a network, you must offer those users the source of your modified version under the same AGPL terms. Want to embed OmniVoice in a <b>closed-source or proprietary</b> product without those obligations? A <b>commercial license</b> is available — see <a href="#license">License</a>.
</details>
<details>
@@ -353,24 +577,26 @@ Personal, educational, internal-team, and non-commercial use is free under <a hr
<details>
<summary><b>Can I add my own TTS engine?</b></summary>
<br/>
Yes. OmniVoice uses a <b>built-in backend registry</b>. To add an engine in ~50 lines, subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</code> and add it to the <code>_REGISTRY</code> dictionary at the bottom. Six engines are built in: OmniVoice, CosyVoice, MLX-Audio (14+ sub-engines), VoxCPM2, MOSS-TTS-Nano, and KittenTTS. See the <a href="#tts-engines">TTS Engines</a> section for details.
Yes. OmniVoice uses a <b>built-in backend registry</b>. To add an engine in ~50 lines, subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</code> and add it to the <code>_REGISTRY</code> dictionary. Fourteen engines are built in: OmniVoice, CosyVoice 3, GPT-SoVITS, MLX-Audio (14+ sub-engines), VoxCPM2, MOSS-TTS-Nano, KittenTTS, Sherpa-ONNX, plus lazy-registered IndexTTS 2, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, and Confucius4-TTS. See the <a href="#tts-engines">TTS Engines</a> section for details.
</details>
---
## License
<a id="license"></a>
OmniVoice Studio is source-available under the [**Functional Source License (FSL-1.1-ALv2)**](https://fsl.software/).
## 📜 License
**Free** for personal, educational, research, internal team, and non-commercial use. Each release **converts to Apache 2.0 automatically two years after publication**.
OmniVoice Studio is free and open-source software under the [**GNU Affero General Public License v3.0 (AGPL-3.0)**](https://www.gnu.org/licenses/agpl-3.0.html).
**Business / enterprise** users building a competing product or service on top of OmniVoice Studio need a commercial license. **Pricing tiers coming soon.** For inquiries in the meantime, reach out at **OmniVoice@palash.dev**.
**Free for any use — including commercial and internal business use.** Run it, sell the audio you produce with it, dub your own or clients' videos, roll it out across your team — all free, no license needed. As a **network copyleft** license, AGPL adds one obligation: if you **modify** OmniVoice Studio and offer that modified version to others over a network, you must make the complete corresponding source of your modified version available to them under the same AGPL-3.0 terms.
See [`LICENSE`](LICENSE) for the full terms.
A **commercial license** is available for organizations that want to embed OmniVoice Studio in a **closed-source or proprietary** product or service without the AGPL-3.0 copyleft obligations. **Pricing tiers coming soon.** Inquiries: **OmniVoice@palash.dev**.
The bundled `omnivoice/` TTS model by Han Zhu remains Apache-2.0 upstream. See [`LICENSE`](LICENSE) for the full, binding terms.
---
## Acknowledgments
## 🙏 Acknowledgments
OmniVoice Studio is built on the shoulders of exceptional open-source work:
@@ -383,6 +609,20 @@ OmniVoice Studio is built on the shoulders of exceptional open-source work:
| [**CTranslate2**](https://github.com/OpenNMT/CTranslate2) | Optimized Transformer inference on CPU and GPU |
| [**AudioSeal (Meta)**](https://github.com/facebookresearch/audioseal) | Invisible neural audio watermarking for AI provenance |
Like the local-first philosophy? It runs in the family:
| Project | What it is |
|---------|------------|
| [**Opal**](https://github.com/debpalash/Opal) 💠 | **Play everything.** The evolved media player for the next decades of entertainment — video, anime, comics, torrents, Jellyfin/Plex, with local AI built in. |
| [**memxt**](https://github.com/debpalash/memxt) 🧠 | **The fastest benchmarked open-source AI memory system.** 100% local memory for AI agents, with MCP support. |
---
@@ -392,7 +632,8 @@ OmniVoice Studio is built on the shoulders of exceptional open-source work:
If you read this far, you're our kind of person.<br/>
**[⭐ Star this repo](https://github.com/debpalash/OmniVoice-Studio)** so others can find it too.<br/>
**[💬 Join the Discord](https://discord.gg/bzQavDfVV9)** to share what you build.
**[💬 Join the Discord](https://discord.gg/bzQavDfVV9)** to share what you build.<br/>
**[❤️ Support development](https://ko-fi.com/debpalash)** — fund the AI agent bills that keep OmniVoice shipping.
<p><b>Keep the open-source ElevenLabs alternative free, local, and shipping.</b></p>
</div>
---
## Why sponsor?
OmniVoice Studio is built by one developer, in the open, using Claude Code and AI agents — and the agent bills are real. Over the last few months I've spent thousands of dollars on Claude subscriptions to keep features shipping, bugs fixed, and your issues answered.
OmniVoice is **free**, **fully local**, and **AGPL-3.0**. There's no paid tier, no accounts, no cloud, and no SaaS revenue — nothing runs on a server we bill you for, because nothing runs on a server at all. That's the whole point, and it's also why there's no recurring revenue to fund development. Sponsorship is what makes continued full-time work possible.
If OmniVoice has created value for you or your company, sponsoring means the next release keeps coming — and you get a thank-you (and, at most tiers, a logo slot) in return.
### Where your money goes
Every dollar goes to the cost of building OmniVoice — chiefly the **AI agent bills that keep it shipping** (Claude subscriptions and API usage), plus the occasional signing certificate, test hardware, and model-hosting costs. It is not a salary top-up; it's what keeps the lights on for continuous development.
---
## Sponsorship tiers
Tiers are about **visibility and gratitude** — what you get is placement, not gated features (see [Not a paywall](#not-a-paywall)). Higher tiers include everything in the tiers below them.
| Tier | Suggested monthly | What you get |
|------|-------------------|--------------|
| **🥉 Backer** | _set by owner_<!-- OWNER: set amounts --> | Your name or handle listed in the **Backers** section of this file, with a link of your choice. |
| **🟫 Bronze** | _set by owner_<!-- OWNER: set amounts --> | Everything above, **plus** a small logo in `SPONSORS.md`**and** in the README [Sponsors section](README.md#sponsors). |
| **🥈 Silver** | _set by owner_<!-- OWNER: set amounts --> | Everything above, **plus** your logo in the **README** and in the app's **in-app Sponsors page footer** (as that page ships). |
| **🥇 Gold** | _set by owner_<!-- OWNER: set amounts --> | Everything above, **plus** a **prominent logo slot** and link on the project **website / landing page**. |
> **Amounts are set by the maintainer** — look for the `<!-- OWNER: set amounts -->` markers in this file's source. If you don't see a price that fits, say so in your inquiry; custom and annual arrangements are welcome.
Placements marked "as that page ships" (the in-app Sponsors page and the project website) are on the near-term roadmap. Until they exist, Silver/Gold logos live in `SPONSORS.md` and the README, and are added to the app and site the moment those land — no re-application needed.
---
## How to become a sponsor
**1. Open a sponsorship inquiry (recommended).** This opens a short GitHub form (name/org, logo, tier, contact) so we can get you set up:
> **[→ Open a sponsorship inquiry](https://github.com/debpalash/OmniVoice-Studio/issues/new?template=sponsor.yml)**
**2. Or start recurring support directly:**
- **Ko-fi (recurring or one-time):** [ko-fi.com/debpalash](https://ko-fi.com/debpalash)
If you sponsor via Ko-fi/PayPal and want a logo slot, still open an inquiry (or drop a note there) so we know who to credit and where to link.
**3. Prefer to talk first?** Reach out directly:
- Email: <!-- OWNER: add your sponsor contact email here if you want one public -->
- Or ask in the `#dev` / `#announcements` channels on [Discord](https://discord.gg/bzQavDfVV9).
---
## Logo & asset guidelines
To make your logo look sharp everywhere (README on GitHub, the in-app page, the website), please send:
- **Format:****SVG preferred** (scales cleanly); otherwise **PNG at 2× resolution**.
- **Background:****transparent** — no baked-in white/black box.
- **Contrast:** send a variant that stays legible on **both light and dark** backgrounds, or one light-mode and one dark-mode file (GitHub and the app both render in either theme).
- **Dimensions:** legible at **~40px tall**; keep the wordmark within roughly **480px wide**. Landscape/wordmark shapes work best in the README row.
- **File size:** keep SVGs under ~50 KB and PNGs under ~100 KB.
- **Link target:** the destination URL you want the logo to point to (usually your homepage).
**How your logo gets added:**
- **Easiest:** attach the asset and link in your [sponsorship inquiry](https://github.com/debpalash/OmniVoice-Studio/issues/new?template=sponsor.yml) — the maintainer places it.
- **Or open a PR:** add your asset under `docs/sponsors/` and an entry to the tables in this file. Silver/Gold logos are also wired into the app's in-app Sponsors page (via the `sponsors.js` manifest) and the project website as those surfaces ship.
By sponsoring you confirm you have the right to use the submitted logo and grant OmniVoice permission to display it in the contexts above. We won't alter your logo beyond scaling, and we'll remove it promptly on request.
---
## Current sponsors
OmniVoice doesn't have any sponsors yet — **you could be the first.** These slots fill in as sponsors come aboard.
### 🥇 Gold
_Be the first Gold sponsor — [claim this slot](#how-to-become-a-sponsor)._
### 🥈 Silver
_Open — [become a Silver sponsor](#how-to-become-a-sponsor)._
### 🟫 Bronze
_Open — [become a Bronze sponsor](#how-to-become-a-sponsor)._
### 🥉 Backers
_Open — [become a Backer](#how-to-become-a-sponsor)._
<!-- When a sponsor joins, add them to the matching section above:
- Logo tiers (Bronze+): <a href="https://sponsor.example"><img src="docs/sponsors/name.svg" alt="Name" height="48" /></a>
- Backers: - [Name / handle](https://link) -->
---
## Not a paywall
Sponsorship is a **thank-you, never a paywall.**
Every feature of OmniVoice Studio is and will remain **free** and **open-source under [AGPL-3.0](LICENSE)**. Sponsors do **not** get private builds, gated features, license exceptions, or anything that degrades the experience for people who don't (or can't) pay. What sponsors get is **visibility and our gratitude** — and the knowledge that they're directly funding the next release.
OmniVoice stays local-first and fully functional with zero dollars spent. Sponsoring just helps it keep getting better, faster.
---
<div align="center">
<sub>Thank you for keeping local-first voice AI alive and free. ❤️</sub><br/>
<sub>Questions? <a href="https://github.com/debpalash/OmniVoice-Studio/issues/new?template=sponsor.yml">Open an inquiry</a> · <a href="https://discord.gg/bzQavDfVV9">Discord</a></sub>
| [GitHub Issues](https://github.com/debpalash/OmniVoice-Studio/issues) | Bugs and feature requests — use the templates; attach the diagnostic bundle (Settings → About → "Save diagnostic bundle") |
# Regional dialect hints (#280 item 2). Maps a BCP-47 dialect code to the
# instruction injected into LLM translation prompts so the output uses that
# region's vocabulary and grammar (the reporter's example: choosing Argentina
# should yield "Vos sos muy listo", not the Peninsular "Tú eres muy listo").
# Only LLM-backed paths can honor these — provider="openai" and the
# quality="cinematic" refine pass. Keep entries short: they ride on every
# per-segment prompt, so verbosity = wall time.
DIALECT_HINTS={
# Spanish
"es-ES":"European Spanish (Spain): use tú/vosotros forms and Peninsular vocabulary.",
"es-MX":"Mexican Spanish: use tú/ustedes forms and Mexican vocabulary.",
"es-AR":"Rioplatense Spanish (Argentina): use voseo — 'vos' with its verb forms (e.g. 'vos sos', 'tenés') and 'ustedes'; prefer Argentinian vocabulary.",
"es-CO":"Colombian Spanish: use tú/usted as natural in Colombia and Colombian vocabulary.",
"es-CL":"Chilean Spanish: use Chilean vocabulary and expressions.",
# Portuguese
"pt-BR":"Brazilian Portuguese: use 'você' forms, Brazilian vocabulary and spelling.",
"pt-PT":"European Portuguese: use European vocabulary, spelling, and 'tu' where natural.",
# English
"en-US":"American English: use US spelling and vocabulary.",
"en-GB":"British English: use UK spelling and vocabulary.",
"en-AU":"Australian English: use Australian spelling and vocabulary.",
"en-IN":"Indian English: use Indian English vocabulary and conventions.",
# French
"fr-FR":"Metropolitan French (France): use standard French vocabulary.",
"fr-CA":"Canadian French (Québec): use Québécois vocabulary and expressions.",
"fr-BE":"Belgian French: use Belgian vocabulary (e.g. septante, nonante).",
# German
"de-DE":"Standard German (Germany): use Federal German vocabulary.",
"de-AT":"Austrian German: use Austrian vocabulary (e.g. Jänner, Erdapfel).",
"de-CH":"Swiss Standard German: use Swiss vocabulary and 'ss' instead of 'ß'.",
# Arabic
"ar-EG":"Egyptian Arabic: use Egyptian colloquial vocabulary where natural for dubbing.",
"ar-SA":"Gulf/Saudi Arabic flavor: prefer vocabulary natural to the Gulf region.",
"ar-MA":"Moroccan Arabic (Darija) flavor: prefer vocabulary natural to Morocco.",
# Dutch
"nl-NL":"Netherlands Dutch: use vocabulary standard in the Netherlands.",
"nl-BE":"Belgian Dutch (Flemish): use Flemish vocabulary and expressions.",
}
defdialect_clause(dialect:Optional[str])->str:
"""Prompt fragment for a requested dialect, or '' when unset/unknown.
"fix":"YouTube clip downloads in Voice Gallery will fail. Download the standalone binary from https://github.com/yt-dlp/yt-dlp/releases and place it in your PATH.",
note:"Best speed/quality tradeoff. 5× faster than large-v3 with minimal WER loss."
note:"Best speed/quality tradeoff. 5× faster than large-v3 with minimal WER loss. Community CTranslate2 conversion (no official Systran/OpenAI turbo repo) — re-verify availability on catalog audits."
"Model downloads will fail until this resolves. Behind a restricted network, set a proxy in Settings > General or configure a mirror via HF_ENDPOINT.",
# One-line "what to do" per docs-taxonomy key. Keys mirror error_docs_map's
# taxonomy; the docs URL itself stays owned by error_docs_map.
_HINTS:dict[str,str]={
"PKG_RESOURCES_MISSING":"Install setuptools in the backend environment (provides pkg_resources).",
"PKG_RESOURCES_MISSING":"Run `uv pip install --reinstall 'setuptools>=75,<80'` in the backend venv (a plain install is skipped when setuptools' metadata is present but its pkg_resources files were removed by antivirus). Restart after.",
"GATEKEEPER_QUARANTINE":"Clear the macOS quarantine flag (xattr -cr the app), then reopen.",
"APPIMAGE_WEBKIT_WHITESCREEN":"Launch with WEBKIT_DISABLE_DMABUF_RENDERER=1 set.",
"HF_AUTH_FAILED":"Set a valid HF_TOKEN in Settings → Hugging Face and retry.",
"PYANNOTE_LICENSE_REQUIRED":"Accept the pyannote model licenses on Hugging Face, then retry.",
"COMPUTE_TYPE_UNSUPPORTED":"Your GPU doesn't support float16 — OmniVoice retried on int8. If transcription still fails, set OMNIVOICE/ASR_COMPUTE_TYPE=int8 or use CPU.",
"TRANSFORMERS_IMPORT":"Your transformers install is incomplete. Reinstall it (`uv pip install --reinstall transformers`) or switch ASR to faster-whisper (Settings → Models).",
"OS_INVALID_ARGUMENT":"The OS rejected a file operation (Errno 22 / invalid argument) — in the transcribe path this is the temporary WAV write before ASR. It's almost always the temp directory: missing, read-only, on a full or removed drive, or blocked by antivirus. Check that your system TEMP/TMP folder exists and is writable and the drive has free space (add an OmniVoice antivirus exclusion if you use one), then retry.",
"SOCKS_PROXY_SUPPORT_MISSING":"A SOCKS proxy is configured in your environment (ALL_PROXY/HTTPS_PROXY=socks5://…) and the backend's HTTP client is missing SOCKS support. Newer OmniVoice builds ship SOCKS support (the socksio package) — update the app. If you still see this, unset ALL_PROXY/HTTPS_PROXY for OmniVoice, or run `uv pip install 'httpx[socks]'` in the backend venv, then restart.",
"SSL_HANDSHAKE_FAILURE":"A corporate or antivirus proxy is intercepting HTTPS traffic and re-signing certificates with its own CA — your OS trusts that CA, but Python's bundled certifi CA list doesn't, so the TLS handshake fails even though the connection reached the server. Newer OmniVoice builds trust the OS certificate store at startup (the truststore package), which should already fix this — update the app and retry. If you still see this, add an HTTPS-scanning exclusion for OmniVoice/Python in your antivirus, or ask IT for the proxy's CA bundle and set SSL_CERT_FILE to it, then restart.",
"UNSUPPORTED_VIDEO_URL":"This link isn't a directly downloadable video. Paste a direct video page (e.g. a youtube.com/watch?v=… or douyin.com/video/<id> link), not a share/profile/feed link — or download the file and drop it in directly.",
"VIDEO_DOWNLOAD_NETWORK":"The connection to the video server dropped mid-download (often a transient CDN/network blip or a regional rate-limit). Just retry — OmniVoice already cleaned up the partial download. If it keeps failing, check your network/VPN.",
"BROKEN_VENV":"The Python backend environment was moved or damaged. OmniVoice rebuilds it automatically on the next launch; if it keeps failing, use Clean & Retry on the setup screen.",
"MODEL_CACHE_CORRUPT":"The model cache had broken file links — snapshot entries that no longer point at their downloaded data (interrupted renames or antivirus interference can cause this). OmniVoice repairs this automatically and retries the load once. If the error persists, quit OmniVoice, delete the model's models--<org>--<name> folder inside the Hugging Face cache, and restart — the model re-downloads automatically.",
# HF_MIRROR_UNREACHABLE has a DYNAMIC hint (it names the configured mirror)
# — see hf_mirror_hint(); build_failure special-cases it.
# Per-op failure is recoverable — emit the error frame and stay
# alive so the parent can retry without paying the respawn +
# multi-GB model-load cost again.
_send(stdout,{
"op":"error",
"stage":opor"unknown",
"message":f"{type(exc).__name__}: {exc}",
"traceback":traceback.format_exc(),
})
if__name__=="__main__":
sys.exit(main())
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.