* feat(settings): compute-device override — auto | CUDA | ROCm | XPU | MPS | CPU
Auto-detect stays the default; the override kills the 'auto-detect picked
wrong' issue class. Applied at the single choke point (_probe()'s family
selection) so routing, get_best_device(), and every badge inherit it.
Resolution: OMNIVOICE_DEVICE env > Settings pick (prefs.json) > auto (#981
pattern). An override can steer, never invent hardware: a family the host
lacks is noted and ignored; cpu is always honorable. Applies at next
backend start (host caps are immutable per process — same restart contract
as the rest of the Performance tab, RestartBadge shown).
GET/PUT /api/settings/compute-device (admin-gated) reports resolved vs
applied so the panel shows restart-required truthfully and disables itself
under an env pin instead of pretending.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): entry for the compute-device override (#1557)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(device-override): harvest — the override reaches CT2 ASR, full i18n, honest edge states
- _ctranslate2_cuda_ok() and the ASR sidecar now gate on the probe's family,
so a cpu pin (or ROCm host) can never hand CTranslate2 a CUDA device —
the override reaches every CT2 loader through one shared gate
- override_ignored exposed by the API and shown by the panel (env pin naming
a device this machine lacks: auto is in effect, restart won't change it)
- all 8 panel strings + 5 device-family labels translated into all 21
locales; failed saves keep their error visible through the re-sync
- test isolation: cleanup drops OMNIVOICE_DEVICE before re-probing so no
overridden caps leak into later tests; panel tests wait for loaded state
- xpu/intel search keywords; oxfmt formatting
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(device-override): round 2 — fail-safe probe fallbacks, complete i18n, combined pin state
- a broken capability probe now means CPU everywhere (CT2 gate + ASR
sidecar) — never a torch-derived guess that would bypass a cpu pin or
re-open #1529 on ROCm; regression test added
- env-pinned AND not-detected shows both facts in one subtitle
- device_load_failed/perf_save_failed translated into all 21 locales;
CJK/th/vi/ar strings no longer say literal 'Auto'
- test_ctranslate2_never_gets_cuda_on_a_rocm_build pins the probe family
(it was order-dependent on the lru_cache before)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): pin the probe family in the faster-whisper OOM-fallback test
Same class as the rocm-build test: it mocked torch but not the probe the
new override gate consults first, so on a cpu-family CI host the CUDA
fallback chain under test was unreachable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* docs(engines): a guide for every engine + index; fix two engine-metadata bugs
21 new pages under docs/engines/ (10 TTS, 10 ASR, index README) — every
registered engine now has one: what it's for, platform support, model env
vars, quirks with issue refs. Linked from both READMEs' engine sections.
Code fixes found while verifying facts against the registries:
- KittenTTS docstring claimed default voice 'Jasper'; the code default is
expr-voice-2-f
- the isolated-ASR sidecar read only ASR_MODEL_FW while the download
preflight read ASR_MODEL_FASTER — set one and the other quietly used a
different model; both now resolve ASR_MODEL_FW-override → ASR_MODEL_FASTER
- moonshine's install hint named 'useful-moonshine', a package the backend
never imports; now moonshine-onnx / moonshine-voice
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): entries for the engine guides + sidecar model fix (#1556)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(engines): second-harvest fixes — TLS guidance, matrix/code alignment, CN counts
- README matrix aligned to gpu_compat (the code is the source of truth):
CosyVoice macOS is CPU not MPS, IndexTTS and GGUF gain their real
CUDA/CPU/MPS cells
- gpt-sovits guide: prefer https/tunnel for non-loopback servers, plaintext
warning; first-use download guidance on both OmniVoice pages
- preflight empty-env fallback matches the sidecar (ASR_MODEL_FASTER='' no
longer resolves a different repo)
- nano installs via uv pip; kitten log level wording; index links install
guides incl. the Gatekeeper step; README_CN engine counts 16/11
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(readme-cn): the all-engines-local claim now excludes the remote client
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* docs(readme): lead with download + first clone; seed benchmarks page
Quickstart (installers, install guides, a three-step first-clone walkthrough)
moves above What's-new/Features in both READMEs — visitors get the action
before the pitch. New docs/benchmarks.md anchors measured per-engine/device
numbers on the bench_pipeline.py harness, community-contributed, no estimates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(changelog): entry for the README conversion restructure (#1555)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(bench): emit RTF + CUDA peak VRAM; guard NaN RAM; define the benchmarks schema
Bot harvest on #1555: the tts stage now prints RTF per warm measurement and
CUDA peak VRAM (None elsewhere — no made-up zeros), the stage floor refuses
unmeasurable RAM instead of sailing past a NaN comparison (FLOOR_GB=0
overrides), docs/benchmarks.md columns map 1:1 to what the harness prints,
and the download badges say they open the release page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(readme): link palash.dev from the maker section
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(bench): name the resolved engine, track VRAM from resolution, comment the guards
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(readme): the quick-switch gif is the hero image
The hero shows motion now; the Launchpad screenshot moves into the 0.5.0
What's-new slot so nothing appears twice.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(bench): peak VRAM is reserved memory; adapter engines name their model
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(bench): subprocess-isolated engines report VRAM n/a, not a parent-side zero
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(bench): out-of-process detection is declarative; sherpa rows name their model
'runs_out_of_process' is now a TTSBackend attribute set by SubprocessBackend
AND omnivoice-gguf (which inherits TTSBackend directly but spawns a binary
per generate — the isinstance check missed it). Duck-typed for the same
module-purge reason as _is_subprocess_isolated. Sherpa-onnx identity comes
from _model_dir's basename when _model_id is absent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(bench): backends self-report model identity via TTSBackend.model_identity()
Greptile enumerated the adapter engines one at a time (mlx _model_id,
sherpa _model_dir, cosyvoice env-only) — the attribute sniffing rots per
engine. The hook fixes the class: each multi-model backend reports its
own identity, the profiler just asks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* test(shell): backend-lifecycle fault-injection harness
Runs spawn_backend_and_wait/supervise_backend against REAL dying child
processes and asserts the user receives the correct NAMED diagnosis —
not merely that recovery happens. Wrong/missing explanation was 61% of
the historical "can't reach the backend" class; this rig is the
permanent regression harness for every future lifecycle fix.
Seam: OMNIVOICE_BACKEND_CMD (JSON argv or whitespace form) runs any
command as "the backend" — venv bootstrap and ffmpeg resolution are
skipped, everything else (err-log run offsets, drainer threads, env
pinning, real OS pipes, spawn-failure diagnostics) stays real. Plus
OMNIVOICE_LOG_DIR (per-test log+marker dirs, also a support tool) and
harness-only timing overrides OMNIVOICE_STARTUP_BUDGET_S /
OMNIVOICE_SUPERVISOR_POLL_MS whose production defaults are pinned by
unit tests. Lifecycle fns genericized over tauri::Runtime for the
MockRuntime app; behavior-neutral with the env unset (unit-pinned).
Scenarios (tests/backend_lifecycle.rs, scenario children = this test
binary re-invoking itself; serial by mutex + CI --test-threads=1):
- port conflict (exit 78) → the detectHints-matchable port phrasing
- generic chained traceback → root cause survives into the diagnosis
and the crash marker
- spawn failure → spawn diagnostic reaches the user, NO bogus marker
- slow start past budget → timeout names the budget + last stderr
- post-Ready crash loop → 3 restarts announced, markers before restarts,
"kept crashing" diagnosis naming the last exit
- SIGKILL (unix) → named as signal 9
- deliberate kill → supervisor yields silently, no marker, never Failed
- deferred-startup FATAL → the named step reaches the user, forensics,
and the splash narration
CI: harness added to the 3-OS tauri-cross-platform matrix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(ci): temporary Windows loader bisect probe for the harness binary
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(shell): embed Common-Controls v6 manifest into Windows test binaries
Bisected on #1551: EVERY integration-test binary of this crate died at
load on Windows with STATUS_ENTRYPOINT_NOT_FOUND (0xc0000139) — cargo
gives test binaries no manifest, so the loader resolves comctl32 v5,
which lacks the TaskDialogIndirect entry point tauri's dialog/tray stack
imports. build.rs now embeds tests/windows-test.manifest via
rustc-link-arg-tests on Windows targets. Bisect probe removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): scenario gate is PID-valued — the parent can't self-inject
CodeRabbit on #1551: in a parallel local `cargo test`, the parent's own
scenario_child test could observe the armed env and start playing the
backend in-process (binding the port, idling 600s). The gate value is
now the arming process's PID; a matching PID stays inert, so only the
spawned child — a different process — runs the scenario.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(startup): bind the socket in ~1s and narrate startup step by step
The structural fix for the "can't reach the local backend" class (~1 in 5
of every issue ever filed): uvicorn served nothing until torch import
(10-20s cold), the 30-router fan-out, an import-time DB migration, the
cuDNN preload, and alembic all finished — every slow or fragile step
rendered as an unexplained dead backend.
main.py now keeps module scope fast and defers the heavy work:
- _phase_a_build (executor thread): prefs/env restore + #963 migration,
yt-dlp overlay, cuDNN preload, torchaudio, model_manager, router
imports — order preserved, literal imports so PyInstaller still traces.
- _phase_a_finalize (event loop, no awaits → atomic wrt requests):
include_router, mounts, MCP, SPA, openapi bust.
- _phase_b: the old lifespan startup body; handles on app.state so
shutdown survives a startup that never finished.
- Eager mode (pytest / OMNIVOICE_EAGER_INIT=1) runs everything at import
— byte-equivalent behavior for the ~100 lifespan-less TestClient sites
and for embedders (dump_api_routes, probe boot runner opt in).
While starting: /health answers 503 with the current step, new
/startup/progress serves the full ledger (always 200), and
StartupGateMiddleware 503s everything else with the [starting] marker
(same skip-the-Report-button convention as [shutting_down]). A deferred
failure keeps import-crash semantics: traceback to stderr → shell crash
forensics, run sentinel stays uncleared, exit 1 names the failed step.
Shell: startup_progress() probe (marker-header-gated so a foreign
responder can't narrate the splash) feeds per-step log lines into the
launch poll and the supervisor's reconnect wait. --health-check absorbs
the deferred init (60→180s); --diagnose runs Phase A up front so it
still sees restored prefs. Docker HEALTHCHECK semantics unchanged
(curl -f fails on 503 exactly as it did on connection-refused).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(startup): join the Phase A thread on shutdown; async fail-path sleep
Bot-review harvest on #1550: cancelling the deferred-startup task cannot
stop the executor thread inside Phase A's blocking imports — shutdown now
waits (bounded, only when a build started and hasn't finished) on a
thread-completion event so interpreter teardown can't race a mid-import
(#1000 class). The failure path's last-poll beat is now awaited, not
time.sleep — a blocking sleep froze the very loop that beat exists to let
serve. Also: dump_api_routes forces eager (assignment, not setdefault),
and the integration test's child gets DEVNULL instead of an undrained
pipe that could wedge a cold boot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(startup): close the Phase A submission race; CodeQL nits
Review finds on #1550: shutdown could sample _phase_a_started unset
while the executor callable was queued-but-not-running, skipping the
thread join. started is now set BEFORE submission, the submission is
shielded so a cancel can't strand a queued callable that would never set
_phase_a_finished, and the wrapper sets finished on every exit including
the already-built early return. Contract pinned by
test_phase_a_thread_join_contract. Plus explanatory comments on the new
bare excepts and a consistent return in the gate's websocket branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(shell): gate Ready on the deep health probe; pace crash-loop restarts
Two supervisor hardenings from the backend-reliability root-cause pass:
Ready now requires backend_ready() — the identity probe (/system/info
string-sniff) AND the deep probe (/profiles must 200) — at both Ready
transitions (startup poll, supervisor respawn wait). The shallow probe
alone announced a backend whose install/DB had broken underneath as up;
the UI looked alive while every real request 500'd or dead-ended on
"can't reach the backend". Death detection stays process-exit-only, so a
busy-but-alive backend is still never killed.
Supervisor respawns now back off: first respawn immediate (a one-off
crash self-heals fast), then 5s, then 15s, capped — the budget check
ends a hopeless loop, not an unbounded sleep. The pause runs behind the
already-visible "reconnecting" banner and yields within 500ms to app
quit or a deliberate retry-flow replace.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(shell): yield backoff to a tracked replacement child, not just the flag
Greptile P1 on #1548: a completed Retry/Clean&Retry sets the deliberate-
kill flag and track_backend_child clears it — possibly both between two
500ms backoff samples, so the flag alone can be missed and the old
supervisor would free_port() the retry's healthy replacement. The dead
child we observed can never read as alive again, so a live tracked child
during backoff can only be a replacement — yield to it promptly so the
retry's spawn_backend_and_wait can claim the supervisor slot at Ready.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(shell): backoff yields on spawn-generation change, not liveness
Second Greptile pass on #1548: a replacement child that itself exits
before the old supervisor's next 500ms sample read as "still dead" under
the liveness check, so ownership transfer was missed. The spawn
generation (bumped by every track_backend_child, never un-bumped) is
observable regardless of the replacement's fate — snapshot it at death
detection, yield the moment it changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(shell): snapshot spawn generation before observing the exit
Third-pass review find: sampled after try_wait, a replacement tracked in
the gap bakes its own generation into the snapshot and the ownership
transfer is missed. Snapshot first, and re-check once more before
touching the port so the zero-backoff first respawn is covered too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(triage): crash-class recurrence report — the reliability metric
scripts/crash_class_report.py measures the "backend died / never came
up" class (the project's #1 lifetime failure, ~1 in 5 of all issues)
filtered to reports from the current version — the definition of done
for the reliability cycle. Buckets by the bug reporter's Build-status
stamp (#1547): current / outdated / unknown (pre-deflection builds), so
deflection-miss noise never pollutes the number the work is judged on.
tests/scripts/test_crash_class_report.py pins the title→sub-class
mapping against the real historical title shapes and locks the stamp
literals to frontend/src/utils/bugReport.js so a reworded marker fails
in CI instead of silently zeroing the metric.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(triage): --version is authoritative; loud fetch-cap warning
Bot-review harvest on #1549: with --version, the Environment Version
line now decides the bucket (extracted to pure classify_build + tests) —
a report stamped "current at filing time" during another version's
window no longer counts toward this version's recurrence. Hitting the
500-issue fetch cap now warns loudly instead of silently understating.
The stamp lockstep test asserts the full Build-status prefix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(report): offer the latest release before filing from an outdated build
6 in 10 sampled "can't reach the backend" reports came from builds that
were already obsolete when filed, and were closed with "please update" —
pure triage noise. Every Report-bug affordance now funnels through
openBugReport(): on an outdated build it offers the latest release first
(with a "File anyway" escape hatch), and the report body carries a
triage-greppable "**Build status:**" line either way, so current-version
recurrence — the reliability metric — is countable separately from
stale-build reports.
Freshness sources per deployment (behavior identical, implementation per
mode): desktop reads the Rust updater's channel-aware verdict from the
store (no new network path, no CSP widening); browser/dev/Docker make one
bounded latest-release GET, only once the user has initiated the report
flow whose destination is github.com. An 'unknown' dev build stays
silent entirely — never nudged, never claimed current.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(report): anchor version parsing; zh-TW reportBug.title in Traditional
Bot-review harvest on #1547: parseVersionTriple now rejects trailing
non-semver data (1.2.3.4, 1.2.3garbage) instead of silently reading the
leading triple into an outdated/current verdict; the pre-existing zh-TW
reportBug.title was Simplified-script — now properly Traditional.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Defer and coalesce omnivoice.app and omni_ui persistence behind a 250 ms
quiet window with a 1,000 ms hard maximum, preserving storage schemas,
synchronous pending reads, legacy formats, Factory Reset semantics,
widget read-only ownership, and lifecycle (pagehide/visibilitychange)
durability. Adds scheduler, restore, reset, StrictMode, concurrent-render,
role-ownership, and migration regression tests plus an opt-in
production-bundle responsiveness harness.
Lands #1541 by @bultodepapas (maintainer landing branch; the out-of-scope
attribution-policy commit was dropped).
Co-authored-by: Gius <bultodepapas@gmail.com>
* fix(release): harden the AppImage repair step
All three review findings on #1544, fixed before the tag re-runs it:
- appimagetool pinned to the immutable 1.9.1 release with a verified
SHA-256 — a mutable 'continuous' binary must not execute with the
updater signing key and a release-write token in its environment
- the release tag reaches the script as env data, never interpolated
into shell source (zizmor template-injection)
- a failed latest.json download now fails the step unless the asset is
confirmed absent, and the patch refuses to upload unless at least one
linux signature was actually replaced — a repacked AppImage can never
ship paired with stale updater metadata
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(release): the updater manifest gets one final writer, after the matrix
CodeRabbit + Greptile on #1545: every tauri-action leg re-uploads the
shared latest.json, so patching it inside the Linux leg races the other
platforms — a later leg's upload could resurrect the stale pre-repack
signature. The manifest patch moves to a post-matrix job that runs once
after all legs: it aligns the manifest's linux entries with the .sig
asset that actually shipped (self-verifying — no cross-job state), and
no-ops when they already agree. The leg keeps asset repack/re-sign only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(release): a failed .sig download fails the manifest-align job
Same fail-closed rule as the manifest itself: absence is decided by the
asset list; any other download failure must not exit 0 with a stale
signature left in latest.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The v0.5.0 tag build — the first real release since #1518's guard — proved
the files-map fix loses: linuxdeploy re-links .DirIcon to an ABSOLUTE
build-machine path after tauri places the real bytes, and the guard
correctly refused to publish. tauri-action's atomic build+sign+upload
leaves only a post-upload seam, so the Linux job now repairs the packed
artifact: extract, replace .DirIcon with the icon bytes as a regular file
(nothing left to dangle), repack with appimagetool, re-sign with the
updater key, clobber the draft release's asset and patch the linux
signature inside latest.json. The existing smoke then validates the
repaired AppImage. No-ops cleanly when .DirIcon already resolves.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
CodeRabbit's last-cycle Minor on #1540, applied as the immediate
follow-up the docs-sync rule prescribes: three OmniVoice mentions in
docs/remote-workers.md now carry the product's name.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore: bump to 0.5.0
Owner-requested minor bump. package.json is the source of truth; the
three mirrors (Cargo.toml, pyproject.toml, _FALLBACK_VERSION), the three
lockfiles and the branding pin move in lockstep, and the accumulated
Unreleased section becomes the curated 0.5.0 release notes — quiet
Highlights first, one-liner subsections after, duplicates folded.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: fresh README for the VoiceStudio era; 0.5.0 notes wear the release
README: 551 lines from 656 — a What's-new-in-0.5.0 section with real
captures, the engine tables corrected to the actual 16 TTS registrations,
a stale Settings path and a broken Colab link fixed, roadmap/FAQ/credits
trimmed to what earns its place.
Release notes: the quick-switch GIF and catalogue/gallery screenshots,
captured from the running app during the pre-bump test pass, embedded
after the Highlights; #1542's gallery work and the ffmpeg CI fallback
recorded in their subsections.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: the feature inventory's Remote Model Downloads mention survives the README trim
check-docs-drift requires every docs/features.yaml name verbatim in the
README; the overhaul folded the phrase away. It now lives in the remote
workers feature line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: one blockquote, and an architecture claim that survives the opt-ins
CodeRabbit on #1540: MD028 blank line inside adjacent blockquotes, and
'every layer is on your machine' contradicted the opt-in remote paths
documented two sections away — it now states local-by-default with the
opt-ins named.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(gallery): save gallery voices as profiles, with validated audio references
Work-in-progress lifted from the concurrent gallery session at the
owner's request (its uncommitted working tree, preserved verbatim from
base 92b1ee5d; safety snapshot remains at rescue/gallery-wip):
- gallery voices can be saved as local profiles: audio is copied into
the profile store with content-addressed filenames, existing profiles
are detected and refreshed only when the source clip changed
- backend/core/audio_validation.py: symlink-rejecting, root-contained
resolution for persisted profile WAV references, with tests
- archetype/community routers and the Voice Gallery UI updated for the
save-as-profile handoff (spec: docs/specs/longform/26-gallery-use-handoff.md)
- locale updates for the new gallery strings across all 21 files
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: drop a stray local screenshot script that rode in with the tree copy
* fix(community): explain the tolerated Content-Length parse failure; drop an unused import
CodeQL on #1542: the empty except now says why it is safe (the streamed
byte counter enforces the same cap regardless), and the test file loses
an unused Path import.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gallery): review findings — copy outside the write lock, no stale completions
CodeRabbit on #1542, all findings addressed:
- the profile-audio copy stages to a .part temp BEFORE BEGIN IMMEDIATE
and publishes via atomic os.replace inside it — other backend writers
no longer block for the duration of an audio copy; a mid-copy failure
leaves no temp droppings and no profile row (both pinned by tests)
- VoiceGallery async ops carry per-operation generation tokens: a
preview or save-as-profile that resolves after unmount (or after a
newer operation) can no longer play audio, redirect into a workspace,
or touch state — three fail-before regression tests
- VoiceGalleryActions imports the page at test runtime; the e2e locator
uses a stable data-testid instead of a translated string; symlink
tests skip cleanly where the OS can't create symlinks; the changelog
line carries its PR ref
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci: static ffmpeg fallback when the chocolatey feed is down
Third feed outage to break a PR run (2026-07-20, 2026-07-28, today —
three attempts, three 'installed 0/1'). Chocolatey is a distribution
channel, not the dependency: after the retry loop exhausts, fetch the
static gyan.dev build from its GitHub release mirror and put it on
PATH — same binary, no feed in the path. URL verified live (HTTP 200).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
An RX 7900 XTX in the :rocm image crashed ASR init with 'CUDA driver
version is insufficient for CUDA runtime version' (#1529): ROCm torch
answers torch.cuda.is_available() and hands out 'cuda' device strings,
but whisperx/faster-whisper run on CTranslate2, whose CUDA runtime is
NVIDIA-only. Same class as the Apple/#1127 lesson, on the AMD axis.
- _ctranslate2_cuda_ok(): 'cuda' for CTranslate2 only when torch is a
real CUDA build (torch.version.hip is the honest tell); ROCm hosts
take CPU int8 instead of a native crash.
- _auto_detect(): on a ROCm-GPU host prefer pytorch-whisper — a pure
transformers pipeline riding torch itself, so it actually uses the
HIP GPU while CTranslate2 engines would idle on the CPU.
Fail-before/pass-after: 4 new tests fail on the old device pick/order.
Fixes#1529
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(engines): open the door between the LLM family and its providers
The openai-compat family entry and the LLM Providers panel are one
system — llm_backend resolves every call through the active provider —
but the UI presented them as unrelated (council coherence finding). Now:
- the catalogue's openai-compat row carries a 'Provider · model' hint
naming the endpoint that actually answers (decorative: a provider
registry hiccup degrades to no hint, never a failed listing)
- the row offers 'Configure providers' straight into Settings → LLM
Providers; the panel gains the backlink into catalogue → LLM family
- three new strings in all 21 locales, matching each file's provider
terminology
Also: bugReport's encoded-ceiling test is hermetic now — it was the one
test in its file trusting ambient fetch, and hung on any machine where a
local backend holds the port without answering.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(i18n): the catalogue note names the ACTIVE provider, not the edited one
CodeRabbit on #1538: the panel can be editing a provider that is not
active, and 'this provider answers…' then points at the wrong one. The
note now says the provider MARKED ACTIVE answers, which is true under
any selection — no state-dependent copy needed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(worker): reserve the capacity slot before announcing the accept
Two changes for #1536 (flaky test_worker_at_capacity_rejects_without_penalty):
The client now inserts into _running BEFORE awaiting the accept send.
Today _send enqueues synchronously so the old order could not actually
interleave — but the reserve-then-announce order is the invariant that
stays correct if _send ever gains backpressure (a bounded outbox is the
natural evolution), instead of silently reopening an over-accept window.
If the accept send fails, the reserved task is cancelled: work the
scheduler never saw accepted must not run to double-execution.
The test now pins the real invariant — no over-concurrency — rather than
the scheduler's bookkeeping timing: on a loaded CI runner the first
attempt can die environmentally (a stream hiccup fails _run, whose
finally frees the slot), after which accepting the second task is the
CORRECT behaviour the old flat assertion punished as a failure. The
assertion now applies only while the first attempt is still running, and
names the over-accept explicitly when it fires.
Fixes#1536
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(worker): release the reserved slot on a cancelled accept-send
CodeRabbit on #1537:
- except BaseException, not Exception: a handler cancelled while the
accept send is in flight must release the reserved slot too, or the
unaccepted task keeps running and double-executes after reassignment.
Fail-before/pass-after regression test included.
- the capacity test now polls the second task out of its dispatch states
instead of sleeping 0.5s, and asserts the penalty-free invariant
(excluded_workers empty) unconditionally — capacity rejections never
exclude the worker regardless of the first attempt's health.
- TaskAccepted.envelope finding skipped: the field is read nowhere
server-side (registration is the only envelope consumer) — pre-existing
unused-field design, not introduced here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(i18n): 53 default-value-only strings now speak all 21 languages
Every string used via t(key, { defaultValue }) without a locale entry
rendered English for every non-English user — the worker/compute chrome
(quick settings, join panel, QR enrolment), clone/design labels, workspace
voice strip, and two crash explainers. All 53 keys now exist in en.json
and carry reviewed translations in the 20 other locales, matching each
file's established terminology (existing worker/token/engine vocabulary,
catalogue tab names for the in-text path references, registers preserved,
{{placeholders}} byte-identical, the ovw_ token prefix untranslated).
The two crash explainers are translated from their FULL concatenated
source text — the extraction initially captured only the first string
segment, which src/test/streamDropError.test.ts caught by failing on the
missing proxy/buffering guidance.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(i18n): reference localized UI labels inside diagnostic strings
CodeRabbit on #1534, fixed as the class: every locale's crash_broken_env
quoted the English "Clean & Retry" although the button itself is
localized — all 19 now quote each file's own clean_retry label. Plus the
flagged singles: es unload verb disambiguated from downloading, hi unload
verb aligned with crash_oom_kill, sv kontrollplan gender agreement, de
crash_broken_env moved to the file's Sie register, ru seed_reroll_hint
mistranslation, zh-TW path label matched to the real 系統日誌 section name.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The same cover artwork the project uses on the web, bleeding in from the
hero's right — where the layout holds only air — behind a radial feather
plus right-edge fade so no box edge survives, screen-blended so its dark
field vanishes into the chrome. Decorative: aria-hidden, empty alt,
pointer-inert; 22 KB webp bundled via vite.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(crash): bound crash evidence to the run that produced it
backend_err.log is one file shared by every backend run, and it was
TRUNCATED on each spawn. Both properties destroyed the evidence a crash
marker exists to carry: a respawn wiped the dead process's final words,
and an unbounded tail read afterwards attached the replacement's healthy
startup to the old run's crash marker — the undiagnosable report in #1510
(startup lines, no traceback, timestamps after the recorded crash).
The file is append-only now with a run-start header; each spawn records
the byte offset where its run begins; and every death path (crash markers,
venv-heal detection, restart-budget message, the 300s startup timeout)
reads through read_error_log_tail_for_run(), which cannot see another
run's output. The log rotates to backend_err.log.1 past 1 MiB so
append-only cannot grow unbounded. Bootstrap-phase reads (uv sync) keep
the whole-file reader — no backend run exists yet there.
Fail-before/pass-after: the new tests fail under the old File::create
truncation and unbounded tail.
Fixes#1510
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(crash): flush the dying run's stderr before the next offset; redact home paths
CodeRabbit on #1532:
- the stderr drainer is tracked now and joined (2s bound) before a new
spawn records its offset, so a dead run's buffered tail cannot be
appended after the new run's start and misattributed. Full per-child
offset binding is unnecessary: spawns are serialized by the #1223
spawn-once flow; the buffered tail was the only remaining window.
- the spawn-failure diagnostic redacts the home-directory prefix — it is
retained across runs now and lands verbatim in bug reports.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The catalogue's group captions read "Available" / "Not installed", which
renders a fresh install (3 of 16 engines ready) as a mostly-broken app.
The sections now say "Ready to use" / "Add more engines", and the
unavailable-row details toggle asks "What it needs" instead of "Why
unavailable?" — same information, framed as headroom to unlock.
Council outcome (first-run seat): unavailable engines must read as more
you could install, never as brokenness. Keys added to all 21 locales.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(engines): add quick switching controls
* docs(changelog): note engine quick switching (#1530)
* fix(support): theme amount cards
* fix(support): restore themed amount cards
* fix(engines): address quick switch review findings
* fix(engines): green the full frontend suite around the quick switch
Three failure classes the targeted runs missed:
- the popover referenced --chrome-radius, which does not exist; it now
wears the footer's shared MENU_SURFACE like the compute popover
- LogsFooter tests hand-wrote their api/system and api/hooks mocks, which
drop every export the footer gains next; they are partial mocks now
- DubHeader/AudiobookHero tests rendered without a QueryClientProvider,
which useEngines needs
Also: workspace-header chips open the popover downward (dropUp stays on
the footer instance) so it cannot clip off the top of the viewport.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(engines): one QueryClient per test module, not per render
CodeRabbit: the inline client made every wrapper render a fresh cache,
so rerender() restarted the /engines query mid-test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(wayland): rewrite a stale portal identity instead of trusting it
Found live: with the desktop entry's Exec pointing at a binary that had
been moved, GLib resolves the entry to NULL, the host portal answers
"Could not register app ID: App info not found", CreateSession then
fails with "An app id is required" — and the dictation shortcut is
silently dead for the entire session. Only the focused-window fallback
keeps working, which reads as "the shortcut randomly stopped".
ensure_desktop_identity() trusted any existing entry. It now validates
the USER-LOCAL entry's Exec target and rewrites the entry when the
program is gone (parsing both the current quoted spelling and the
unquoted one older builds wrote). System-dir entries stay untouched —
deb installs manage their own.
The stale-entry class is easy to hit in the wild: a dev entry pinned to
target/debug survives cargo clean; an AppImage entry survives the file
being moved or renamed.
(Rebuilt from the first push, whose `git add -A` had swept in another
working session's unrelated in-progress files; this commit carries only
the wayland fix and its changelog line.)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(wayland): read Exec from the Desktop Entry group only
CodeRabbit, #1526: find_map over every line accepted an Exec= from a
[Desktop Action …] group, so an entry with no main-group Exec — which
GLib resolves to NULL — could be retained as healthy, keeping exactly
the stale identity the rewrite exists to replace. Parsing is scoped to
[Desktop Entry] now, with an action-only regression case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(asr): parse the guard, don't grep it
Two Majors CodeRabbit raised on #1523 — which I merged before reading
them, so this is the follow-up rather than a fix on the branch.
- Approved files were matched by BASENAME, so any future
`<anything>/asr_backend.py` was exempt from the guard it exists to
enforce. Matching is by relative path now; a decoy
`backend/engines/asr_backend.py` calling the selector is caught.
- Detection was a line regex, wrong in both directions: it missed
`import get_active_asr_backend as pick` and fired on the name inside
docstrings and comments. It walks the AST now, alias-aware, so only
real calls count.
Both verified by planting the exact bypasses: an aliased call in
services/tts_backend.py and the decoy module above. Neither was caught
before this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(asr): resolve the selector's bindings before flagging a call
CodeRabbit, #1524: matching any call named get_active_asr_backend also
reported a local helper or an unrelated object's method that happens to
share the name. False positives are how a guard stops being believed —
people add allowlist entries for code that was never the bug.
Bindings are resolved first now: a bare call counts only if the name was
imported FROM services.asr_backend, an attribute call only if it hangs
off a module alias for it. Six shapes are pinned in the suite — direct,
aliased and module-attribute calls flagged; a same-named local function,
an unrelated method, and the name inside a docstring not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ui): one support page, and tabs where there were toggles
Sponsor, commercial licence and contact were three destinations for one
question — how do I support this / how do I reach these people — and each
one made you leave to find the others. They are now three sections of a
single page: support, licence, contact, in that order, separated by a
hairline rather than more chrome. Every existing entry point still works;
`initialView` scrolls to the right section instead of hiding the other
two, so the footer heart, the dub/export commercial-licence links and
Contact all land where they meant to.
ContactPage becomes `ContactSections` — the body without the shell — and
its "Support the project" CTA now scrolls up to the support section
rather than navigating, because that surface is on the same page.
Model Catalogue: the Engines/Models switch and the matrix's TTS/ASR/LLM
switch are tabs, not Segmented. These pick between workspaces, not
between the two states of one setting, and Tabs carries roving tabindex
and role="tab" from the primitive. The matrix tabs keep their active
engine chip and now keep their hover title too — Tabs passes `title`
through.
Tests: the pane/family switches are driven by pointer down, not click —
Radix activates on pointer down, so a bare fireEvent.click leaves the
pane unchanged and reads as a switcher that ignores itself. The contact
suite now covers the section (its host owns the header), and asserts the
support CTA scrolls without ever reaching for Ko-fi.
Full frontend suite: 2022 passed. The one unhandled `window is not
defined` rejection in the parallel run predates this change — same error,
same count, on the base commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(engines): available engines first, and unavailable ones recede
Two things the matrix got wrong for a list you pick FROM: it rendered in
payload order, so a usable engine could sit under four you cannot select,
and an unavailable row was faded WHOLE — which took its status badge and
GPU chips down with it, the two things that say why it is unavailable.
Available rows now sort to the top, preserving registration order inside
each group (that order is meaningful — it puts the defaults first). The
name of an unavailable engine recedes instead, and its mark dims with it;
the evidence stays at full contrast.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(support): the footer heart returns you to the support section
CodeRabbit, #1522: App.jsx renders SupportPage in the same tree position
for donate / enterprise / contact, so React keeps ONE instance and only
swaps props. The scroll effect treated 'support' as "already at the top"
and returned early — correct for a fresh mount, wrong for the only way
this page is actually reached. Clicking the footer heart from the contact
section left you sitting on contact.
Every view scrolls now. The regression test drives the prop change the
way the router does and fails without the fix.
Also adds the (#NNN) refs the Unreleased entries were missing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(support): cover the enterprise route's licence section
CodeRabbit, #1522: the suite drove support and contact but not the third
destination — and every section renders regardless, so only the scroll
target proves the mapping. Uses the exact initialView App.jsx passes for
mode === 'enterprise'.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The recurrence guard from #1512 scanned api/routers only. A service or
engine module that transcribes on a request's behalf skips ensure_loaded()
just as thoroughly, so the guard could be sidestepped by moving the call
one module down the stack — verified: adding a get_active_asr_backend()
call to services/tts_backend.py passes the router scan and fails this one.
The broader scan is the one thing #1519 did better than the fix that
landed in #1515; absorbing it here rather than leaving it in a PR that
now conflicts. Thanks @ahov520.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(launchpad): quieter, borderless design refresh
The launchpad carried decoration from an earlier direction: icon chips,
corner-hung count badges, a permanently visible filled arrow, uppercase
mono card titles, and a dotted stipple divider — plus a frame that had
been invisible since the app-wide border tokens were zeroed.
Rework it around what the borderless direction actually implies:
- Feature tiles get a whisper-faint surface instead of a dead frame, and
read as three bands (bare glyph + count / title + arrow / description).
`--card-hue` is spent sparingly — the glyph at rest, the surface, count
and arrow only once raised. Titles move to sans sentence case; counts
are plain tabular numerals. Lift softened 4px -> 2px, coloured glow ->
neutral shadow, plus an explicit focus ring and a staggered entrance.
- Hero drops the boxed "646" pill and the filled A/B-Compare button for
quiet type, with a hairline standing in for the separation.
- Section labels trade the dotted stipple for a single fading hairline;
rows are transparent until hover and reveal "Open" on hover/focus (it
stays in the DOM, so AT and keyboard always reach it).
- Hero, tiles, recent files, callout and project lists now share one
1180px column — previously only the top half was capped, so lists ran
edge-to-edge on a wide display while the deck stayed centred.
Two bugs found and fixed while doing it:
- Buttons that had `border border-solid border-transparent` removed fell
back to the UA default border and rendered a visible 1px outline. They
now carry `border-0` explicitly.
- `.lp-animate` used `animation-fill-mode: both`, so after the entrance
it kept owning `transform` — and animation-origin declarations outrank
normal ones, which silently killed the card hover lift. Now `backwards`,
which still holds the from-state through the stagger delay.
Also drops CSS the page has not rendered since #904: the cursor-spotlight
layer, the breath ring, and the per-card waveform strip.
Verified with headless renders at 1600/1280/940 and the empty state.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(dictation): decode Wayland portal signals and show the capture pill
The GlobalShortcuts portal declares Activated/Deactivated as
(o session, s shortcut_id, t timestamp, a{sv} options). We decoded the
timestamp as u32, so zbus rejected every signal with
Signature mismatch: got `(osta{sv})`, expected `(osua{sv})`
and the press was dropped as an invalid signal. Registration succeeded
and the desktop even reported the bound chord back, so the hotkey looked
wired up while doing nothing at all — on every Wayland compositor, for
the whole life of the feature (#1490). Decode the 64-bit timestamp, and
keep the 32-bit spelling as a fallback so a non-conforming portal
degrades to working rather than to silence.
With presses arriving, the second half of the failure showed: nothing
had shown the widget window since it became a hidden recorder host, so a
capture ran with no pill on screen — and a mic or Accessibility failure
rendered into a window nobody could see. Add show_dictation_pill, which
bottom-centres the capsule on the monitor under the pointer and shows it
without taking focus (Windows keeps SW_SHOWNOACTIVATE so paste still
lands in the user's document), and call it from the widget for every
state but idle. Wayland denies clients their own placement, so the
compositor picks the spot there; the pill still appears.
dispatch_dictation_capture now logs whether a press was emitted or
queued — a press that reaches Rust and produces nothing was otherwise
indistinguishable from one the compositor never delivered.
Tests: portal signals decode at both timestamp widths (the 64-bit case
fails before this change with the exact production error); pill
placement centres, respects a second monitor's origin, and clamps rather
than going off-screen; the widget shows for a state needing the user,
stays hidden while idle, and never shows for a press that arrives while
dictation is disabled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: sync in-progress workspace changes
Uncommitted work already in the tree, checkpointed so the branch matches
the local machine:
- Remote GPU workers: join-from-the-app flow, one-time secrets, QR join
codes, a Compute control in the status bar, and the device-list
Workers panel (#1516)
- Model Catalogue workspace, with Settings pointing at it
- Settings sidebar search and keyboard navigation
- Demo assets for dubbing, dictation and voice design, plus the scripts
that render them
- Backend: validation-error handling, ASR request-path degradation, and
the accompanying tests
- CHANGELOG entries for the above and for the Wayland dictation fix
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(tests): follow Engines to the Model Catalogue, and green the sweep
- test_supertonic3 asserted the license gate points at "Settings" while
the engine now names Model Catalogue → Engines, which is where the
accept button actually lives. The assertion follows the move; what it
pins is unchanged — the hint must name a place the user can reach it.
- Carries the CJK allowlist entries for the rendered dub bundle (#1517)
and the regenerated route snapshot for /workers/agent (#1516), both of
which this branch inherits from the workspace sync.
- docs/install/linux.md: the dictation capsule is bottom-anchored
everywhere except Wayland, where the protocol gives applications no
say in their placement. Documented rather than left as a surprise
(CodeRabbit).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: stop a flaky dependency fetch from failing green runs
en-core-web-sm resolves to a direct GitHub release URL, and github.com
intermittently answers `http2 error: refused stream before processing
any application logic`. uv's own three retries all land within the same
few seconds and fail together, so the whole job dies on a dependency
that has nothing to do with the change under test — it cost #1518 and
#1517 an otherwise-green run tonight.
Two changes: back off between whole `uv sync` attempts, which is what
actually clears it, and pass --no-sync to the pytest steps. `uv run`
re-resolves the environment before running, so every test step was a
fresh chance to hit the same fetch even though the install step had
already synced — that is exactly how #1518 failed, in the isolated
backend/tests step, with all 5467 tests already passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: one retry seam for every uv sync, not just the job that failed last
en-core-web-sm resolves to a direct GitHub *release* URL rather than a
package index, and github.com intermittently answers `http2 error:
refused stream before processing any application logic`. uv's own
retries all land inside the same ~10 seconds and fail together, so a job
dies on a dependency unrelated to the change under test. Tonight that
cost four otherwise-green runs across #1515, #1517 and #1518 — and the
first fix only covered the Tests job, so the next failure simply moved
to Smoke (Linux), which syncs separately.
The fetch is per-job, so the fix has to be per-job: scripts/uv-sync-retry.sh
backs off between whole attempts (15s, 45s, 90s) and every workflow that
syncs now goes through it — ci.yml (tests + the platform matrix),
release.yml, security.yml, evals.yml. It still fails loudly after four
attempts, so a genuinely broken lockfile is not disguised as a flake.
The Tests job also lacked the UV_HTTP_TIMEOUT / UV_HTTP_RETRIES the smoke
matrix has always set, which is part of why it was the one that kept
dying; it has them now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(ci): pin the Intel-Mac contract by intent, not by command spelling
test_ci_verifies_intel_mac_as_the_documented_remote_only_host asserted
the literal line `run: uv sync --extra pockettts`, so routing every sync
through scripts/uv-sync-retry.sh read as a broken Intel-Mac contract. The
contract it exists to protect is that the pockettts extra installs ONLY
on backend_supported legs — which the regex now pins, while leaving how
the sync is invoked free to change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: keep every uv run out of the resolver, and bound the retry budget
CodeRabbit, #1517:
- `uv run` re-resolves before running, so the smoke suite, the
worker-artifact tests, the release test run and the eval run were each
a fresh chance to hit the flaky direct-URL fetch outside the retry
loop. All of them pass --no-sync now; the environment is already
synced by the step that owns the retries. security.yml's
`uv run --with pip-audit` is deliberately left alone — it layers an
ephemeral package rather than running the project's own tests.
- The retry count multiplied uv's own budget (UV_HTTP_RETRIES=5 with a
120 s timeout on the smoke matrix). Three attempts and 60 s of total
backoff outlast the refusals actually observed while staying well
inside the jobs' timeout-minutes.
- The Intel-Mac contract test pinned the smoke command literally too, so
--no-sync tripped it exactly like the sync line did. Same fix: assert
the contract (smoke runs only on backend_supported legs), not its
spelling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(demos): ship the demo audio and video the app already advertises
Every demo asset in the app was a dead link on anything but a Mac.
`personalities.py` has carried a `preview_url` for each of the seven
voice-design presets since they were added; DictationDemo.jsx posts three
bundled WAVs to /transcribe so the feature can be shown without microphone
permission; the Dub workspace reads a manifest and plays a source video plus
four dubbed languages. None of those files were committed, because the tooling
that renders them (scripts/build_demos.sh, scripts/build_dub_demo.sh) hard-
requires macOS `say` — it even carries a `TODO: add espeak-ng path for Linux
contributors`. So the presets returned 404, the replay buttons did nothing, and
the dubbing demo never loaded.
Rendered with VoiceStudio's own engine, which runs wherever the app does:
- 7 voice-design previews (2.2 MB)
- 3 dictation replay clips (1.1 MB) — verified by transcribing them back:
the conversational and French clips round-trip exactly
- dubbing demo: source + 4 dubbed videos with subtitles and manifest (9.6 MB)
Tooling fixes this turned up:
- build_dub_demo.sh wrote to backend/assets/demo/dubbing, but main.py mounts
backend/assets/samples at /demo_audio — so the frontend's
/demo_audio/demo/dubbing/manifest.json could never have resolved even after
a successful Mac build. Output moved under the mount.
- `say` is now the fallback rather than the requirement: the new
scripts/render_dub_demo_audio.py renders the five tracks with the engine and
the shell script picks them up.
- The five demo paragraphs lived in two files. They are now one JSON both read
— two copies is one edit away from a video whose subtitles disagree with it.
- render_demos_omnivoice.py peak-normalized, which a single-sample transient
defeats: the Helpdesk preset landed at -30 dB RMS against -17 dB for its
neighbours, so the preview row played at wildly different volumes. Now EBU
R128 at -18 LUFS with a -1.5 dBTP ceiling.
- …and pinning the output rate, because loudnorm resamples to 192 kHz
internally and writes there unless told otherwise, which turned 2.1 MB of
previews into 17.5 MB of identical-sounding audio.
- update_manifest() looked for a manifest at a path nothing writes, so it
always printed "not found" and did nothing.
- Dictation is rendered here now too. It was excluded on the grounds that
`say` was good enough and engine TTS was overkill — true only on macOS.
tests/test_demo_assets_exist.py resolves every advertised URL against the
directory main.py actually mounts, and checks each dubbing subtitle matches the
script its manifest entry claims. A missing static file is not an import error
and not a failing request; nothing would have caught this otherwise.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(changelog): stamp the demo-asset entries with their PR ref
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(demos): watermark rendered demo audio, and harden the render scripts
Review findings on #1517:
- Greptile P1: the renderers wrote engine output straight to disk, so a
re-render shipped demo audio with no provenance mark. These clips play
back to users as VoiceStudio output — they are synthetic audio leaving
the app like any other, and now go through mark_synthetic (#1169), the
one chokepoint every producing route uses. It runs on the file AFTER
loudnorm, since loudnorm re-encodes what it is handed, and says so
loudly when marking is unavailable rather than committing an unmarked
asset. The dubbing renderer shares the same helper.
- CodeRabbit: build_dub_demo.sh checked only source.src.wav before
deciding it could run without macOS `say`, so a Linux or Windows run
with four of five tracks present reached a missing one, called `say`,
and left a half-built bundle. It now requires all five.
- CodeRabbit: shutil.move over an existing path delegates to os.rename,
which raises FileExistsError on Windows — os.replace overwrites
atomically everywhere.
- CodeRabbit: the preview test discovered presets in a parametrize
argument, importing app code at collection time and leaving
core.personalities in sys.modules for later tests. Discovery moved into
the test body.
CI: the rendered dub bundle's zh/ja subtitles, its manifest and the
script source are dubbing CONTENT, not UI strings — allowlisted in
test_no_hardcoded_cjk.py with that justification.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(demos): a render that cannot be watermarked fails instead of warning
CodeRabbit and Greptile, #1517: mark_synthetic degrades rather than
raising — correct for generation, wrong for a render script, whose whole
job is to produce files a human then commits. A printed warning on a
scrolling console is not a gate, so both scripts exited 0 with unmarked
assets sitting on disk ready to commit. They now raise, with the reason
and the fix; OMNIVOICE_DEMO_ALLOW_UNMARKED=1 stays for a local listen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: stop a flaky dependency fetch from failing green runs
en-core-web-sm resolves to a direct GitHub release URL, and github.com
intermittently answers `http2 error: refused stream before processing
any application logic`. uv's own three retries all land within the same
few seconds and fail together, so the whole job dies on a dependency
that has nothing to do with the change under test — it cost #1518 and
#1517 an otherwise-green run tonight.
Two changes: back off between whole `uv sync` attempts, which is what
actually clears it, and pass --no-sync to the pytest steps. `uv run`
re-resolves the environment before running, so every test step was a
fresh chance to hit the same fetch even though the install step had
already synced — that is exactly how #1518 failed, in the isolated
backend/tests step, with all 5467 tests already passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: one retry seam for every uv sync, not just the job that failed last
en-core-web-sm resolves to a direct GitHub *release* URL rather than a
package index, and github.com intermittently answers `http2 error:
refused stream before processing any application logic`. uv's own
retries all land inside the same ~10 seconds and fail together, so a job
dies on a dependency unrelated to the change under test. Tonight that
cost four otherwise-green runs across #1515, #1517 and #1518 — and the
first fix only covered the Tests job, so the next failure simply moved
to Smoke (Linux), which syncs separately.
The fetch is per-job, so the fix has to be per-job: scripts/uv-sync-retry.sh
backs off between whole attempts (15s, 45s, 90s) and every workflow that
syncs now goes through it — ci.yml (tests + the platform matrix),
release.yml, security.yml, evals.yml. It still fails loudly after four
attempts, so a genuinely broken lockfile is not disguised as a flake.
The Tests job also lacked the UV_HTTP_TIMEOUT / UV_HTTP_RETRIES the smoke
matrix has always set, which is part of why it was the one that kept
dying; it has them now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(ci): pin the Intel-Mac contract by intent, not by command spelling
test_ci_verifies_intel_mac_as_the_documented_remote_only_host asserted
the literal line `run: uv sync --extra pockettts`, so routing every sync
through scripts/uv-sync-retry.sh read as a broken Intel-Mac contract. The
contract it exists to protect is that the pockettts extra installs ONLY
on backend_supported legs — which the regex now pins, while leaving how
the sync is invoked free to change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: keep every uv run out of the resolver, and bound the retry budget
CodeRabbit, #1517:
- `uv run` re-resolves before running, so the smoke suite, the
worker-artifact tests, the release test run and the eval run were each
a fresh chance to hit the flaky direct-URL fetch outside the retry
loop. All of them pass --no-sync now; the environment is already
synced by the step that owns the retries. security.yml's
`uv run --with pip-audit` is deliberately left alone — it layers an
ephemeral package rather than running the project's own tests.
- The retry count multiplied uv's own budget (UV_HTTP_RETRIES=5 with a
120 s timeout on the smoke matrix). Three attempts and 60 s of total
backoff outlast the refusals actually observed while staying well
inside the jobs' timeout-minutes.
- The Intel-Mac contract test pinned the smoke command literally too, so
--no-sync tripped it exactly like the sync line did. Same fix: assert
the contract (smoke runs only on backend_supported legs), not its
spelling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(workers): join from the app, share by QR, and a status-bar Compute control
Remote workers shipped with a hole in the middle: the control plane could
mint join codes, and on the other machine there was nothing to paste them
into. Becoming a worker meant launching with OMNIVOICE_WORKER_MODE and
OMNIVOICE_WORKER_TOKEN in the environment and relaunching — on the machine
that is usually the least convenient one to configure by hand.
Backend
- GET /workers/agent, POST /workers/agent/join, POST /workers/agent/enabled.
Join redeems a code and starts the agent live; no restart.
- Worker mode now persists in settings as well as the environment (env still
wins, and the panel is told so it can disable a switch it cannot honour),
and it is written only after a join that actually worked — a failed
enrolment must not have the app retrying on every launch.
- The endpoint carried by the redeemed code is remembered. Without that a
machine that joined from the UI came back up enrolled but with nowhere to
dial, and the only fix was OMNIVOICE_WORKER_ENDPOINT.
UI
- "Lend this machine's GPU": paste the code, Join. Once joined it offers a
switch rather than another code, because the pinned certificate survives.
- <OneTimeSecret/> renders join codes and connection strings as a QR next to
the text, with a live expiry countdown, and is used by both halves. QR
generation is best-effort: a string past the format's capacity still shows
the code and Copy, because losing the QR is a degraded share and losing the
only copy of a one-time secret is data loss.
- Status-bar Compute control: pick local or a machine, flip the feature, mint
a join code — without opening Settings. Absent entirely until the user has
opted in or enrolled something.
- Remote workers now reads as a device list: status dot, address, latency,
live task meter, resident models, last seen; housekeeping actions revealed
on hover; a three-step empty state.
- Approve is on the row. A worker could connect, sit there labelled "Not
approved" and never be usable, with no way out of it in the UI.
Fixes found on the way
- Status dots and menu surfaces in the GPU picker were painted from fixed
Tailwind palette classes (bg-emerald-400, text-amber-400, hover:bg-white/5),
so on Midnight or Catppuccin they showed Gruvbox colours next to the
theme's own. Both controls now paint from themed --color-* tokens, shared
in computeTarget.jsx along with the JSON wrapper all three copies duplicated.
- Button funnels every child into one <span>, so an icon passed as a child
renders glued to its label — the flex gap only applies to the `leading`
slot. Six buttons across these panels were affected.
- InboundNodePanel passed `variant="warning"` to Badge, which takes `tone`;
the "on your network" warning rendered as an ordinary neutral pill.
Docs updated in the same change (docs/remote-workers.md): the join flow, the
QR, the status-bar control, and the new environment variable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(changelog): stamp the remote-workers entries with their PR ref
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(workers): a join is not done until the control plane accepts it
Review findings on #1516:
- Greptile P1: `start()` only SCHEDULES the dial-out loop, so a control
plane that rejected this worker — expired token, wrong address, a
server that never answers — looked identical to a successful join. The
route persisted worker mode, reported success, and the machine retried
forever on every launch. The agent now signals first registration, and
join waits for it before persisting anything.
- CodeRabbit: a failed REJOIN left the machine unable to reconnect to the
control plane it was already serving, because pinning the new
certificate overwrites the old one on disk. Snapshot the pinned
certificate, endpoint and setting up front, and restore them (and the
running agent) when the join fails.
- CodeRabbit: join and the enable toggle awaited stop()/start() with no
exclusion, so two concurrent requests could interleave their pairs and
have `start()` return early — reporting success for a control plane it
never dialled. Both now hold one lifecycle lock.
- CodeRabbit: with OMNIVOICE_WORKER_MODE set, the toggle still started or
stopped the agent and wrote a setting the rest of the app ignores,
contradicting the env_pinned status it reports. It now answers 409 and
says which variable is in charge.
- CodeRabbit: the QR code kept encoding the previous secret until the new
one finished encoding, so the code on screen could disagree with the
text beside it.
CI: regenerated tests/fixtures/api_routes.txt for the three
/workers/agent routes.
Tests: a join the control plane never accepts is a 409 that persists
nothing and leaves no agent dialling; a failed rejoin restores the
previous certificate, endpoint and setting; an env-pinned machine
refuses the toggle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(workers): the environment pin governs joining too, not just the toggle
CodeRabbit, #1516:
- join_control_plane skipped the env_pinned guard set_agent_enabled
enforces, and joining is precisely what ENABLES worker mode: under
OMNIVOICE_WORKER_MODE it wrote a setting nothing consults, and with the
variable pinned off it handed back a machine that reported a successful
join and lent nothing. One shared guard now covers both routes.
- Two of the three rollback assertions could not fail before the fix
(nothing wrote those settings on the failure path). The test now pins
the behaviour only the rollback produces: the previous enrollment is
dialling again, rather than left stopped until someone notices.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(appimage): stop shipping a dangling .DirIcon, and prove it in CI
The Linux icon is blank because the AppImage's .DirIcon is an absolute
symlink into the machine that built it. From the published v0.4.2:
.DirIcon -> /home/runner/work/OmniVoice-Studio/OmniVoice-Studio/frontend/
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/
appimage/OmniVoice Studio.AppDir/OmniVoice Studio.png
That path exists on nobody's computer. The link dangles the moment the
AppImage leaves CI, so file managers have no icon for the file, and the
integration tools that read .DirIcon install nothing. A dangling symlink is
not a build error — the bundle packs, runs, and passes every check we had —
which is how it shipped for a whole release without anyone noticing.
Locally built AppDirs are worse: both .DirIcon AND the root .desktop symlink
come out absolute, so a from-source bundle has no readable desktop entry
either, which is why the icon is missing in the menu and the dock too.
- `.DirIcon` is now a real file, copied in through `appimage.files` — the
same seam that already places the WebKitGTK marker.
- `bundle.category` is set, so the generated desktop entry stops emitting an
empty `Categories=`. That is not the same as omitting the key:
desktop-file-validate rejects the entry and menu builders skip it.
- verify-apprun-bundle.sh — already run against the extracted AppImage in the
release job — now fails when .DirIcon is missing or resolves outside the
bundle, when the .desktop entry does not resolve inside it, when Icon=
names a file that is not at the AppImage root, or when Categories= is
present but empty. Its unit test covers each of those, including the exact
shape v0.4.2 shipped.
The `.DirIcon` copy cannot be verified without a full release build, so the
guard is the load-bearing part: the next release either passes it or fails
loudly. It can no longer ship blank in silence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(changelog): stamp the AppImage icon entries with their PR ref
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(changelog): fold the AppImage icon fix into the existing Fixed section
CodeRabbit (#1518): the Unreleased block must carry one `### Fixed`
section of one-line entries. Merge the two entries in and drop the
narrative and the version reference.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>