The one-section-per-version check matched raw heading text, so [v0.5.2]
and [0.5.2] would pass as two versions while core.changelog, which serves
the in-app notes, strips the v and whitespace and reads them as one. The
check now uses parse_changelog itself.
Cut the Unreleased notes as 0.5.2 (2026-09-10) and fold in the untagged
0.5.2 section prepared on 2026-09-02, so release.yml publishes one
complete section. A test now requires one section per version.
The backend runs inside the app's tree, and the uv processes it starts for
engine installs had no working directory of their own. uv therefore
discovered VoiceStudio's pyproject.toml and applied its [tool.uv]
constraint-dependencies, torch==2.8.0 among them, to each engine's venv.
Resolved that way, MOSS-TTS-v1.5 (torch==2.9.1+cu128) and Confucius4
(torch==2.7.0) are unsatisfiable, so their one-click installs and
bootstraps could never succeed. An engine that pins no torch got the app's
instead of its own, so its venv was not really independent.
uv_subprocess_env, which every one-click install step and every engine
bootstrap already uses, now always sets UV_NO_CONFIG=1. It used to return
None in two cases and let the call inherit the environment; it now always
returns a copy. Region and custom mirrors still apply, because they reach
uv as UV_INDEX_URL. The app's own `uv sync` and the translation installer,
which install into the app's environment on purpose, are unchanged.
The translation uninstall route runs `pip uninstall -y <package>` on the
app's own environment. Two entries made that break something else:
- The LLM engine's package is openai, a core dependency that Settings →
LLM Providers also uses. Unlike Argos, it was not marked builtin, so
uninstalling that engine removed it from the app.
- Google, DeepL, Microsoft and MyMemory share deep_translator. Uninstalling
any one removed it for all four.
The route now asks uninstall_blocker() first. It refuses (400) a package
VoiceStudio itself depends on, read from the installed package metadata so
there is no second list to keep in step, and refuses (409) a package
another engine shares, naming the engines that would stop working. The
openai entry is marked builtin as well, and a test requires every entry
backed by an app dependency to be.
mlx_supported() fails three ways. Only the non-Apple branch is a platform
gap; Apple Silicon whose PyTorch cannot use MPS was left on the generic
line, and the live-host test asserted a platform reason even there, so it
would fail on such a Mac. That case now has its own owned sentence, and
the live test asserts only the branch its host can produce, with literal
cases covering the rest on every machine.
An engine with no weights download counted as installed once its venv
interpreter existed, so a dependency install that died halfway made the
next attempt answer already_installed and the engine failed at its first
import. The import probe now writes a completion marker, and a fresh
dependency step removes the old one. IndexTTS keeps its weights check, so
no existing install is asked to reinstall.
The MOSS bootstrap no longer blames a non-CUDA host for an install
failure; the index is always supplied, and uv's error says what failed.
The PocketTTS and Supertonic guides now say where the sidecar runs, and
the three repository-engine guides say what to do if the first weight
download outlasts the compute-time budget.
The public reason sanitizer had no category for a host the engine cannot
run on at all, the same gap that hid the license button. "MLX requires
Apple Silicon" and PocketTTS's Intel-Mac reason became the generic
"check installation" line, and "not supported on this platform" became
"isn't installed yet", which an existing test asserted. Each sent people
after an install that could never work.
A platform category, matched after the license and before the install
and file checks, now says the engine doesn't run on this platform and
points at its guide. mlx-audio's "Apple Silicon only" wording is left
out of the markers: it also appears on an M-series Mac when the package
is simply missing, where installing does help.
Source validation demanded a pyproject.toml in every checkout, and
Confucius4 ships none, so its install could never get past fetching the
source. The manifest file is now per spec. The regression test fabricates
each pinned upstream's real root files.
Both engines ran with the app's interpreter, installed as optional extras
into the app's own environment (`uv sync --extra`). They now get one-click
installs like the sidecar engines: a PyPI-only spec (no source to fetch)
creates DATA_DIR/engines/<id>/.venv and installs the app's own pinned wheel
there, so nothing they install can touch the app or another engine.
Each engine prefers its own venv and falls back to the app's interpreter,
so an existing `uv sync --extra` install keeps working and is never
provisioned over: the spec counts a package found in the app environment
as installed.
PocketTTS installs from PyTorch's CPU index: it never uses a GPU, and
PyPI's Linux torch pulls ~15 NVIDIA packages. It stays unoffered on Intel
Macs, where no usable torch exists.
Supertonic's sidecar loads its constants by path when the revision env var
is absent, instead of importing the engines package, whose __init__
imports the app backend that its own venv does not have.
The Install button is hidden once only the license review stands between
the user and the engine. The installer tests' autouse fixture now removes
every spec's env var on teardown: a bare delenv of an unset var restored
nothing, and a persisted path leaked into later suites.
The Model Catalogue shows an engine's license Accept button only when its
reason matches /license not accepted/i. public_backends() replaces probe
text with owned sentences, and no category covered a license gate, so the
reason arrived as the generic "Engine unavailable" line and the only way
to enable Supertonic-3 or PocketTTS never rendered (#2017).
A license category, matched first, keeps those words. The test reads the
regex out of EngineCompatibilityMatrix.jsx, so a wording change on either
side fails CI instead of silently hiding the button.
Three engines that shipped as terminal-only setups now install from Model
Catalogue → Engines with the existing sidecar installer, which is
generalised to take a per-engine venv interpreter, install target, import
probe and host gate.
Each engine gets DATA_DIR/engines/<id>/ with its own checkout and .venv;
every uv pip install passes --python for that venv, never the app's
interpreter. Switching the active engine only changes a pref, so moving
between engines and back cannot corrupt a working one, and uninstalling one
removes only its own folder. Tests pin both invariants for every spec.
MOSS-TTS-v1.5's [torch-runtime] extra pins torch==2.9.1+cu128, which exists
only on PyTorch's index, so its manual install and its bootstrap could never
resolve (#2015). core.torch_indexes defines the index once for the
installer and the bootstrap, and a test ties it to the app's own
pytorch-cuda index.
Install buttons appear only where the install can work: MOSS on CUDA hosts,
dots.tts off Windows (upstream publishes no Windows install). A direct POST
on an unsupported host gets a 409 with the reason. An engine with no
one-click install now points at its guide, not at a page with no Install
button.
Tauri applies platform config as a JSON Merge Patch, so the windows array in
tauri.macos.conf.json REPLACES the base one rather than merging window objects.
desktopWindowConfig.test.js pins that every base window property survives on
macOS, and it caught the base gaining "shadow": false without the overlay.
The programmatic builder already sets shadow(false) on every platform; this
keeps the macOS declaration in step with it. The Linux and Windows overlays
declare no widget window, so the base entry applies there unchanged.
Greptile on #2010: if win.show() fails while hwnd() succeeds, the native
SW_SHOWNOACTIVATE show still ran, putting an always-on-top window on screen
that Tauri believes is hidden. dismiss() and the idle reconcile then cannot
remove it — the stranded-window bug this PR fixes, reached by a different
door.
The previous commit degraded to the native show on purpose, reasoning that a
visible pill beats none. That was the wrong trade: the tray's red dot already
tells the user they are being recorded, while an unhidable always-on-top window
is left behind for the rest of the session. The native show now runs only
after Tauri's show has succeeded, and the test that pinned the fallback is
flipped to pin its absence.
Two more defects from the same live report.
The card. Tauri's default window shadow on Windows gives an undecorated
window a 1px white border and, on Windows 11, rounded corners — drawn around
the whole 460x164 pill window, which is far wider than the pill (at most
284px). That is the bordered card framing empty space, visible whether or not
the pill is showing. The widget window now sets shadow(false), in the builder
and in its tauri.conf.json declaration; the capsule draws its own edge in CSS.
The stale model (#2012). The pill runs in its own window with its own store,
created at app start, usually before the backend listens. CaptureWidget
hydrated the dictation prefs once and memoized the promise whether or not the
load worked, and loadDictationPrefs swallowed the failure and marked itself
loaded. So the widget kept the store's seed, sherpa-whisper-tiny, for the
session. The main window checked the model actually picked (Parakeet,
installed) and said ready; the widget asked the server for the seed, and the
server correctly answered that it was not installed.
loadDictationPrefs now reports whether the backend answered. Only a successful
load is kept; a failed one is retried, and a capture start re-reads so a model
chosen in the main window reaches the widget. An in-flight load is shared.
On the same path, the missing-model install toast was called from the widget,
whose window has no <Toaster> in the desktop app, so it rendered nowhere. The
install recommendation now rides the dictation notice to the main window,
which shows the one-click download. The browser build, where the widget lives
inside the main window, keeps its local toast. The pill labels a missing model
as that rather than "Transcription failed: ...", and clamps error text to two
lines instead of spilling a paragraph past the capsule.
Tests: a failed first load is retried instead of pinning the seed, and a model
changed elsewhere is picked up at the next capture — both fail against main's
widget. The notice routes a missing model to the install toast. The
setup-race test now asserts no local toast in Tauri and the notice payload
instead. 91 frontend tests and 256 Rust lib tests pass; typecheck:ci passes.
Closing the dictation pill on Windows left an empty dark rectangle on screen,
always on top, removable only by quitting the app.
show_pill_noactivate called the raw Win32 ShowWindow(hwnd, SW_SHOWNOACTIVATE)
and never Tauri's own win.show(). The flag is there for a good reason (#982: a
pill that takes foreground makes the dictated text paste into the pill instead
of the user's document), but going straight to Win32 puts the window on screen
behind Tauri's back. Tauri went on believing it was hidden, and every mechanism
that could have removed it was disabled by that one desync:
- isVisible() answered false while the user was looking at the window;
- hide() was a no-op on a window Tauri thought was already hidden, so
dismiss() in CaptureWidget could not remove it;
- the idle reconcile — the backstop that exists precisely to clean up a
stranded pill — asks isVisible() first, and concluded there was nothing
to clean up.
win.show() now runs first, then the native flag. The no-activation behaviour is
carried by the WS_EX_NOACTIVATE style bit that mark_pill_noactivate applies at
creation, which is what makes the Tauri show safe: the style bit, not the show
flag, is what refuses activation. The flag stays as a second line of defence,
since hwnd() can fail and the bit might not have been applied.
Windows only. macOS and Linux already took the win.show() branch.
The ordering is now a function with both shows as parameters, so two tests can
pin it: Tauri's show runs and runs first, and a failing Tauri show still puts
the pill on screen — degrading to the old behaviour beats not showing the user
that they are being recorded.
256 Rust lib tests green; the 10 stranded-pill frontend tests unchanged and
still passing.
A staged task input's artifact id was built with os.path.join, so a Windows
control plane produced `inputs\<sha256>.wav`. That id is not a local path. It
is persisted into remote_tasks.params_json, shipped to remote workers over
gRPC as the identifier for the input they must fetch, and compared against a
later disk sweep to decide whether a staged file is still referenced.
So a Windows host hands a Linux worker `inputs\abc.wav`, where the backslash is
an ordinary filename character and no such file exists. Remote GPU workers are
a shipped feature; this broke them for every Windows control plane. The same
ids also stop matching when an omnivoice_data/ directory moves between
operating systems.
artifact_id_for() makes it canonical POSIX — resolve_within already treats both
separators as structural, so resolution is unchanged. normalize_artifact_id()
covers the upgrade: rows written by the old code carry a backslash, and the
sweeper decides "unreferenced" by comparing ids, so without it an upgraded
install reads every legacy row as garbage and deletes inputs that surviving
tasks still point at.
Two other tests in this run asserted POSIX-only behaviour rather than product
behaviour, and are corrected here too:
- the durability-barrier test required a directory fsync, which
_fsync_parent_directory deliberately skips without os.O_DIRECTORY. It now
gates on that same attribute rather than on the OS name, so the test and
the code it checks cannot drift apart.
- the read-only-cache test built its scenario with chmod(0o500), which on
Windows only toggles a read-only FILE attribute and does not stop a file
being created inside the directory. It verifies its premise by probing and
skips when the host writes anyway — which also covers root and anything
holding CAP_DAC_OVERRIDE, replacing a geteuid check that named only one of
them.
Then the reason none of this was visible: CI runs tests/ on Linux only. The two
worker suites join the existing Windows step in the smoke matrix. They need no
ffmpeg, so they cost seconds. Verified green on Windows first — 244 tests
across the four suites in that step.
Fails before, passes after, both directions: a staged id containing a
backslash, and a legacy-id input deleted by the sweeper.
main is red. Smoke (Windows) failed on
test_upload_durability_barrier_does_not_block_the_grpc_loop with
assert 0.20299999999997453 < 0.2
Three milliseconds of scheduling noise on a shared runner, and a red build
that says nothing about the product.
The three tests here prove a blocking filesystem call does NOT stall the gRPC
event loop: they park the call on a barrier and check the loop still ran their
own coroutine promptly. That is a wall clock on shared hardware, so the two
numbers have to be chosen against each other. The discriminator was a 0.5 s
watchdog — a stalled loop could not proceed until it fired — while the budget
sat at 0.2 s. Responsive measured ~0.2. The line was drawn exactly where the
noise lives.
Both numbers are named constants now, with the reasoning next to them:
a 1.5 s hold, a 0.75 s budget. Responsive lands near 0.2, stalled lands at 1.5,
and the line sits between them with room on both sides. The waiter's own cap
moved above the hold too, so a genuinely stalled loop is reported by the budget
assertion that names the problem rather than by a bare TimeoutError.
Verified the assertion is still worth having: with the blocking call made to
stall the loop for real, the test fails. It is a wider net, not a hole.
55 tests in the file, three runs in a row.
Both review bots found the same thing independently: `e.type !== 'respelling'`
badged rows the user had switched OFF. A disabled entry is indeed not applied,
but for a reason the toggle already shows — labelling it "not applied yet"
reads as a defect rather than their own choice. `inert_entries_for_language`
excludes disabled rows for exactly that reason, so the badge now matches it.
Not taken: the suggestion to name `ipa` and `cmu` explicitly instead of testing
against respelling. The backend's rule is that everything which is not
respelling is inert today, and mirroring it keeps the two in step. An explicit
list would silently stop badging a notation added later — an entry that saves,
validates, toggles on and quietly does nothing, which is the invisibility #1949
exists to remove. A test pins that direction with an unfamiliar type.
Two tests, one per direction. The disabled case fails without the enabled gate.
2860 vitest tests green.
Takes the part of #2002 by @utkarsha741 that #1984 did not already cover.
An IPA or CMU row saves, validates and toggles on, and is then dropped before
term matching — Phase 1 only substitutes respelling. #1984 made that visible in
the "Test a sentence" preview, which the user sees only if they run a test. The
entry LIST is where they look at what they have saved, and there it still
looked like every other working row.
So the row carries the same fact: a warning badge next to the type and scope
badges, on IPA and CMU only. Badging a respelling row would be the opposite
lie — those do take effect.
The rest of #2002 is already on main under a different name: it re-added the
backend skip detection and the test-preview line as `skipped_terms`, where
`inert_entries_for_language` and `inert_entries` have shipped since #1984.
Landing that half would have been a second implementation of one behaviour with
two response fields for it.
String added to all 21 locales, not just en, so the badge is not an English
island in a translated panel. Fails before, passes after: with the badge
condition disabled the new test cannot find it. 2858 vitest tests, 529 locale
and CJK guard tests green.
Smoke (Windows) fails intermittently in
test_revocation_during_result_barrier_cannot_ack_published_bytes with a bare
TimeoutError. It hit two PRs in a row today, one of them documentation-only,
which rules out any change under review.
The wait is a busy loop:
while not barrier_finished.is_set():
await asyncio.sleep(0)
asyncio.sleep(0) yields to the loop but never sleeps, so this runs the loop
flat out on the one thread the upload task also needs to reach
_durable_replace and set the event. On a loaded Windows runner the waiter
starves the worker it is waiting for, and the 1 s cap fires with nothing
actually wrong — a failure with no signal in it, which is worse than no test.
_await_event parks the wait on a worker thread with asyncio.to_thread, leaving
the loop free. Deterministic, and faster: the test drops from a full second of
spinning to the time the work actually takes.
Deliberately narrow. The other spin-waits in this file sit inside
"assert elapsed < 0.2" blocks that exist to prove the gRPC loop stayed
RESPONSIVE during a blocking call — spinning is the measurement there, and
converting them would delete the assertion's meaning.
123 tests across both worker files pass; the target test passes three runs in
a row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
CodeRabbit: running_backend_version accepts a /system/info body that merely
CONTAINS "model_checkpoint" or "data_dir". That is a substring sniff, and it is
fine for the decision it was written for — whether to attach to a healthy
same-version backend. It is not fine for this one, which ends in a message
naming a process for the user to kill. Any service can serve that body.
port_holder now requires the x-omnivoice-backend header that backend/main.py
stamps on every response, the same gate startup_progress already applies for
the same reason: a foreign process on our port must not narrate our UI, and it
certainly must not be the thing we point a user's kill command at. Unmarked
means Foreign, which keeps the conservative wording and offers no command.
Two tests against a real one-shot loopback responder: a spoofed body with no
marker is Foreign and gets no terminal command, and the marker is what makes a
responder ours. The first fails with the header check disabled.
244 lib tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Two more review findings, both real.
CodeRabbit: pinning the START of the dying run's slice was only half the fix. A
start with an unbounded end still does not identify one run — the replacement
writes BELOW those lines, and a tail reads the last N of the file, so the newer
run's healthy startup is exactly what the dead run's crash marker would get.
read_dead_run_tail closes the range: it is called after the settle, and takes
the end from wherever the current run now begins, which is either still the
pinned start (nothing replaced it) or the replacement's own offset — precisely
where this run's slice ends. An end that is not a usable boundary degrades to
the rest of the file, matching how an unusable start already degrades.
CodeRabbit: settle_err_log moved every handle out of the list and then waited
without that lock, so two callers could interleave — the second found an empty
list, concluded there was nothing to wait for, and read the log while the first
was still waiting for exactly the drainer it needed. A settlement lock makes
each caller's return mean the waiting is genuinely done.
Two tests: a dead run's slice stops where the replacement begins (and the
unbounded read really does return the newer run, so the assertion is not
vacuous), and an unusable end degrades rather than capturing nothing.
241 lib tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Lands #1998 by @yangfan-yf-yf. Correct finding: the PowerShell example I added
in #1993 generated the administrator key with `python -c`, and the whole point
of the Docker path is that the host does not need Python. On a Windows host
without it, the very first line of the setup fails.
One thing on top. The key is also accepted as an `?api_key=` query parameter
(core/auth.py), and raw Base64 carries `+`, `/` and `=`. A `+` in a query
string decodes to a space, so a user who pasted such a key into a URL would get
a silent mismatch with nothing to explain it. The Bash line next to it uses
`secrets.token_urlsafe` and never had this shape, so the two now agree:
trim the padding, map `+` to `-` and `/` to `_`.
Verified in Windows PowerShell 5.1 (5.1.26100): the block parses and runs, and
the key is 43 URL-safe characters — the same shape `secrets.token_urlsafe(32)`
produces. validate-install-docs.py and both docker/changelog test files pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
The backend-lifecycle harness asserted a literal — "is already in use, so the
backend could not" — while its own comment said the point was "the exact
phrasing BootstrapSplash.detectHints localizes". Those are not the same thing,
and the gap showed: rewording the message by who actually holds the port kept
the matcher firing and still failed the test.
It now asserts the real contract, the same one the Rust unit tests pin: the
message mentions a port, says it is in use after that, and names the port
number. Any wording that satisfies detectHints satisfies this; any that does
not, fails — which is the failure worth catching, because it silently costs
the user the localised hint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Four tests in backend/tests/ cannot pass on a stock Windows checkout. The
`test` job runs that session on Linux only, so all four were invisible to CI
and hit every Windows contributor on their first `pytest` run — with failures
that have nothing to do with whatever they changed. Same class as #1990.
- test_contained_subprocess_waitid_fallback.py simulates macOS by deleting
os.waitid, then drives the fallback with os.waitpid/os.WNOHANG and
start_new_session. Windows has none of those; os.WNOHANG is an
AttributeError before the first assertion. The module is POSIX-only by
premise, so it says so.
- test_invalid_or_missing_desktop_drain_fd_fails_safe asserts a RuntimeError
that cannot be raised off POSIX: backend_drain_fd returns None there before
it reads the environment. The file already had this skipif on its sibling.
- test_mps_proxy_survives_fatal_child_exit_and_recovers raced the OS. The
child calls os._exit and the parent raises the moment its pipe hits EOF —
before the process is reaped. Asserting poll() on the next line is a race
Linux won and Windows lost every time. It waits for the death now, which is
what the test actually claims.
Then the reason all four survived: nothing runs this session on Windows. The
smoke matrix already does a full `uv sync` there, so the session costs forty
seconds and now runs as a step in it. Verified green on Windows before adding
the gate — 355 passed, 8 skipped — so this cannot break main.
Kept to Windows deliberately: that is the platform I can verify here, and a
gate added blind on macOS would be a guess about a host I cannot run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Four review findings on the attempt id, all real.
Greptile, P1 — the status snapshot could mismatch. The bump and the stage write
were separate, so a restart landing between them returned the PREVIOUS attempt's
stage stamped with the new attempt's id: the splash then recorded
`installing_deps` as work this attempt did, which is precisely the #1894
fabrication the id exists to remove. `begin_attempt_with` now takes the stage
lock across both writes, and `bootstrap_status` reads stage and attempt under
that same lock. The pair a reader observes is always self-consistent.
CodeRabbit, major — an output pump is a thread reading a pipe, and it outlives
the run it drains. It stamped each line with the counter's value at read time,
so a restart relabelled the dying run's trailing output as the new attempt's
evidence. `emit_log_for_attempt` takes the attempt explicitly, and all four
pumps (the backend's stdout and stderr, and both sides of `run_streaming`)
capture theirs when they start. Every other call site runs inside the attempt
it describes and keeps reading the counter.
CodeRabbit, minor — the tests that advance the process-global counter raced
each other under cargo's threaded runner, so one could read a value another had
just moved. They serialize on a lock now, like the env-var tests above them.
CodeRabbit, minor — the backfill-to-live seam deduplicated on stage plus text,
and installer output repeats itself constantly. Across a restart that is not a
replayed line, it is the new attempt's own evidence, and dropping it can remove
the only proof for a stage the poll never samples. The attempt is part of the
identity now.
Two new tests: a new attempt never carries the previous stage, and a repeated
line belonging to a different attempt is kept. The second fails against the
previous dedup key. 241 Rust lib tests and 2853 vitest tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Two review findings on the settle, both real.
Greptile, P1: settling can take up to two seconds, and a Retry arriving in that
window installs a new run and moves ERR_LOG_RUN_START past the dying run's
output. Reading "the current run" after the wait would then hand the dead
process's crash marker the REPLACEMENT's healthy startup — the cross-run
attribution #1510 exists to prevent, reintroduced through the wait added to fix
the tail. Every death path now pins the offset BEFORE settling and reads from
it, via read_error_log_tail_from.
CodeRabbit: a single drainer slot loses a timed-out handle the moment a new run
installs its own. Dropping a JoinHandle detaches the thread, so nothing can
ever wait for that run's output again and both guarantees quietly stop holding.
The slot becomes a list: a settle drains it, joins what finished, and puts back
what is still running, ahead of anything a concurrent spawn pushed.
Two tests: a pinned offset still names the dying run's slice after a respawn
moved the current one, and an unfinished drainer survives another run
installing its own. 239 lib tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Greptile, security, on the reclaim guidance: the convenient one-liner does not
preserve the identity port_holder established.
- `lsof -ti tcp:3900` matches CONNECTED CLIENTS as well as the listener, so
piping it into kill can end a process that merely talks to VoiceStudio.
- Windows `findstr :3900` matches `:39001` and established connections too.
And the identity itself is a fact about the moment the message was written. By
the time a user runs a command it has to be re-established, and only they can
do that.
So both platforms now get two steps: a lookup restricted to the LISTENING
socket that prints the pid and process name, and a kill of that pid once the
user has confirmed what it is. A test pins that the guidance never pipes a
lookup into kill and always shows something to confirm.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
The code half of #1931 landed already: `torchaudio.set_audio_backend()` is
guarded, so the torch 2.9.x upgrade a Blackwell card needs no longer trades one
`ml_imports` crash for another. Two things were still missing.
The changelog said the upgrade was documented. It was not — nothing in docs/
mentions sm_120, Blackwell, or the 50-series at all, so a user hitting a native
access violation inside `import torch` had the issue thread and nothing else.
troubleshooting.md now carries it: why the pinned torch 2.8.0 cannot work
(no sm_120 kernels in the wheel — not a setting, not a workaround), the trio
that has to move together, the verification command that proves the kernels
arrived, and the fact that the change is to the repo's own pins so a later pull
will undo it.
The part most likely to be missed is that there are TWO pin lists.
`constraint-dependencies` governs `uv sync`/`uv lock`/`uv run`;
deploy/torch-constraints.txt governs the `uv pip install` paths, which ignore
project-level uv settings. Editing one leaves the other behind, which is what
`RuntimeError: operator torchvision::nms does not exist` looks like from the
outside. Both are named.
The second gap: nothing protected the guard. CI runs the pinned torch 2.8.0,
where `set_audio_backend` still exists, so deleting the `hasattr` as a
"simplify this no-op" cleanup would pass every test in the suite and restore a
hard startup crash for every RTX 50-series user. tests/ now walks the backend
AST and fails on any reach for a torchaudio API that 2.9 removed unless
something proves it is there — a `hasattr`/`getattr` check or a `try`. Fails
with the guard removed, passes with it.
Not addressed here, because it is already fixed: the reporter's third
observation, that launching through the desktop shell hung inside `import
torch`'s native init, is the OpenBLAS/stdin-pipe deadlock closed under #1952.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
The port-conflict failure asserted "already in use by another application"
without ever asking who held the port. In the reports behind #1933 (and its
duplicates #1935, #1936, #1937 — same machine, same 45-minute window) the
holder was the user's OWN orphaned backend from an earlier run. So the app
told them to quit a copy of VoiceStudio that has no window, and there was no
action in the message that could have worked.
@Chang-Jin-Lee diagnosed this precisely on #1936, including the observation
that the identity check already exists: `running_backend_version(port)` asks
`/system/info` who is there, and is already trusted for the more consequential
decision of whether to attach to a healthy same-version backend. It simply was
not consulted on this path.
So it is now. `port_holder()` returns one of three answers, and
`port_conflict_message()` words the failure from it:
- our own backend at this version (or one too old to report one) — say so,
say it has no window to quit, and give the terminal command that ends it;
- our own backend at a different version — name the version, which is what
identifies it, and give the same command;
- anything else — the existing wording, now actually justified.
The terminal command is only ever offered for a listener that identified
itself as ours. An unidentified one keeps the conservative wording: a user must
never be told to go kill a process that may not be theirs. A listener that
accepts a connection but does not answer `/system/info` counts as
unidentified, which is the reading that cannot do harm.
All three sites that reported this — take-ownership, respawn, and the
early-exit path on EXIT_PORT_IN_USE — go through the one builder now.
What is deliberately unchanged: `kill_orphan_on_port` still refuses to signal
a PID discovered through lsof/netstat. That refusal is correct — the reuse race
is real, and a matching foreign service must never be terminated. This changes
what the user is told, not what the app is willing to kill.
Five Rust tests, one per branch plus the suffix behaviour, and one that pins
every wording against the `detectHints` matcher — that regex is what turns
these English strings into the localised `bootstrap.hint_port`, and an earlier
draft of one message silently lost the translation by saying "is held by". The
frontend test that pinned the old literals now pins the new ones.
241 Rust lib tests and 2851 vitest tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
The report in #1927 is a Windows access violation 13 seconds after "Loading
VoiceStudio model on device: cuda" — a native fault inside the compute stack,
which produces no Python traceback because the process is executing bad machine
code. What the user was shown was "Backend died (exit code -1073741819)", a
timestamp, an uptime, and a log ending mid-startup. The issue they filed has an
empty description, which is the honest response to being handed a number and no
next step.
The classification already existed and is good: `crashCauseHint` distinguishes
a native fault (a GPU driver disagreeing with the bundled CUDA runtime, or a
partially downloaded weight file), an exit 78 port conflict, an OOM kill and a
half-built Python environment, and names concrete actions including the
crash-isolated engines. It just never reached this surface — the only place it
rendered was the message on a stream dropped by a crash, and a crash with no
request in flight has no stream to drop.
So the details dialog renders it. A sentinel marker is deliberately excluded:
it cannot know a crash happened at all (sleep, force-quit and a stopped VM
leave the same trace), so it has no cause to explain, and asserting one would
be the #1375 fabrication in a new place.
Three tests: the access violation gets the compute-stack guidance, a port
conflict gets its own rather than the GPU one, and a sentinel gets none. The
first two fail against the previous component.
2853 vitest tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
The crash report in #1850 carries a stderr tail that stops 58 seconds before
the death it is meant to explain. That is not a quiet backend — it is a race.
`wait()` returns the moment the child exits, but stderr is drained by a
separate Rust thread reading a pipe and appending to backend_err.log. The two
crash-marker sites read that file immediately on detecting death, so the
drainer's in-flight lines — the traceback that names the cause — land after the
tail is taken. The report then shows a log that simply stops, and the crash is
undiagnosable no matter how good the rest of the capture is. Every silent
"exit code 1" report is a candidate for this.
The machinery to wait already existed for a different reason: #1510 joins the
drainer before a respawn records its start offset, so a dying run's buffered
tail cannot be attributed to the new run. It was just never applied to the
death paths. `join_previous_err_drainer` becomes `settle_err_log`, called from
the crash-marker sites in both the startup and supervisor paths as well as
before a respawn.
One behaviour change while it moves: when the bound expires the handle is now
handed back rather than dropped. Dropping detaches the thread, and every later
caller — including the respawn that #1510 protects — silently loses the ability
to wait for that run's output at all. Bound stays at 2 s, so a wedged pipe still
cannot stall crash recording.
Regression tests: a drainer that writes a traceback 120 ms after death (the
tail contains it now, contains only "steady state" before), and a wedged
drainer that outlives the bound (the slot still holds it). The three tests that
install into the process-global drainer slot now serialize on a lock — they
were racing each other under cargo's threaded runner.
238 lib tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
@yangfan-yf-yf pushed two more commits to #1987 after the first pass landed.
Two things in them were worth taking:
- an explicit `compose pull` step, so the platform override is proven before
`up -d` rather than discovered when the pull inside it fails; and
- a PowerShell form. An ARM64 Windows host cannot use `export`, and the
surrounding page only ever shows Bash — so the guidance did not actually
reach the users most likely to need it.
Not taken: the same commits also moved `--platform linux/amd64` into the
default `docker pull` / `docker run` quick start. That is a no-op for the
amd64 majority and contradicts the Architecture section directly above, which
introduces the flag as the conditional ARM64 step. The canonical command stays
the one almost everyone should run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
The first-run splash decided which bootstrap attempt a piece of evidence
belonged to by inferring attempt boundaries from the `bootstrap_status` stage,
which is sampled about once a second. Inference from a sampled signal cannot be
airtight, and two routes slipped through it:
- a retry that goes failed -> checking -> starting_backend inside one sample
window, where the poll sees no restart stage at all; and
- the supervisor's own venv rebuild, which re-enters `checking` with no
`failed` stage and no click behind it. If the poll samples the same stage
name on either side of it, the sequence is `installing_deps` ->
`installing_deps` — literally no signal that anything restarted, and the
previous attempt's completed steps stayed on screen as this attempt's work.
That is the #1894 fabrication arriving by a route stage inference cannot
close.
The producer knows the answer exactly, so it now says so. `ATTEMPT` is a
monotonic counter bumped wherever the bootstrap really restarts —
`respawn_backend`, which both retry commands and the scoped reset funnel
through, and the automatic venv rebuild. `bootstrap_status` returns it beside
the stage (a flattened `BootstrapStatus`, so the wire shape the frontend
already reads is unchanged), and every `bootstrap-log` line carries it too.
The splash scopes stage evidence by equality on that id and the boundary
heuristics are gone: `RESTART_STAGES`, the leaving-`failed` rule, the
wall-clock `attemptStart`, and the `selfInitiatedRef` guard that existed only
to stop the poll re-stamping a boundary the UI had already opened. `beginAttempt`
is now presentation only — it clears the visible log for a retry the user asked
for.
Two new tests cover what only an id can carry: a Rust-side restart the poll
cannot see at all, and a log line from the previous attempt that must not count
toward this one. Both fail against the previous component and pass now. Rust
side: 240 lib tests green, including four on the counter and the status shape.
Frontend: 2847 vitest tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Creating a symlink on Windows needs SeCreateSymbolicLinkPrivilege, which a
normal account does not hold unless Developer Mode is on. GitHub's hosted
Windows runners hold it, so seven unguarded call sites passed in CI and failed
only on a contributor's own machine, with WinError 1314 and no connection to
whatever they were working on:
tests/backend/services/test_audiocpp_backend.py (5)
tests/test_exports_api.py (1)
tests/test_storage_report.py (1)
The repo already knew about this — tests/test_hf_cache_repair.py carries a
private _symlink_or_skip helper whose docstring describes exactly this failure.
The pattern simply never reached the other files, which is the whole class of
the bug: a convention that lives in one module's private helper gets rewritten
from scratch, or forgotten, at every new call site.
So the helper is now a `symlink_or_skip` fixture in tests/conftest.py, and
tests/test_symlink_guards.py walks the AST of every test module and fails on a
raw symlink_to / os.symlink that has no way to skip. Guarded means the fixture,
a try, a skipif marker (module-level pytestmark included), or a test that has
already run a skipping helper — the three legitimate existing patterns, which
it recognises rather than forcing a rewrite.
Coverage is unchanged: the full pytest job runs on Linux, where nothing skips.
Fails before (7 errors, then the guard reports the offending files), passes
after.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
tests/test_no_hardcoded_cjk.py rejects CJK outside frontend/src/i18n/, and the
refreshed tree annotated README_CN.md with the characters themselves. The file
name already says which language it is; the annotation does not need to be in
it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Lands #1981 by @Dawcraft, which refreshes docs/STRUCTURE.md to match the tree
as it actually is — the old file still described a root-level layout that the
2026-07-12 cleanup removed, and pointed at a tests/services/ mirror that has
not existed since the tests/backend/ reorganisation.
Verified every path, directory and CI claim in the refreshed file against the
repo: the router auto-include list, the isolated backend/tests/ pytest step,
the smoke-matrix job and its HF_HUB_OFFLINE guard, and every file the tree
names. One number was off — backend/services/ holds 78 modules, not 79.
Off-by-one in a doc is the symptom; the class is a count nothing checks, which
is wrong the week after it is written. tests/test_structure_doc.py now pins
the router count, the service count and the engine-adapter list to the tree,
so the next module to land fails the suite with the line to update instead of
quietly aging the doc. Fails before the fix (79 != 78), passes after.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Lands #1987 by @yangfan-yf-yf, which closes#1921.
The published images are linux/amd64 only, and the quick start reached image
resolution before saying so — an ARM64 user met "no matching manifest for
linux/arm64/v8" with no explanation. Verified against docker.yml, which says
so in its own comment: "only building linux/amd64".
One gap in the original: the platform override was documented for docker pull
and docker run, but Compose has no per-command --platform flag, so the
recommended Compose command still resolved the missing ARM64 manifest and
failed exactly as before. DOCKER_DEFAULT_PLATFORM covers it, with the same
caveat the rest of the section makes — emulation, not native support, and only
the CPU profile makes sense under it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
The existing case pinned the literal string "Invalid source language code",
which the #1960 fix replaces with a message that names the offending code. It
now asserts what the test is actually about — a 400 that identifies the code —
so improving the guidance again does not fail it for the wrong reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1960.
The report was "400 Bad Request: Invalid source language code" and nothing
else. That cannot be acted on or triaged: it does not say which of the ninety
or so codes was wrong, so neither the user nor a maintainer reading the
auto-filed issue can tell whether the picker offered something the backend does
not accept, or a stale preference from an older build is still being sent.
I could not determine the cause from the report, which is exactly the problem.
Naming the code makes the next one answerable instead of guessing at this one.
The value is a language code chosen from a menu, not private data, and the
engine validator a few lines away already echoes its input the same way.
Also adds the check I actually wanted while investigating: a test that reads
the picker's own LANG_CODES and asserts the backend accepts every one of them,
so a code added to the menu cannot silently become a 400. It passes today —
the menu and the allowlist do agree — which is how I ruled that out as the
cause rather than assuming it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1773.
The 500 handler has always put error_class in the response body, but nothing
lifted it onto the Error object — and the auto bug reporter reads the Error. So
every unclassified 500 filed "VoiceStudio hit an internal error; check the
backend log for details." and nothing else: identical reports, none of them
triageable, with the distinguishing datum sitting unused in the payload that
produced them.
#1956 did exactly this for the streaming path. The classic path had been
carrying the field on the wire the whole time; it just never survived the hop
onto the exception.
Only a string is kept. A 404 or a validation error has no class, and an empty
one would put a blank line in every report; a non-string is ignored rather than
stringified. Both pinned.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1949.
Settings offers three notations. Only Respelling substitutes text today; IPA
and CMU rows save cleanly, are validated, get a badge and can be toggled on,
then get dropped before term matching and are never read again.
That much is Phase 1 behaving as designed. The defect is that it was INVISIBLE:
"Test a sentence" answered "No entries match — spoken as written" for a term
that does match. Not a degraded answer, a wrong one — and it sent the user off
to re-type an entry that was already correct, or to convert it to Respelling,
where a phoneme string is then read as graphemes.
docs/specs/01-expressive-tts.md asked for exactly the opposite: such entries
"passed through and flagged 'phoneme not honored on this engine' (parity-rule:
visible degradation)". That flag was never implemented. This is it.
The dry run reports inert entries separately, and the panel names them. The
substitution path is deliberately untouched — this does NOT start feeding raw
phoneme strings into the grapheme stream, which is the thing Phase 1 refuses on
purpose, and a test pins that it still refuses.
Not Phase 2. Lowering IPA/CMU to engine markup is a real feature per engine and
stays open; what changes here is that the gap is now honest rather than silent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1847.
The splash is the only surface with a Show/Copy affordance for these lines, and
it unmounts the moment the stage flips to ready — so on a successful first run
the whole install log was gone for good, with no completion pause and nowhere
to retrieve it. A user who wanted to check what had just been installed, or
attach it to a bug report, had nothing.
The lines are written to bootstrap.log beside backend.log now, so everything
about a run is in one directory and a bug report does not have to hunt in two.
Truncated once per process rather than appended forever: a bootstrap is a
single episode and the useful question is always "what happened this time".
That also bounds the file across repeated retries without needing a hook on
every restart path. The docs say so, and say to copy it first if you need a
superseded attempt.
Best effort throughout — a log that cannot be written must never take the
bootstrap down with it, and a test pins that it does not.
The counter half of this issue (Activity frozen at 200) was already fixed on
main by #1918; I verified that before starting rather than assuming the whole
issue was open.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Both points from the review are right:
- The three test homes do not each get their own CI job. `ci.yml` runs all
three as steps of the single `test` job (`Run pytest`, `Run pytest
(backend/tests, isolated)`, `Run Vitest`); what makes `backend/tests/`
separate is the pytest session, not the job.
- `tests/backend/services/test_dub_pipeline*.py` does not exist — that
regression test is flat, at `tests/backend/test_dub_pipeline_wav.py`.
The mirroring example now uses a path that exists
(`backend/services/ffmpeg_utils.py` ->
`tests/backend/services/test_ffmpeg_utils.py`) and says that
backend-wide and cross-cutting suites stay flat.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LDyC6prbjFydox9XQGhyny
Closes#1974.
The dev launcher only treated a port holder as ours when it ran out of the git
checkout. A backend the Tauri shell spawned lives under a per-app directory
named after the bundle id instead, so the launcher saw its OWN orphaned backend
as a stranger, refused to free port 3900, and aborted the run with "Refusing to
stop unrelated process" and no way forward but Task Manager.
Ownership now also accepts the app's reverse-DNS identifier in the executable
path or the command line. A bundle id is specific enough to be safe: nothing
else on the machine carries it, which is the point of the namespace.
The guard itself is unchanged in spirit — a foreign listener on the port is
still refused, and a test pins that widening ownership did not widen it to
everything, including a process from some other vendor's bundle.
Known limit, since I hit it in this repo: on Windows the check is given the
command line and executable path but not the working directory, so a backend
started by hand from an arbitrary interpreter — a bare `uvicorn` whose only
link to the checkout is a relative --app-dir — is still not recognised. That is
a different shape from the reported one and needs the cwd, which this code path
does not currently have.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1858.
Whichever app registers a global shortcut first wins, and the default collides
with 1Password Quick Access on macOS — so for a large share of installs the
hotkey the onboarding screen advertises silently does nothing.
Registration failure was a Rust-side log line and nothing else. There was no
publish on the error path, so the frontend kept reporting whatever accelerator
had been REQUESTED, with no way for any screen to know the OS had refused it.
The failure is published now, carrying the outcome in `backend` and still
naming the accelerator so the UI can say WHICH combination is taken.
Surfaced as its own state rather than folding into the existing "no hotkey
registered" badge. That one means "not checked yet"; this means "this exact
combination belongs to another app, pick a different one" — different
situations needing different actions.
Detection rather than a new default, deliberately. Any default can collide with
something, so changing the value would move the problem rather than remove it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
STRUCTURE.md still described the April layout: it was missing
backend/engines, worker, mcp_shim, speech_client, migrations, plugins,
hooks and config; the frontend e2e suites, i18n and src-tauri packaging
inputs; and the bin, skills, .agents/skills, notebooks, omnivoice-gallery
and .github/workflows top-level entries. Stale docs are bugs.
Three corrections beyond the missing entries:
- "all tests live here, no exceptions" was wrong. There are three homes
(tests/, backend/tests/, co-located vitest) and the split is deliberate:
pyproject testpaths, a separate ci.yml job, and the sys.modules-stub
hazard documented in backend/tests/conftest.py. Replaced the claim with
a table that records why each home exists.
- .env.example does not exist and the app never reads a repo-local .env;
the durable user env file is ~/.config/omnivoice/env
(backend/core/user_env.py), written by the Settings panel.
- .agents/ was listed as deleted, but it is back with a different job:
the canonical skill copies pinned by skills-lock.json.
Also fixes the dead blob/main/STRUCTURE.md URL in the backlink script --
the file has lived in docs/ since the cleanup pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LDyC6prbjFydox9XQGhyny
Closes#1898.
On Windows the shell terminates the backend's job object with no graceful
phase — a console-less GUI child has no reliable control event — so the
backend never runs its lifespan shutdown and never clears its own run
sentinel. Every deliberate quit came back on the next launch as "The backend
did not shut down cleanly last run — it likely crashed or was killed". The
backend-side fix in #1895 only helps platforms where teardown actually begins.
A process about to be killed cannot record its own intent, so the shell records
it: the sentinel is retired immediately before the tree is terminated, on the
one path that knows the stop is deliberate. Anything that dies WITHOUT passing
through that path still leaves its sentinel behind and is still reported as a
crash, which is the property worth keeping.
Best effort by design. The data directory comes from the running backend, so if
it cannot be reached the file stays and the next launch reports a crash — the
same behaviour as before, never worse. A test pins that specifically: silently
erasing evidence of a real crash would be worse than a false positive.
Split into a pure file-level half and the port lookup so the behaviour is
testable without standing up a stub server. Verified on Windows with a real
toolchain: 233 Rust tests pass, including the three new ones.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1857.
The CSS honours prefers-reduced-motion in about a dozen separate blocks, but
that is the OS switch and nothing else. Someone who wants a calm app without
turning motion off system-wide had no way to ask for it, and someone whose OS
setting is not respected by their environment had no recourse at all.
Settings → Appearance → Reduce motion sets data-motion="reduce" on the root,
and one blanket rule covers the whole tree including pseudo-elements. That
shape is deliberate: a per-component list is what let the header status dot
keep pulsing under Reduce Motion (Part B of the same issue, fixed separately),
and a single rule cannot have that gap.
Additive by design. The media query is left untouched and keeps working on its
own, so turning this off never re-enables motion for someone whose system asked
for less. A test pins that the two stay independent.
Durations go to 0.01ms rather than none: a zero duration skips animationend /
transitionend, which strands anything waiting on them. Imperceptible, still
fires. Also pinned.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1973. Lands #1975 by @CoDe-ReDz.
The app shipped six themes, all dark, and "auto" stayed dark on a light-mode
OS — so a user looking for a light mode found nothing. Light text on a dark
background causes halation for people with astigmatism, which makes this an
accessibility gap rather than a preference.
One correction to the contributor's palette: --chrome-fg-muted at #586e75 gives
4.39:1 against --chrome-bg #eee8d5, just under the 4.5:1 AA threshold for
normal text. Raised to #4d5f66 (5.45:1) in both the explicit light block and
the prefers-color-scheme mirror, keeping it in the Solarized family.
For the record, the two contrast failures the review bot flagged as P1 are not
real: --color-fg-subtle measures 6.66:1 and --chrome-fg-dim 5.86:1, both
comfortably AA. The token that actually failed was one it did not mention.
Everything else the bots raised was already handled on the branch: both theme
labels go through t() with real translations in all 21 locales, and the
header's white-to-grey gradient is overridden for the explicit light theme and
the auto mirror alike.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1826.
A degenerately short generation reaches a convolution whose kernel is wider
than the tensor it was handed, and torch reports that in its own terms —
"Calculated padded input size per channel: (1). Kernel size: (2). Kernel size
can't be greater than actual input size". It arrived doubly wrapped in
"Underlying error:" and named nothing the user could change, when the fix on
their side is simply to type more than one character.
It is worth classifying for a second reason: this is not transient. The generic
wrapper told the user to "retry once", and this class fails identically on
every retry, so the advice actively wasted their time. The new remedy says so.
Matched on torch's own wording, which nothing else produces, so it is safe on
the context-free surfaces — and it needs to be, because that is exactly how it
reaches the user, through the generic 500 and the streaming error frame.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1849.
UiScaleSetup is a client-side zoom — it makes no backend calls at all — but it
was gated on backendReady. So on a clean install the user watched the entire
bootstrap, and answered the macOS Accessibility prompt, at whatever size the
app had guessed, and was offered the size control only once all of that had
finished. Someone who cannot comfortably read the UI had to get through the
least readable part of the product first.
The gate now runs as soon as the store has hydrated, which is its only real
prerequisite: uiScaleConfigured lives in the store, and reading it earlier
would flash the screen at someone who had already chosen a scale.
Pinned at the source level. Rendering App in jsdom to observe the ordering
would need the whole backend, store and Tauri surface mocked — a far more
fragile test than the two facts it pins.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1879.
mlx-audio raises a bare ValueError in its own vocabulary — "No conditionals
available. Either provide audio_prompt/audio_prompt_sr for voice cloning, or
ensure conds.safetensors is in the model directory." — and the generate route
passed it straight through as the 400 detail. The user was told to supply an
argument they have no way to name and to check for a file they have never heard
of, when what happened is simply that they asked to clone with nothing to clone
from.
Classified now, with a remedy in the user's terms: pick a profile that has a
saved reference clip, or record one. It also notes that a designed voice with
no saved reference cannot be cloned from, which is the case that produces this.
The route still passes through every ValueError it cannot classify. Most are
VoiceStudio's own validation messages and are exactly what the user should
read, so replacing them wholesale would have been a regression — tests pin four
of them as untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Refs #1931.
torchaudio 2.9 removed set_audio_backend(). soundfile has been the only backend
since 2.0, so the call was already a no-op — but unguarded it raises
AttributeError inside the ml_imports startup phase, and a failure there takes
the whole backend down: the desktop app sits on "starting backend" forever and
/health stays 503.
The group hitting it is not hypothetical. RTX 50-series (Blackwell, sm_120)
cards have no kernels in the pinned torch 2.8.0, so those users MUST move to
torch 2.9.x, which brings torchaudio 2.9 with it. Being forced to upgrade and
then crashing on a line that does nothing is the whole defect.
This does NOT raise the torch pin. Doing that changes the CUDA build on every
platform, in Docker and in CI, so it is the owner's call rather than something
to slip into a bug fix — the issue stays open for it. What lands here is the
half that is safe: the guard, plus a troubleshooting section with the exact
upgrade recipe and the command to confirm the card is visible, so an affected
user has a supported path today.
The guard is tested at the source level: reproducing it needs a real torchaudio
2.9 in the environment, which the pinned test env does not have. One test also
pins that the guard actually WRAPS the call, since a hasattr elsewhere in the
file would satisfy a naive substring check while the real call stayed bare.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1808.
#1797 moved the compute-time budget into Settings → Performance & Device, but
three branches of _timeout_guidance still told the user to raise
OMNIVOICE_GENERATE_TIMEOUT_S. That sends someone to set an environment variable
for a value the app now exposes as a control — and on Windows, setting one
durably is the trap this project's own docs warn against.
Nothing about the mechanism changed: the variable still works and still takes
precedence over the setting. Only which of the two the message names.
Two existing tests asserted the env var appears in that text. They predate
#1797 and were pinning the behaviour this issue reports as wrong, so they now
assert the control instead. A third test guards the whole class rather than the
three instances, so a branch added later cannot quietly reintroduce it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1866.
Model Catalogue → Engines showed "Engine unavailable. Check installation and
configuration." and "Last error: A previous engine check failed." for engines
the user had simply never installed. Neither names a missing package, a missing
step, or a next action, and the second reads like a crash or a poisoned cache
rather than "you have not installed this yet" — so a normal, expected state
looked like a fault.
The probe's own sentence still cannot cross the boundary: it carries exception
text, local paths and sometimes credentials, which is why it was replaced in
the first place. What changed is that the private diagnostic is now CLASSIFIED
into a VoiceStudio-owned category — package not installed, needs configuring,
file missing or unreadable — exactly the shape _public_routing_reason already
uses for routing. Anything unrecognised keeps the old generic sentence rather
than asserting a cause the probe never gave.
test_docs_url_survives_the_public_metadata_scrub pinned the generic wording
while testing something else; it now asserts what it is actually about, that no
private text survives.
Also skips the exec-bit placeholder test on Windows, where os.access(X_OK) is
true for any existing file so the assertion cannot fail — it errored the whole
module on a Windows checkout. Pre-existing, unrelated to this change, and in
the way of running these tests at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1845. Closes#1886.
The widget window is created always-on-top, and the setup state had no time
limit at all. On a clean macOS install the pill sat over the first-run setup
window — covering the disk-space line and the Start installation button — and
over every other application, until Accessibility was granted or the user
dismissed it by hand. There was no cap and no safety net: the stranded-pill
reconcile only runs while idle, and this state is not idle.
A permission the user has not granted yet does not outrank what they are
actually doing, and mid-setup they usually cannot grant it yet anyway. The
prompt now gets a bounded claim on the screen and then steps aside.
Polling deliberately continues after the window hides, so granting
Accessibility later still returns the widget to idle on its own — what expires
is the pill's claim on the screen, not the reconciliation. The hide is latched
so it fires once rather than fighting anything that legitimately shows the
window again; both properties have a test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1856.
The mandatory-only install path ships no speech-to-text model, and the
dictation step rendered its three script cards regardless. Every card came up
red with "No speech-to-text model is installed", and the step's own copy
invited the user to press the hotkey or hit Replay, neither of which can
transcribe anything. That is the final screen of first-run setup, so the last
thing a new user saw was three failures they were told to cause.
The step now checks readiness the same way the component already checks for
its bundled sample WAVs, and when no model is installed it offers the model
chooser in place of the cards — the same picker the Transcriptions page uses,
so the user installs one and continues rather than reading an error three
times. A model already on disk can be selected without a download.
`checking` deliberately keeps the cards: the probe resolves in well under a
second, and flashing the install panel first would be worse than the wait.
A test pins that.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Closes#1957.
A download failed with nothing but the OS sentence: "[WinError 448] The path
cannot be traversed because it contains an untrusted mount point". That is a
Windows rule about the VOLUME — Dev Drives, mounted VHD/ReFS volumes and
junctions into another user profile all trigger it — so retrying the same link
can never work, and the message names nothing the user can change.
Classified now, with a remedy that points at Settings → Storage and gives the
fsutil escape hatch for a folder that has to stay put. Matched on the numeric
code first, since Windows translates the sentence, with the English phrase as a
fallback. Allowlisted for context-free surfaces because it arrives through the
global 500 handler, which otherwise attaches no hint at all — and its trigger
is unmistakable, so it cannot land on an unrelated failure.
A test pins that the offending path never comes back in the payload.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Two CI failures, both mine to fix.
The warning I added to the Colab ASR cell used \n escapes inside the notebook
JSON, and they landed as real newlines, so the cell's Python had an
unterminated string and tests/test_colab_asr_setup.py could not exec it. The
block prints line by line now, with no escapes to get wrong.
test_tauri_log_clear_reports_truncate_failure patched _tauri_log_candidates,
but #1925 moved Clear onto _tauri_plugin_log_candidates, so the patch no longer
reached the code under test and the real resolver was consulted instead. It
passed on a machine with a shell log on disk and failed on a clean runner.
Patches both halves, matching the fixture in test_tauri_log_clear.py.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
#1877 completes the zh-CN translation and drops its ratchet to zero, but the
PR never ran today's gates — it has been conflicting, so CI reported nothing —
and the file it lands does not pass tests/test_locale_parity.py.
Three things fixed here:
- Twelve keys were declared twice inside the same object (timing_concise,
autofit_quality, the plan_* set, the role_* set). Python's parser rejects a
duplicate key outright, so the whole suite errored rather than failing one
assertion. Deduped keeping the first occurrence, which is the block #1877
actually translated.
- The `player` section appeared twice: the complete new one and an older
two-key stub. JSON keeps the LAST, so the stub silently won and six keys
vanished at runtime. The stub is gone.
- `settings.hf_source_*_label` appeared twice with slightly different wording.
The file is rewritten as canonical JSON (indent 2, non-ASCII preserved), which
is byte-identical to how en.json already serialises, so the format matches the
other locales exactly. zh-CN now has zero keys missing and zero beyond en.
Also fixes the review finding on #1959: the capture route picks its engine from
a `mode` form field, not an `accurate` flag, so parametrising on `accurate`
sent a field the route ignores and ran the default fast path twice. Both
engines are exercised now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
tests/test_no_hardcoded_cjk.py fails on any non-English text outside the
translation layer, allowlist aside, and #1892 put the mirror region's Chinese
label into two Rust doc comments. The comments only quote what the UI shows, so
naming the region in English says the same thing and keeps the guard green
without widening the allowlist for a comment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Bot review raised one blocking and several real findings across these PRs.
Each is fixed here rather than merged and followed up.
#1892 — apply_pypi_index_env() now runs for EVERY uv invocation, and with the
default region "auto" it called the UNCACHED auto_detect_region(), racing two
live network probes with a 4s timeout per uv call. On a blocked or offline
network that is a repeated multi-second stall, and it multiplies the outbound
calls a local-first app makes unasked. The probe is memoised for the life of
the process. It also now clears UV_INDEX_URL before setting it, so a stale
ambient value cannot outrank the region the user picked.
#1925 — backend.rs trimmed OMNIVOICE_LOG_DIR for its emptiness guard but built
the path from the RAW value, while the Python reader strips it. A padded value
therefore had the writer and the reader looking at different directories, which
is the divergence the PR exists to close.
#1920 — the rotation walk caught bare OSError, so a PermissionError or a real
I/O failure was swallowed and the panel silently rendered less. Only the race
the guard exists for (a file that rolled away, and on Windows the handler's own
sharing violation) is skipped now; anything else surfaces.
#1951 — the fix was right but shipped no tests and no changelog entry. Both
added, including a case pinning that the wizard preflight and the diagnostic
route the same host the same way, since they carry separate copies of the
branch.
#1923 — the cell hardcodes the CTranslate2 model, but if the cuDNN 8 step
failed the backend falls back to PyTorch Whisper and downloads a second
multi-gigabyte model. The cell now says so while the download it just spent is
still on screen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
The frame matcher required non-whitespace from line start up to the `@`,
which is right for rejecting a V8 header posing as a frame but wrong for
JSC: it labels top-level frames `global code@url`, `eval code@url` and
`module code@url`. Those are exactly the frames an injected extension
script throws from, so on WKWebView — the macOS desktop shell — and Safari
no frame matched, the origin came back unknown, and the extension's error
still offered "Report this bug". #1901 was fixed on Chromium only.
The three labels are enumerated rather than allowing spaces generally, so
the header false positive the anchoring exists for stays closed; a test
pins that. Each WebKit case uses a distinct message because shouldShow()
throttles by message text and a shared one would pass on the throttle
instead of the frame match.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
#1917 adds a Learn more link to the unavailable-engine row, driven by
_ENGINE_DOCS, and a guard that every registered engine has a doc page. It was
written before audiocpp landed on main, so against today's main the guard
failed on audiocpp and the registry shape test failed on the new docs_url key
— PR-green under an older base, main-red on merge.
docs/engines/audio-cpp.md already existed; only the id-to-path mapping was
missing. The shape test now expects docs_url, with a note pointing at the
guard so the next engine added without a doc fails loudly rather than
quietly dropping its link.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
`round(s.get("end", 0), 2)` does not defend against a stored None: the key is
present, so `.get` returns the None rather than the default, and `round` raises
TypeError: type NoneType doesn't define __round__ method
`max(s.get("end", 0) for s in segments)` on the line above raises first when any
other segment is timed:
TypeError: '>' not supported between instances of 'NoneType' and 'float'
Two engines reach these builders with end=None. `_sherpa_result` sets
duration=None when it cannot derive one from the sample rate, and sherpa is the
first capture engine. `OpenAICompatASRBackend._adapt_response` emits end=None for
every plain-text response, which is what a server that rejects verbose_json
returns, and that backend is selectable as the active one used by accurate mode.
Measure the duration from the segments that carry a number, and pass the nulls
through. That is the shape the segment list already renders since #1904 — it
shows whichever half of the range is known — and it keeps the honest null the
producers deliberately write instead of inventing a zero.
capture_ws.py has the same two lines and gets the same treatment; it also emits
end=None itself in five of its own streaming payloads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes#1943.
A macOS mlx-audio text-to-speech failure returned a 500 advising the user
that "the connection to the video server dropped mid-download". No video was
involved. VIDEO_DOWNLOAD_NETWORK triggers on bare phrases — "timed out",
"connection reset", "broken pipe" — so any unrelated failure carrying one
is handed a confidently wrong next step, which is worse than no hint at all.
failure._CONTEXT_FREE_HINT_CLASSES already existed for exactly this, and its
own comment names VIDEO_DOWNLOAD_NETWORK as the class that must never appear
on a stageless surface. Only append_hint honoured it; public_exception_response
took over the 500 path without carrying the rule across, and the streaming
error frame then inherited the same gap through it.
The filter now lives in public_exception_response, so every context-free
caller gets it. MODEL_CACHE_CORRUPT joins the allowlist — its trigger is a
VoiceStudio-authored sentence, no library can produce it, and the 500 handler
is the surface a corrupt cache actually reaches.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
The poll loop treated any status other than "starting" as success, so a
backend that stayed alive but reported a failed startup would pass the
very test meant to catch a broken start (CodeRabbit + Greptile on #1955).
Succeed only when the ML import step is done or status is ready; fail
loudly on any other terminal status or error. Also give the child an
empty HF_HUB_CACHE so it never reads the developer's populated cache.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCDZcBpP6QQa4dzUa8z6rh
Closes#1800.
Every engine failure the taxonomy cannot classify renders one floor message,
"Generation failed. Check the selected engine and try again." The auto bug
reporter puts that message and a stack of minified bundle frames into the
issue, so unrelated faults arrive as byte-identical reports — roughly a dozen
of the open issues are that same report filed again, and none of them can be
told apart, let alone triaged.
The streaming error frame now carries the exception's TYPE NAME, the frontend
keeps it on StreamingPreviewError, and the report prints it as "Backend error
class: …". A MemoryError and a FileNotFoundError stop being the same issue.
Only the class name — no substring of the exception message is copied, so the
response-safety contract still holds and a test pins that a path in the
exception never reaches the payload. This is the same datum the dub routes
already put on the wire as error_class and the analytics allowlist already
treats as content-free.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
frontend/ is a bun workspace, so its package.json is locked by the
repo-root bun.lock. Dependabot bumped only the manifest, so
`bun install --frozen-lockfile` — which CI and deploy/Dockerfile both
run — rejected the tree and the Tests job never got past install.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
tests/test_no_literal_borders.py guards the app-wide border removal: a
`border-[var(--chrome-border…)]` renders a stray hairline the moment that
token stops resolving transparent. The picker's indent rail used one, which
failed the guard. The indent and padding already carry the nesting, so the
rail keeps its width as border-transparent and shows nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Spawns the real backend the way the desktop shell does (containment marker
plus a piped stdin) and asserts startup gets past the ML import. On the
pre-fix watchdog it times out after 180 s; on the fix it passes in ~4 s.
Windows-only, since the deadlock is a Windows loader-lock interaction and
CI's backend job runs on Linux.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCDZcBpP6QQa4dzUa8z6rh
frontend/package.json moved but the workspace-root bun.lock did not, so
`bun install --frozen-lockfile` — what CI and deploy/Dockerfile both run —
rejected the tree. Plain `bun install` tolerates the drift, so a green local
run said nothing about it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
The Dub landing used to carry a WorkspaceProjects panel, and that panel was
the only caller of the project rename endpoint. Making the landing
history-only removed the panel and left rename unreachable from the entire
UI, while the rename route, the project list and the inline-rename CSS all
stayed. App.jsx's renameProject became an unused variable, which is what
failed CI lint — the lint error was the symptom, the lost capability was the
bug.
Projects now live only in the sidebar rail, so the affordance moves there:
inline rename on each project row, commit on Enter or Save, abandon on
Escape, empty and unchanged names ignored, and the button hidden when no
handler is wired. The orphaned WorkspaceProjects component is deleted.
Also fixes a Windows-only failure in initialLoadRetry.test.js: it took
.pathname off a file:// URL, which on Windows yields "/C:/..." and made
readFileSync resolve "C:\C:\...", so the file ENOENT'd on every Windows
checkout. Uses fileURLToPath instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
(cherry picked from commit 0a164add119335c3a150725cfe4c4ce8959c50a5)
The missing-model empty state offered exactly one action: download the
recommended Whisper Tiny. The six other catalogue models — the more accurate
English Parakeet, the 25–44 MB streaming models that show text while you
speak, the bilingual zh/en ones — were only reachable through Settings, and
a user who already had one on disk was still told to download Whisper Tiny.
The page now lists the whole sherpa-onnx catalogue grouped by the trade-off
the user is actually choosing between (best accuracy vs lowest latency),
with languages and download size on every row. Any model can be installed
in one click, an installed one can be switched to without a download, and
the progress bar names the model that was picked. If the catalogue cannot
be read the single recommended-download button remains as the fallback.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
(cherry picked from commit 9c4ba2bab2a2253fbf4f82bf082db530267ddadc)
Adds the sherpa-onnx dictation model picker under the Transcription engine
row so the model the hotkey loads is switchable without opening Settings,
routes the Sherpa transcription path through that same preference, and makes
the Windows desktop dev stack recover instead of demanding Task Manager.
Refreshes the Tauri and npm dependency pins that went with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
Every backend spawned by the Windows desktop shell hung forever in the
startup worker's `import torch`, inside the loader for numpy's OpenBLAS
DLL. The desktop parent-liveness watchdog (0a20aeb0) parks a synchronous
read on the stdin pipe the shell hands the backend, and that pending read
deadlocks the DLL initializer. The identical command from a terminal, with
no stdin pipe and no watchdog, starts in seconds — which is why it only
reproduced under the app.
Bisected outside the app by spawning the backend with the shell's exact
env, pipes, creation flags and job object: a watchdog thread that merely
sleeps is harmless; a pending ReadFile, via the C runtime or straight to
the kernel, hangs it every time. Native stacks (py-spy --native) show the
watchdog in NtReadFile and the importer waiting on a critical section from
inside the OpenBLAS initializer.
Fix: on Windows the watchdog polls PeekNamedPipe and reads only bytes that
are already buffered, so no I/O is ever outstanding on the pipe. It still
exits the instant the desktop closes its end (ERROR_BROKEN_PIPE), and a
non-pipe stdin keeps the shared blocking reader. Verified: the app-style
spawn goes from an indefinite hang to ready in ~3 s, and the desktop-prod
build boots and loads the model.
Not in v0.5.1; the watchdog landed 2026-08-30 on main.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCDZcBpP6QQa4dzUa8z6rh
Greptile on #1924: the second alternative in FRAME_LINE was unanchored, so a
V8 HEADER whose message happens to read `... user@chrome-extension://...`
matched as a JSC frame. FRAME_URL then took the message's URL as the throw
site and suppressed the report -- the same false positive the previous commit
fixed, one layer down.
The round-1 test missed it because its message carried a chrome-extension://
URL with no `@` before it, so the header never matched either alternative.
A JSC frame is `fn@url` and a function name has no spaces, so the `@` must be
reachable from the line start through non-whitespace only: `^\s*\S*@`. A V8
header is `Name: message`, so the space after the colon stops the match. Both
stack dialects still work, including an anonymous Firefox frame that begins
with the `@`.
Two tests: our error whose MESSAGE contains `user@chrome-extension://` with
our frames below is still reported (red before), and a bare
`Y@chrome-extension://...` with no header line at all is still recognised as a
frame, so the anchor does not cost the Safari/Firefox shape.
Full suite 328 files / 2740 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chang-Jin-Lee <ckdwls525@gmail.com>
The GPU routing "accelerated" caveat branch in preflight and diagnose
always showed the driver/arch "may fail at kernel launch" fix hint,
even when the actual reason was a low-VRAM advisory unrelated to
drivers or torch. Gate that message on KERNEL_RISK_MARKER and show an
accurate VRAM-appropriate hint otherwise.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Achado do CodeRabbit (fora do diff) no PR #1942. Depois de desacoplar os dois
sinais, a mensagem passou a ser escolhida so por `_segmented_off` — mas na
penultima tentativa o plano devolve (True, True): o acelerador esta esgotado E
a tentativa re-lanca, entao o `snapshot_download` so entra na PROXIMA. O log
dizia "falling back to snapshot_download" enquanto na verdade ia retentar.
Sao tres estados distintos e ler so um sinal funde dois deles. A frase virou o
helper puro `_segmented_retry_note(disable, reraise)`, testado nos tres ramos —
inclusive o (True, True), que e o que a revisao pediu para cobrir.
Conflito unico em `install_model`: a `main` (#1926) acrescentou
`and not allow_patterns` a condicao do acelerador, e este branch trocou
`_attempt == 1` por `not _segmented_off`. As duas guardas valem e foram
mantidas juntas.
O comentario acima da condicao ainda dizia que qualquer falha cai no
snapshot_download; atualizado para a regra atual (falha nao-transitoria, ou a
ultima tentativa), com ponteiro para `_segmented_retry_plan`.
O `_segmented_enabled` no topo do arquivo viola a mesma instrucao de caminho
que o CodeRabbit apontou nos testes novos. Deixar metade do arquivo fora da
regra so garante que a proxima revisao aponte de novo.
Achado P1 do Greptile no PR #1942: com `disable` e `reraise` amarrados um ao
outro, a tentativa 4 de 5 desligava o acelerador E ja caia no
`snapshot_download` na mesma iteracao. Resultado: o acelerador ficava com 3
tentativas em vez de 4, e uma nova queda abandonava o manifesto reaproveitavel
uma tentativa antes do necessario, recomecando por um arquivo separado — que e
exatamente o que este helper existe para evitar.
Os dois sinais agora sao independentes: a tentativa que esgota o acelerador
ainda re-lanca, entao o caminho simples comeca na ULTIMA tentativa. Acelerador
fica com 1-4, `snapshot_download` com a 5.
Tambem blindei o caso de o acelerador falhar ja na ultima tentativa: ali nao ha
para onde re-lancar, entao a decisao vira "caminho simples agora" em vez de
estourar o laco sem nunca ter tentado o fallback.
Os testes do helper passaram a resolver o modulo da app em tempo de execucao,
como pede a instrucao de caminho para tests/**/*.py (import de modulo da app no
topo fica velho se um teste anterior sujar o sys.modules) — apontado pelo
CodeRabbit no mesmo round.
Achados do CodeRabbit no PR #1942.
O mais grave: com o erro classificado como transitorio, o codigo mantinha o
acelerador ligado mas caia direto no `snapshot_download` na MESMA tentativa. Se
esse download desse certo, o laco terminava e o manifesto do `.part` nunca era
reusado — exatamente o recomeco-do-zero que a correcao existe para impedir.
Agora o erro transitorio e propagado para o retry externo, cuja proxima
tentativa reentra no `_segmented_snapshot` e retoma do manifesto. A decisao
virou o helper puro `_segmented_retry_plan`, testavel direto (o laco mora dentro
de `install_model`, uma rota de ~200 linhas). A ultima tentativa fica reservada
para o caminho simples, entao o acelerador continua sem poder ser o motivo de um
install falhar de vez.
Tambem deste round de revisao:
- `Invoke-CimMethod ... Terminate` tinha o retorno descartado com `$null =`. O
Win32_Process.Terminate reporta falha pelo ReturnValue, nao lancando: um kill
negado por permissao era reportado como sucesso e a porta seguia presa. Agora
o ReturnValue e validado, com exit 4 proprio e a mensagem carregando o codigo.
- O teste de concorrencia era vazio: o handler sincrono do MockTransport retorna
antes de qualquer outra task rodar, entao `peak` nunca passava de 1 e a
asserção `peak <= 4` passava sem exercitar o semaforo. Passou a segurar as
requisicoes abertas com um asyncio.Event e a exigir `peak == 4` (verificado:
com o semaforo afrouxado para 1000, o teste acusa 31).
- A doc dizia que OMNIVOICE_DOWNLOAD_MAX_WORKERS limita as faixas e que origem
sem Range cai no snapshot_download. Nenhum dos dois: `_segmented_snapshot` nao
passa `num_connections` (usa as 8 padrao) e origem sem Range vira stream unico
dentro do proprio acelerador.
- Entradas de Highlights do CHANGELOG sem o `(#NNNN)` exigido.
As issues #1940 (downloader segmentado sem progresso em conexao instavel) e
#1941 (stack de dev irrecuperavel no Windows) foram abertas para estas
correcoes; substitui os refs emprestados de #1224 e #1690.
Entradas referenciadas a #1224 (truncamento de corpo no download) e #1690
(supervisor do backend de dev), que sao as issues que estas correcoes
estendem. Nao ha issue propria aberta para elas ainda.
`canStop: !windows` fazia o script recusar qualquer parada no Windows com
"stop it in Task Manager and retry". O motivo original é legítimo: `taskkill
/pid` mira um PID reutilizável, e um PID reciclado entre o inspect e o kill
derrubaria um processo alheio.
Só que isso deixava o `bun run dev` permanentemente travado sempre que um
backend ficasse órfão — exatamente o cenário do commit anterior sobre a árvore
de processos. O predev falhava e não havia caminho de recuperação automático.
A parada agora é presa à INSTÂNCIA do processo: um único PowerShell busca a
instância CIM, confere o CreationDate contra a identidade já inspecionada e só
então chama Terminate NAQUELA instância. O terminate age sobre o objeto que a
checagem validou, não sobre um PID buscado de novo depois — a corrida some.
PID reciclado devolve exit 3 e é deixado em paz, em vez de falhar a execução.
`belongsToCheckout(..., windows = false)` respeitava a flag na hora de montar
a string, mas normalizava o caminho com `resolve()` do host. Rodando no
Windows, "/work/VoiceStudio" virava "C:\work\VoiceStudio" e não casava com
nada numa linha de comando POSIX — o mesmo valia para o separador `sep`.
Efeito prático: o teste "command ownership requires a checkout path boundary"
já falhava na `main` limpa em qualquer máquina Windows, passando só no CI
Linux. Passa a usar `path.posix` quando a flag diz POSIX.
O supervisor faz `spawn("uv", ...)` e o uv sobe o uvicorn como filho dele.
Windows não tem sinais: `child.kill()` vira TerminateProcess só no filho
DIRETO, então matar o `uv` deixava o uvicorn neto vivo segurando a porta 3900.
O spawn seguinte falhava com `[Errno 10048]`, o supervisor contava como crash,
e três desses derrubavam a stack inteira de dev — inclusive o Vite, via
`--kill-others-on-fail`.
`killProcessTree` usa `taskkill /T` no win32 e mantém o envio de sinal no
POSIX. Como o kill forçado devolve exit não-zero e sinal nulo, o reload que nós
mesmos pedimos passaria por crash; isso é tratado olhando se o tree-kill de
fato aconteceu, e não a plataforma — um crash de verdade durante um reload
continua indo para a recuperação de crash (coberto por teste que já existia).
O downloader segmentado gravava progresso no manifesto apenas quando um
segmento INTEIRO terminava, e dimensionava os segmentos como
tamanho/num_connections. Num blob de 806 MB isso dava 8 segmentos de ~100 MB:
numa conexão que cai a cada ~50 MB nenhum segmento jamais completava, o
manifesto nunca era escrito e cada tentativa recomeçava do zero.
Pior, o acelerador só rodava na PRIMEIRA tentativa (`_attempt == 1`), então
depois da primeira queda todas as retentativas iam para o `snapshot_download`
e o `.part` acumulado ficava órfão para sempre.
Agora os segmentos são limitados a 16 MB e a concorrência passa a ser
controlada por semáforo (antes vinha da própria contagem de segmentos), e o
acelerador é preservado entre tentativas quando o erro é de rede — reusando
`_is_retryable_download_error`, que já é a fonte única dessa classificação.
Ele só é desligado de vez quando a falha NÃO é transitória, ou seja, quando o
acelerador de fato não serve naquele host.
Reproduzido em rede real: `peer closed connection without sending complete
message body (received 54260979, expected 100708200)`.
CodeRabbit review on this PR caught real agreement errors in the
translations added for consent.desc (and de's try_dictation_desc
register):
- de: try_dictation_desc used informal du/deine while every other
setup.* string in this locale uses formal Sie/Ihre — switched to match.
- es/fr/pt: adjectives after the anonymous-stats noun (feminine plural
in each language) didn't agree in gender/number.
- ru: predicate adjectives didn't agree with the feminine noun
"статистика".
- sv: adjectives didn't agree with "statistik" (en-gender noun).
Removing the redundant SectionHead (previous commit) left one more
duplicate on the dictation onboarding step: the step rail and
DictationDemo's own heading both said "Try dictation"/"Try Dictation" —
flagged by Greptile review on this PR.
Give the rail a genuinely distinct short label
(setup.dictation_step_label, "Dictation"), mirroring the consent step's
already-correct pattern (rail label vs. card title are different
strings). Translated into all 21 locales to hold the locale-parity
ratchet. Added a new test that renders the real DictationDemo (not
mocked to null, unlike the existing consent test) to prove the rail
label and the card's own title are distinct and each render exactly
once — the gap that let this ship undetected.
STEP_SUBTITLES.consent and STEP_SUBTITLES.dictation reused the exact
same i18n key as the step's title/label, and each step's body then
rendered a SectionHead with that same key again, on top of the step's
own content component rendering its own heading — showing the same
phrase 2-3 times on screen.
Give both steps a genuine short description (consent.desc,
setup.try_dictation_desc) for the header subtitle, matching the
system/models steps' existing pattern, and drop the now-redundant
SectionHead in each step's body since the content component
(AnalyticsConsentCard, DictationDemo) already renders its own title.
The two new keys are translated into all 21 locales to hold the
locale-parity ratchet (tests/test_locale_parity.py) at its current
baseline.
Closes#1855
CodeRabbit: the docstring said $XDG_STATE_HOME/VoiceStudio where the code says
OmniVoice. Checked against the writer rather than guessing which side was
wrong -- backend.rs::backend_log_path() joins "OmniVoice" on Linux, so the
code was right and the docstring was a pre-existing error. It matters because
that docstring is what gets read when telling a Linux user where the file is.
Reading backend_log_path() to settle it turned up something worth fixing in
the resolver this PR introduced: Rust checks OMNIVOICE_LOG_DIR before any
per-OS default, and nothing on the Python side knew about it. The backend is a
child of the shell, so an ambient override reaches both processes -- a
resolver that ignored it would look in the per-OS default while the writer
wrote somewhere else. That is the same divergence class as the desktop Logs
panel in #1782, and leaving a newly added resolver knowingly wrong was not an
option.
Two tests: the override moves both candidates, and a whitespace-only value
falls back to the default, matching the writer's !dir.trim().is_empty() guard.
The platform parity test now also clears OMNIVOICE_LOG_DIR so it stays a
statement about the defaults.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chang-Jin-Lee <ckdwls525@gmail.com>
/system/logs/tauri/clear iterated every entry in _tauri_log_candidates() and
truncated each one, including backend_err.log -- the spawned backend's stderr.
Three things make that data loss rather than a tidy-up.
The tab that owns the button does not show it. On desktop the Frontend/Tauri
panel goes through the Rust read_log_tail command, whose tauri_log_path()
resolves tauri.log and nothing else, so the user truncates a file they were
never shown.
backend.rs::open_err_log_for_run() opens it APPEND-ONLY so "a respawn must not
destroy the previous run's evidence" (#1510) and rotates it to .1 rather than
truncating. It manages its own size; clearing it from here only undoes that
design. The same file's spawn diagnostics are described there as "retained in
backend_err.log across runs and lands verbatim in bug reports".
A native death -- a Windows access violation, a SIGSEGV -- writes nothing to
the Python log by construction, so this file is the only record it happened.
#1777 and #1782 are both threads where the maintainer had to ask a reporter
for it by hand.
Clear is narrowed to the shell's own log. The READ path is unchanged: the
candidate list was split into two halves and recomposed, and a parametrized
test pins that /system/logs/tauri still reaches all four files in the same
order on darwin, linux and win32 -- the recompose is where a slip would
silently hide a log.
Refs #1510
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chang-Jin-Lee <ckdwls525@gmail.com>
Both bots caught the same defect from opposite sides, and both were right.
`err.stack` starts with a header line carrying the message, and the regex
scanned the whole string for the first URL -- so a URL in the MESSAGE was
mistaken for the throw site.
Greptile's half: an extension error reading "Failed to fetch
https://example.com" reported the message's URL as its origin and escaped the
filter. The bug this PR exists to fix, surviving inside the fix.
CodeRabbit's half, and the worse one: one of OUR failures that happens to
quote a chrome-extension:// URL in its text was suppressed as if an extension
had thrown it. A false positive here silences a real bug, which is strictly
worse than the noise it saves.
Frame detection now covers both stack dialects -- V8's " at fn (url:1:2)"
after a header, and JSC/SpiderMonkey's "fn@url:1:2" with no header at all --
and reads the URL off the FIRST frame only. When that frame names no URL (a
native or anonymous throw site) the origin is unknown and the report IS
offered: walking deeper would attribute the error to a frame that did not
throw it.
Three tests added, all three red against the previous commit; against
upstream/main the two original extension cases and Greptile's are red, while
the two "still reports" ones pass there because upstream offers a report for
everything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chang-Jin-Lee <ckdwls525@gmail.com>
A browser extension injected into the page throws into the page's own error
channel, so window.onerror surfaced it with a "Report this bug" action and a
user filed it. #1901 is one such report: the stack is entirely
chrome-extension://eppiocemhmnlbhjplcgkofciiegomcon/executors/200.js with no
VoiceStudio frame in it, and the message ("Cannot read properties of
undefined") gives a maintainer nothing to tell it apart from a real bug.
IGNORE_PATTERNS matches on the message and cannot help here -- an extension's
TypeError reads exactly like one of ours. The existing `Script error.` entry
covers only the opaque cross-origin case; an extension's script is not opaque,
so it arrives with a full stack and goes straight through.
Filter on the THROW SITE: `e.filename` when the event carries one, else the
first stack frame naming a URL. Deliberately not "any frame mentions an
extension" -- an extension that patches a built-in leaves its frame in the
middle of a stack whose fault is genuinely ours, and dropping those would
silence real bugs, which is worse than the noise it saves. A test pins that
case.
consoleBuffer still records these into Settings -> Logs -> Frontend. What is
suppressed is only the offer to file them against this project.
Closes#1901
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chang-Jin-Lee <ckdwls525@gmail.com>
Three review findings, all applied.
Greptile P1, read race: a rollover can rename a candidate between the
existence check and the open, and the handler exposes no lock a route can
take. Per-file OSError now skips that file instead of 500ing the whole panel
-- which is what the single-file version did in the same situation, so this is
strictly better than before rather than a new guarantee. A roll landing
mid-walk can still shift which chunk a file holds, so a tail taken at that
instant may repeat or miss a block; the panel re-polls every 5s and the next
read is clean. Buying strict consistency would mean reaching into logging's
internals from a route.
Greptile P1, clear race: enumerating first left a window where a rollover
created a backup after the scan and its history survived a Clear that
reported success. Clear now works off the fixed name set -- every name the
handler can write is known up front, so there is nothing to enumerate and no
snapshot to go stale.
CodeRabbit: the CHANGELOG lines ended in (#1782), which reads as "this fixes
#1782" when the desktop path defect that thread is about is untouched. Now
(#1920).
Two tests added, both red before: a candidate vanishing mid-walk still fills
the request from the next file, and a Clear whose scan reported nothing still
empties the backups.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chang-Jin-Lee <ckdwls525@gmail.com>
CodeRabbit was right: the invariant had a hole. `<ReadinessChecklist compact />`
is a fifth direct child of `.launchpad`, rendered when profiles or studio
projects exist -- which is the state the #1859 reporter was in. The first pass
gave shrink-0 to the three unconditional blocks and missed it, and the test
could not have caught it: the fixture rendered the EMPTY page, where that
branch does not mount.
Wrapped rather than passing the class down. ReadinessChecklist takes no
className, is mounted twice (nested inside the empty state as well as here),
and shrink-0 is a fact about this parent's flex column, not about the
component.
The test now runs the invariant over BOTH page states, because they render
different direct children -- empty gives the flex-1 empty state, populated
gives the checklist -- so checking one leaves the other unconstrained. The
ReadinessChecklist stub renders a marker node instead of null so the wrapper
the page owns is still findable.
Against upstream/main 3 of the 4 in this file are red; against the previous
commit, the two populated-page ones.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chang-Jin-Lee <ckdwls525@gmail.com>
main.py rolls omnivoice.log at 2 MB into .1/.2/.3, and /system/logs read only
the current file. For the minutes after a rollover the Backend tab showed a
handful of lines while up to 6 MB of history sat in omnivoice.log.1. Measured
with 3 lines in the current file and 500 in each of two backups: tail=200
returned 3 lines and reported total_lines: 3.
That is the panel CONTRIBUTING and the engine guides tell a reporter to paste
from, so the gap costs a round trip on every bug report that lands near a
roll.
The tail now reaches into the rotated siblings, but only when the current file
cannot satisfy the request -- the panel polls every 5s and opening 6 MB of
backups on each call would be a bad trade for a case that only matters right
after a roll. The response gains a `paths` list so a report can say whether
its tail crossed a boundary.
Clear is in the same commit because the two are coupled: it truncated only
omnivoice.log, so it freed almost nothing, and once the tail can see the
backups a Clear that leaves them looks like it did nothing at all.
Found while reading #1782, and it does NOT close it. That thread's blank panel
is the desktop path, which never reaches this route -- details in a comment
there.
Refs #1782
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chang-Jin-Lee <ckdwls525@gmail.com>
`.launchpad` is grid row 2 of `.app-container` and is itself a flex column
with `overflow-y: auto`. Opening the log panel grows grid row 4 and shrinks
row 2, which the shell is built for -- row 2 is supposed to hand the shortfall
to its own scrollbar.
It did not, because a flex item shrinks before its container scrolls. The
three content blocks under `.launchpad` carried the default flex-shrink: 1, so
they absorbed the shortfall: the hero is overflow-hidden, so it clipped its
own heading mid-line, and the deck moved up into the hero's artwork. That is
exactly what #1859 describes.
Measured in headless Chromium 153, 720px window, same nesting as the shell,
log panel at 560px:
variant hero box/natural clipped deck top vs h1 bottom
footer collapsed (28px) 145/145 no +59
current 64/145 YES -22
+ min-height:0 on .launchpad 64/145 YES -22
+ shrink-0 on the children 145/145 no +59
The third row answers the lead #1859 flagged as unconfirmed. The missing
min-height: 0 on `.launchpad` -- its two siblings, .app-container >
.main-content and .studio-panel, both set it -- is inert: overflow-y: auto
already zeroes a grid item's automatic minimum size, so the track was
shrinking correctly all along. Not adding it, because a rule that looks like
the fix and is not would mislead the next reader.
The test states the invariant rather than the three class names: a
`.launchpad` child either protects a height (shrink-0) or is deliberately
elastic (flex-1, which is what the empty state is). A fourth block cannot be
added unlabelled.
Not touched: the bell-icon-opens-a-log-console labelling mismatch the report
mentions as a separate observation.
Closes#1859
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chang-Jin-Lee <ckdwls525@gmail.com>
Two CodeRabbit Major findings, both applied.
O(n^2) retention was mine, introduced in the previous commit. Keeping the full
run in `logs` state meant `prev.concat([entry])` copied the whole array on
every event, so a 5000-line install did ~12.5M element copies and could
stutter the splash on exactly the verbose runs that need it. The run now lives
in `allLogsRef` and is pushed to (O(1)); `logs` state is back to holding only
the rendered tail, so the one array copied per event is bounded by
VISIBLE_LOG_LINES again. `totalLines` is what re-renders on a new line. Copy
reads the ref, and the two full-run scans (detectHints, isUnrecoverableFailure)
read it behind `isFailed`, when nothing is arriving any more.
Permanent dedup was pre-existing and does lose real output: installer text
repeats constantly, and any line matching one of the last five was dropped, so
the counter undercounted and Copy lost lines. The overlap it guards can only
happen on the first live event after backfill, so it now runs until the first
accepted line and never again. Telling a true repeat from a replayed one for
the whole run would need a sequence number from the Rust side; narrowing the
window to where the ambiguity actually is does not.
Three tests added: a repeated line survives, the backfill seam is still
deduped (regression guard for the narrowing), and the rendered state stays at
200 across a 5000-line stream, which is the invariant that keeps the append
cheap. Against upstream main 6 of the 7 in this file are red; the seam test is
a guard, not a fail-before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chang-Jin-Lee <ckdwls525@gmail.com>
MAX_LOG_LINES = 200 capped the `logs` state itself, so on a cold install every
line past the 200th destroyed an earlier one. Four consumers read that array
and all four degraded once a real install ran past the cap:
- the Activity heading renders logs.length, so the counter sat pinned at 200
for the rest of a multi-minute bootstrap while lines kept streaming. Live
progress read as stalled when it was not. This is what #1847 reported.
- handleCopyLogs serializes the same array, so Copy could only ever return the
newest 200 lines -- and this splash is the only place in the app with a
copy-log affordance at all.
- detectHints(message, logs) scans for actionable failure markers. A failure
early in a long install lost its marker, so the card fell back to
hint_default and told the user nothing specific. This is the severe one: the
screen still looks helpful while saying nothing.
- isUnrecoverableFailure(message, logs) runs off the same scan.
Only the <pre> needed the cap -- it is a DOM budget, not a retention policy.
Keep the run in state, slice at the one place that writes DOM, and rename the
constant to VISIBLE_LOG_LINES so the next reader cannot make the same mistake.
The array is bounded by one bootstrap: both retry paths clear it and App.jsx
unmounts the splash when the stage flips to 'ready'. The two full-array scans
are behind `isFailed`, so they never run while lines are streaming.
Not fixed here: the other half of #1847, that the splash vanishes on success
with no completion state and the log is then unrecoverable. That needs either
a lifecycle change in App.jsx or a Rust-side persisted stream, and the report
frames the two as separable.
Refs #1847
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chang-Jin-Lee <ckdwls525@gmail.com>
The entry said the log records "what kind of failure it was", which is the
overclaim Greptile flagged on the field itself. It records whether the probe
raised, and nothing about the cause.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chang-Jin-Lee <ckdwls525@gmail.com>
Greptile P1: `failure=unavailable` reads as a classification of the cause, but
SubprocessBackend.health_check() swallows its own exceptions by contract, so a
dead sidecar and a package that was never installed both return (False, msg)
and land in the same bucket. Rename to `probe=`, with `raised:<Class>` and
`returned-unavailable` as the two values, so the field states what the probe
did and claims nothing about why. The limitation and what it would take to fix
it properly (structured failure metadata from the probes) are named in the
comment and the test docstring.
Greptile P2: drop the trailing arrow glyph from the Learn more button. It sat
outside t(), and a bare "→" points the wrong way once the app switches to an
RTL locale. InfoHint hardcodes the same glyph and would want the same
treatment, but that is not this PR.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chang-Jin-Lee <ckdwls525@gmail.com>
Model Catalogue -> Engines can only ever say "Engine unavailable. Check
installation and configuration." and "Last error: A previous engine check
failed." for an engine whose package is not importable. That is by design:
public_backends() replaces reason and last_error because an availability probe
can carry exception text, a local path or a credential, and two of the shipping
is_available() implementations do interpolate an exception into their message.
So the row cannot explain itself. docs/engines/<engine>.md can -- accurate,
and for cosyvoice CI-guarded against the installer registry -- but nothing in
frontend/src referenced docs/engines at all, so the point of failure was a dead
end. #1746 is that dead end reaching the tracker.
Add a registry-authored docs_url next to install_hint and setup_snippet. It is
a VoiceStudio-owned constant keyed on the engine id, not probe output, so the
public scrub leaves it intact by construction rather than by classification --
which is what keeps the security boundary where the maintainer put it. The row
renders it with the same "Learn more" affordance MCPBindingsPanel and
RemoteBackendPanel already use, so no new i18n key is needed.
The health log line said "Engine health check failed; details withheld" and
named neither the engine nor the kind of failure, while the response tells the
user to check the backend log and docs/engines asks them to copy that engine's
lines. Log the registry id and a stable exception class -- the same class=
shape core.public_errors.public_failure() already logs. The diagnostic text
stays out and the id is flattened to one token, so
tests/test_response_safety.py's existing log-injection test passes unchanged.
Not touched: publishing the computed reason itself. That is the product
decision the reporter flagged, and the log line is deliberate, not an
oversight -- test_engine_health_route_logs_but_does_not_return_private_
diagnostic pins it.
Refs #1866
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chang-Jin-Lee <ckdwls525@gmail.com>
The preflight detail line and the storage-path row set dir="rtl" purely to
move the ellipsis to the start of the line, so a long path keeps its tail
visible. That also makes the line an RTL paragraph, and the Unicode bidi
algorithm places a leading run of European numbers or neutrals at the visual
right edge of one. Every detail starting with a digit or a "/" therefore
rendered with its head at the end:
48.0 GB total -> GB total 48.0
/home/user/.cache/huggingface -> home/user/.cache/huggingface/
Letter-first details were unaffected, which is the signature of bidi
reordering rather than bad data; the backend emits these strings correctly
ordered.
Wrap the text in <bdi> at both call sites. The isolate is dir="auto", so the
run is ordered by its own content while the box keeps direction: rtl for the
ellipsis side. Measured in headless Chromium 153: "48.0" moves from x=49 to
x=0 and "total" from x=20 to x=46, and an overflowing path still has its head
clipped off the start (x=-142), so the start-side ellipsis survives.
jsdom resolves no bidi, so the regression test pins the DOM shape instead, and
a source scan fails any future dir="rtl" element that owns its text directly.
Closes#1848
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Chang-Jin-Lee <ckdwls525@gmail.com>
"Tests (backend + frontend)" was failing on this branch: the backend suite
passed (7059 tests) and the frontend suite passed, but `bun run format:check`
flagged src/test/widgetPillShadowClip.test.jsx. Formatting only — no
behavioural change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review finding on 5e9538a0. The reset keyed only off arriving at a restart
stage, so a retry whose restart stage the poll never sampled did not reset at
all: failed -> checking -> starting_backend inside one ~1s window surfaces as
failed -> starting_backend, leaving the FAILED attempt's stages in
polledStages and rendering its install chrome as this attempt's completed
work. Not merely a late boundary — no boundary.
retry_bootstrap / clean_and_retry_bootstrap are the only exits from `failed`,
so leaving that stage is itself proof a new attempt began. Keying off it as
well as off restart stages closes the case without new producer state.
Regression test fails against 5e9538a0, passes here. Suite green (25 tests).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review finding on ece08bd7. The attempt boundary was stamped when the ~1s
status poll first reported `checking` — which lands after the Rust side has
already emitted the new attempt's first log lines. Those lines were then
filtered out as belonging to the previous attempt, so a fast stage that the
poll also missed stayed pending: the exact evidence loss the log union was
added to prevent.
Retries we initiate now open the attempt in beginAttempt(), at initiation, so
their boundary is exact. A guard stops the stage-transition effect from
re-stamping a boundary we already set a poll interval earlier — without it
the fix would have been undone one tick later.
The effect remains the fallback for restarts begun on the Rust side, where
the poll is the only signal available. That window is documented rather than
hidden: it fails toward showing a step pending (conservative and honest)
rather than done (the fabrication this PR removes). Closing it entirely needs
a Rust-provided attempt id — a new IPC surface, deliberately out of scope.
Regression test fails against ece08bd7, passes here. Suite green (24 tests).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two review findings on #1896, both real:
1. Polling misses completed stages. `bootstrap_status` is sampled ~1/s, so a
stage that starts and finishes between samples was never recorded — on a
fast disk `creating_venv` routinely does — and would render pending
forever even though it ran. The stage-tagged `bootstrap-log` stream is
emitted as the work happens, so a line tagged with a stage is independent
proof that stage ran. The observed set is now the union of the two; the
comment claiming the poll "guarantees" a stage lands at least once was
wrong and is gone.
2. Retry kept stale stages. The observed set was add-only and the splash
stays mounted across a Retry, so a stage the failed attempt reached would
still render done in the new attempt even when that attempt skipped it —
the exact fabrication this change exists to remove. Arriving back at a
restart stage from anywhere else now starts a fresh attempt. Keyed off the
stage transition rather than our own Retry buttons, so a Rust-side restart
resets it too. Log evidence is filtered to the current attempt; the
visible log is deliberately left alone (clearing it would destroy the
user's context, cf. #1847).
Regression tests added for both; each fails against 1c344bf9 and passes here.
Full BootstrapSplash suite green (6 files, 23 tests).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Greptile P1 on #1897: the comment claimed the moved clear_sentinel() sits
"comfortably inside any shutdown deadline, including Windows' effectively-zero
one". That is false. On Windows tools.rs terminates the job object with no
graceful phase, so lifespan teardown never begins and this line is never
reached — a deliberate quit is still misreported as a crash there.
The code change is unaffected and still correct for the platforms where
teardown does begin. Only the claim was wrong, so only the claim changes.
Windows needs the shell to signal deliberate intent before the hard kill,
which is a Rust-side change tracked separately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured on macOS build 0.5.2-153: a backend given SIGTERM directly
completes graceful shutdown in 5.25s and clear_sentinel() runs
correctly. But the desktop shell's quit path
(frontend/src-tauri/src/bootstrap.rs:388) grants only a 2s grace
before force_terminate()+kill(), and Windows
(frontend/src-tauri/src/tools.rs:522-528) grants no graceful phase at
all. clear_sentinel() used to be the LAST statement of lifespan
shutdown (backend/main.py:1213), behind ~50s of bounded waits and
model unload/free_vram()/gc.collect()/httpx close — so a deliberate,
clean quit routinely got SIGKILLed before reaching it, leaving
run_sentinel.json behind for the next launch to misreport as a crash.
Move the sentinel clear to the TOP of the shutdown block, immediately
after `yield`: once uvicorn has begun graceful shutdown the exit is
deliberate by definition, so the sentinel has already done its job.
One os.remove is comfortably inside any shutdown deadline, including
Windows' effectively-zero one. The later clear_sentinel() call is
removed (not duplicated) so a later failure in this function can't
mask the early result; the truthful "Shutdown: done."/degraded log at
the end now reads that earlier return value instead of re-clearing.
Adds a regression test that forces a later shutdown step
(model_loads_begin_shutdown) to raise, simulating the kill hitting
mid-teardown, and asserts the sentinel is already gone and
detect_unclean_shutdown() reports no crash. Confirmed fail-before /
pass-after against this change.
Fixes#1895
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BootstrapSplash derived step "done" state purely from STEPS.indexOf(stage),
so a warm start (bootstrap.rs finds the venv healthy and jumps straight from
Checking to StartingBackend) rendered downloading_uv/creating_venv/
installing_deps as fabricated green DONE ticks, including the "first run,
5-10 min." label. A repair sync (venv exists, only InstallingDeps runs) hit
the same fabrication, and JourneyRail hardcoded Setup=done/Installing=active
regardless of stage.
Track which stages are actually observed (sticky, via the ~1s
bootstrap_status poll) and derive doneness and journey-chrome visibility from
that instead of list position. Journey rail, the "Installing" heading, the
step list, and the resume note stay hidden until a genuine install stage
(downloading_uv/creating_venv/installing_deps/awaiting_setup) is observed;
the masthead, live stage label, progress meter, and activity log are
unaffected. No new user-facing strings.
Fixes#1894
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
China / high-latency paths often need 3–5s just for TCP to
huggingface.co or hf-mirror.com; the previous 2s/3s probes
falsely reported Unreachable on port 443 and could hard-fail
older preflight builds.
Co-authored-by: Cursor <cursoragent@cursor.com>
Repair previously called apply_uv_env without UV_INDEX_URL, so China
region installs still hit pypi.org for hatchling and failed with
tls handshake eof while the UI showed 中国 (镜像). Fold index selection
into apply_uv_env so first-run, drift, repair, and pip repairs share it.
Co-authored-by: Cursor <cursoragent@cursor.com>
CodeRabbit: the previous assertions passed for any non-empty title that
differed from the visible text, so a fallback to the label would have
satisfied them — they did not prove `errorInfo.message` takes precedence.
Match on "audio group", which lives only in capture.mic_hint_linux and never
in the label, so a fallback or an unrelated tooltip fails the test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CodeRabbit: only the setup title was covered. Add the error branch that
carries a message, asserting the title is strictly more than the visible
clipped text. The no-message branch falls through to `label` and is covered
by the setup-state test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CodeRabbit: the max-width assertion used `<=`, so a future cap of 200px
would pass while silently narrowing the pill and clipping more of the label
— the truncation #1884 is about. Assert equality with the real content box
(WINDOW_WIDTH - GUTTER * 2) so the required width is pinned, not just
bounded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CodeRabbit correctly flagged the regression test as tautological: with a
single `main_window_visible` argument, `should_restore_on_reopen(false)`
passes under the aggregate `has_visible_windows` rule too, so the test gave
no protection against the bug it was written for.
Take Cocoa's aggregate flag as a second, deliberately-ignored parameter and
pass it from the event arm. That lets the test state the contract that
matters — main window hidden, pill visible, still restore — and it now fails
if the body ever reverts to deciding on the aggregate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `widget` Tauri window is exactly 300x64 with an 8px body padding on
every edge around the 48px pill, leaving only an 8px gutter before
`overflow: hidden` clips anything painted outside it. `.capture-pill`'s
shared shadow (`0 8px 32px` + `0 2px 8px`, ~40px of needed clearance) had
nowhere to go there, so it hard-clipped into a straight edge at the
window boundary — a rounded capsule sitting inside a hard-edged dark
rectangle instead of floating free over the desktop. The recording/
transcribing state shadows had the same problem and fully override the
base shadow, so the clip would reappear the instant dictation started.
Scope a tighter shadow to `html[data-window='widget']` for the base pill
and both state variants, each layer verified to keep
`|y-offset| + blur + spread <= 8` (the gutter), and cap `max-width` to
the window's 284px content box (the shared 340px value is wider than the
window itself). The main-window `.capture-pill-host` path is untouched —
it has real clearance and its shadow already renders correctly there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first revision of this handler gated the Dock-icon restore on Cocoa's
`has_visible_windows`. That is too coarse for this app: the dictation pill
is a second, always-on-top window (`widget`) shown and hidden independently
of the main window, and its Accessibility-setup state stays on screen until
the permission is granted.
So with the main window closed and the pill up, Cocoa reports
`has_visible_windows: true`, the guard returns false, and clicking the Dock
icon still does nothing — the exact failure this handler was added to fix.
Ask the main window directly instead. `is_visible()` fails only if the
window is gone, and a redundant show is harmless next to a Dock icon that
stays dead, so an error is treated as "not visible".
Adds a regression test naming the pill case so the coarse predicate cannot
come back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closing the main window with the traffic light hides it (CloseRequested
never destroys it, by design) but the app.run() loop only handled
ExitRequested — there was no RunEvent::Reopen arm anywhere in the
crate, so clicking the Dock icon with no visible windows did nothing.
The window was only recoverable via the tray's "Show VoiceStudio" item
or a full quit/relaunch.
Add a macOS-gated RunEvent::Reopen arm that runs the same
show/unminimize/focus sequence as the tray's "show" handler, now
factored into a shared show_and_focus_main_window() so both recovery
paths can't drift apart. The restore decision (skip when a window is
already visible) is split into a pure should_restore_on_reopen() helper
so it's unit-testable outside the real Cocoa event loop, following the
file's existing with_noactivate_style/is_app_origin pattern.
Scope: macOS-only implementation of a macOS-only platform convention.
Does not touch CloseRequested's hide-instead-of-close behavior, and
does not add multi-window support (out of scope per #1887).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pill's label <span> only ever set `title` for state === 'error';
every other state, including 'setup', rendered `title={undefined}`.
The 300x64 widget window leaves ~284px for the label after padding and
the status dot/button, so `capture.a11y_setup` ("Allow Accessibility
so dictation can type for you") clips to "Allow Acc…" with nothing to
reveal the rest on hover.
Reuse the already-localized `label` as the tooltip for every state,
keeping the richer error message where one exists. No new strings, no
locale changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
zh-CN.json was missing 486 of en.json's 3,057 leaf keys (baseline 486 in
_MISSING_BASELINE), so Settings / Models / Engines / Dictation surfaces
rendered English fallback. Translate every missing key following the ko
overhaul in #1776: brand and technical terms verbatim (Tauri, Discord,
Hugging Face, LLM, FFmpeg, torch.compile, DELETE), {{placeholders}}
preserved on all 70 keys that carry them, i18next tags (<1>, <code>,
<issueLink>) intact, existing translations and key order untouched.
Tighten _MISSING_BASELINE['zh-CN'] from 486 to 0 — zh-CN now matches
ko at full parity, verified with tests/test_locale_parity.py (248
passed).
Signed-off-by: yearthmain <yearthmain@gmail.com>
Greptile flagged the Unreleased entry as missing the contributor
credit the changelog convention requires for community PRs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
macOS draws its own native traffic-light cluster even with
decorations:false (tauri.conf.json's titleBarStyle:"Overlay" still
overlays it), but Header.jsx's showWindowControls only checked
whether the app was running under Tauri, not which OS — so the
custom Windows-style minimize/maximize/close row rendered on macOS
too, duplicating the native controls.
Gate it on platform using the same navigator.platform check already
used in HotkeyTab.jsx / SettingsSearch.jsx. Windows/Linux keep the
custom row since decorations:false gives them no chrome otherwise.
Fixes#1864.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CodeRabbit flagged that setPlatform() redefined navigator.platform as
an own property but nothing ever restored it, so after this file's
tests run the global stays pinned to whichever platform ran last
('Linux x86_64') — order-dependent and able to leak into any later
test in the same environment that reads navigator.platform. Capture
the original descriptor (undefined, since it's an inherited jsdom
getter) and restore it — or delete the own-property override — in
afterEach.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Greptile flagged that omitting `instruct` from the first-sound request
fixes OmniVoice (whose `_resolve_instruct` rejected the old free-text
prose) but breaks a different engine: mlx-audio's Qwen3 VoiceDesign
backend requires a truthy `instruct` and raises ValueError without one
(`_is_voice_design()` in backend/services/tts_backend.py), so a user who
picked that engine during onboarding would still get silent first-sound
failure — same bug class, different engine.
Send 'middle-aged, low pitch' instead of omitting the field: it's the
exact taxonomy string the backend's own "Narrator" personality preset
uses (backend/core/personalities.py), so it's valid vocabulary for
OmniVoice's `_resolve_instruct` and a non-empty description for any
voice-design engine. Rewrote firstSoundInstruct.test.js, which
previously asserted instruct was absent entirely (passing for the wrong
reason); it now asserts a non-empty, taxonomy-only instruct is sent and
cross-checks its value against personalities.py's narrator preset so the
two can't silently drift apart.
Also credited the community contributor in CHANGELOG.md per the repo's
own convention (Greptile P2) and promoted the entry to Highlights,
matching every other credited entry in the file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CodeRabbit flagged that the hf_token_replace_warning text shared the
same `max-[560px]:hidden` class as the dismissable "add a token" pitch,
so a user replacing an already-active token on a narrow viewport (mobile
width, or a small first-run window) never saw the warning that doing so
clobbers the working token. Only the pitch should hide at that width —
the overwrite warning is safety copy and must always render. Added a
regression test asserting the warning's className never carries the
responsive-hide class.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tauri.conf.json sets decorations:false + titleBarStyle:"Overlay" on
every platform, so on macOS the native traffic-light cluster is drawn
on top of the web content instead of getting its own row; Windows and
Linux draw nothing there. The header's left block (status dot +
kicker) had no inset at all for that zone, so on macOS the traffic
lights sat on top of it.
Fix, macOS-only: detect macOS the same way HotkeyTab.jsx /
SettingsSearch.jsx already do (navigator.platform), and apply a new
.header-area__left--mac-inset class that completes header-area's own
16px left padding to the same flat 64px-from-window-edge total that
.header-area--tabs already reserves for the identical cluster. Windows
and Linux get no inset, so no space is wasted where nothing is
overlaid.
Fixes#1860.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Header.jsx's status dot animates via the hqPulse keyframes, applied as
a Tailwind arbitrary [animation:...] utility. None of index.css's
twelve @media (prefers-reduced-motion: reduce) blocks named hqPulse
(it isn't a stable CSS class, so those selector-based blocks can't
reach it), so the purely decorative pulse kept running with OS Reduce
Motion on.
Fix: append motion-reduce:[animation:none] to the dot's className -
the same mechanism LogsFooter.jsx already uses for its own
arbitrary-utility pulses (heart-glow, donate-pop-in).
Part B only, per the issue split - Part A, an in-app motion toggle, is
a product decision and stays open.
Fixes#1857 (Part B).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
App.jsx's post-onboarding "first sound" request appended a hardcoded
narrator prose string as `instruct`. Every engine's instruct is a
controlled vocabulary (OmniVoice's `_resolve_instruct` rejects
anything outside a fixed token list), so this 400ed on every first
run — silently, since the surrounding catch is deliberately silent
by design (a first impression must never surface an error).
Omit `instruct` entirely instead of swapping in valid vocabulary:
it matches every other call site in the app (`if (instruct)
fd.append('instruct', ...)`), matches the seeded demo profile's
empty stored instruct, and every engine backend already treats a
missing/empty instruct as "no styling" rather than a required field
— so this can't regress no matter which TTS engine is active.
Fixes#1853.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HfTokenCard.jsx unconditionally rendered the "add a free Hugging Face
token" pitch in first-run's Models & engines step, even when the
backend had already resolved and validated one (app/env/hf-cli). Since
Save persists via huggingface_hub.login(), which overwrites the
canonical $HF_HOME/token file outright, complying with the unnecessary
prompt could silently clobber an already-working token.
The card now checks GET /system/hf-token/state (the same resolver the
Settings -> API Keys panel already consumes) before rendering:
- an active, validated token shows the source + masked value instead
of the pitch
- replacing it requires an explicit "Replace..." click plus an inline
overwrite warning, rather than one blind paste-and-Save
- a still-loading check shows a neutral placeholder
- a failed check falls back to the pre-fix pitch rather than hiding
the card
Fixes#1851.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
current_accelerator() returns None on CPU-only builds (no accelerator
compiled in), so .type would crash. Use check_available=True and fall back
to 'cpu' when None. For dots_tts, also select fp32 on MPS since
DotsTtsRuntime is untested on MPS.
Addresses greptile P1 + coderabbit Functional Correctness review comments.
current_accelerator() returns None on CPU-only PyTorch builds (no
accelerator compiled in), so accel.type would crash. Use check_available=True
and fall back to 'cpu' when None.
Addresses greptile P1 + coderabbit Stability review comments.
Both Confucius4 and DOTS-TTS engine sidecars hardcode device selection to
`torch.cuda.is_available()`, which returns False on Ascend NPU, Intel XPU,
and other non-CUDA accelerators — causing the models to silently run on CPU
(in fp32) instead of the available accelerator.
Replace with `torch.accelerator.current_accelerator().type`, the
device-agnostic API that auto-detects CUDA, NPU, XPU, MPS, and CPU. MPS is
excluded for Confucius4 (upstream untested on Apple Silicon). dtype stays
bf16 for any GPU-class accelerator and fp32 on CPU.
Also verified on Ascend 910B (torch 2.14, torch_npu, 4 NPU):
before: cuda_available=False → device "cpu", precision "float32"
after: accelerator → confucius4 device="npu", dots_tts precision="bfloat16"
MOSS-TTS-v1.5 engine hardcoded device selection to
`device = "cuda" if torch.cuda.is_available() else "cpu"`. On an Ascend
NPU (torch_npu) host, `torch.cuda.is_available()` is False, so the whole
model silently runs on CPU in fp32 — never using the accelerator — even
though torch.accelerator reports `npu` and bf16 is supported.
Replace with the device-agnostic `torch.accelerator.current_accelerator()`
so any backend (CUDA / NPU / XPU / MPS) is picked up automatically. MPS is
still excluded (MOSS's upstream trust_remote_code modelling code is untested
on Apple Silicon); dtype is bf16 for any GPU-class accelerator and fp32 on
CPU.
Verified on Ascend 910B (torch 2.14, torch_npu, 4 NPU):
before: torch.cuda.is_available()==False -> device "cpu", dtype float32
after: accelerator -> device "npu", dtype bfloat16
"20~30초" is read aloud as a single number — OmniVoice says "이십삼" (23).
The separator never reaches the listener, so any written range is heard as
the wrong figure.
`normalize_text` only ran its number pass behind `_num2words_lang`, which
returns None for ko/ja/zh/th/vi (those scripts read digits natively and are
deliberately outside num2words). Nothing else looked at the range mark, so
the tilde went to the engine untouched and the two numbers ran together.
Rewrite `N~M` into the spoken form before the engine sees it, outside the
num2words gate so the CJK languages are covered too. Verified by rendering
each candidate and transcribing it back (ko, OmniVoice, cloned voice):
"대략 20~30초짜리" heard "23초" WRONG
"대략 20-30초짜리" heard "23초" WRONG (reproduces it)
"대략 20에서 30초짜리" heard "20에서 30초짜리" correct
"20〜30分ぐらい" → "20から30分" heard "20〜30分くらい" correct
Deliberately narrow:
* Only the tilde family (U+007E, U+301C, U+FF5E). Japanese and Korean IMEs
emit the latter two. An ASCII hyphen is left alone — between digits it
also spells dates, phone numbers and product codes, where "to" is wrong
(`tests` already pin "pages 3-5" as unchanged).
* Only languages with a verified spoken form (ko/ja/zh/en). Anything else
keeps its tilde, matching how `_PERCENT_WORD` is scoped.
* Spacing belongs to the form, not the caller: a Korean postposition binds
to its numeral ("20에서 30"), Japanese and Chinese set no spaces, English
needs them on both sides.
* Neighbour guards block digits and ASCII letters but allow CJK, because
CJK writes the unit hard against the digits ("20~30초"); a `\w` guard
rejects exactly the cases the rule exists for.
`ko`/`ja`/`zh` join `_FULL_NAME_TO_CODE` so the new resolver can see them.
They stay out of `_NUM2WORDS_LANGS`, so this does not open a num2words path
for them — the same inert-entry pattern the file already documents for
"vietnamese".
`backend/services/text_normalization.py` joins the functional-CJK allowlist
in tests/test_no_hardcoded_cjk.py, under the text-processing group and by
the procedure that file documents: the range words are engine input, not
user-facing UI strings.
Tests: 7 new change-cases and 8 new leave-unchanged cases (hyphen, date,
phone number, product code, decimals, a non-numeric tilde, an unverified
language, and no language at all). All 7 change-cases fail against the
previous implementation.
Full suites before and after: the same 17 failures, none of them touched by
this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`bun run desktop` dies before the window opens on a fresh clone:
Error: ENOENT: no such file or directory, open
'.../frontend/node_modules/@tauri-apps/plugin-dialog/dist-js/index.js'
The alias hardcoded `frontend/node_modules/...`, but this is a bun
workspace: bun hoists the package to the workspace root and leaves
`frontend/node_modules` empty, so the path the alias names does not
exist. Vite's dep optimizer reads it directly and throws, taking
`beforeDevCommand` — and the whole desktop shell — down with it.
Probe both layouts and fall through to Vite's own resolution when
neither is present, so a missing package degrades to normal resolution
instead of crashing the dev server.
Verified on macOS 26.6 (Apple Silicon), bun 1.2.22, fresh clone: the
window now opens and the backend serves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Most gallery previews fail with "the voice engine returned no audible
audio for this archetype". The renders are fine — the guard is not.
Two problems, both in the degenerate-buzz check:
1. `_spectral_flatness` took ONE FFT of the whole clip. Spectral
flatness is defined over short frames; a full-length transform gets
finer frequency resolution the longer the clip is, so voiced
harmonics carve deeper and deeper nulls and the geometric mean
collapses. The number tracked clip length, not timbre.
2. `_DEGENERATE_FLATNESS = 0.015` was calibrated against
`_speech_like()` in the unit test — a synthetic harmonics+noise
stand-in that is far flatter than real speech. Real renders measure
well below it, so the threshold sat inside the speech range.
Measured on this engine's own output (framed, per this patch):
pure tone 80 Hz 2.6e-10 two-tone buzz 3.3e-09
quietest real speech 2.0e-04 (VoxCPM2 ko)
Frame the measurement (1024/512, skipping inter-word frames at the
noise floor) and move the threshold to 1e-5 — ~3000x above the tonal
cases, ~20x below the quietest real render.
Before: 6 of 8 renders rejected; ml_japanese_explainer,
ml_japanese_companion and feat_23_the_explainer all 503 through
GET /archetypes/{id}/preview.
After: 0 false positives across 27 real clips (Japanese, Korean and
English archetypes, cloned voices, human reference recordings), and
those three previews return 200. Every accepted clip was confirmed as
real speech by transcribing it with the app's own ASR.
Not addressed: a render that collapses toward NOISE rather than a tone
still passes (one observed at flatness 0.073, ASR returns a
hallucination). The old threshold missed it too, so this is not a
regression — calibrating an upper bound needs more than one sample.
Tests: frame-based measurement must be clip-length invariant, and the
threshold must sit between the measured tonal ceiling and the measured
real-speech floor. Both fail against the previous implementation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Greptile's P1 on #1809, and it is right. `launch_backend_and_wait` holds
`BackendState::lifecycle` around the entire launch, including the readiness
wait — which this branch just made unbounded for as long as the backend
answers `/startup/progress`. Retry, Clean & Retry, reset and uninstall all
need that same lock, so on a slow start the user's own escape hatch would
block behind the wait instead of interrupting it: an app with no way out,
which is worse than the early kill the branch set out to remove.
Every flow that is about to take lifecycle ownership now bumps a generation
counter first, before reaching for the lock. The waiting loop snapshots that
counter once its caller holds ownership — so a bump that predates it is not
mistaken for a preemption — and stands down within one 500 ms poll when it
changes, releasing the lock for whoever asked.
That also settles what happens at the splash's six-minute stall budget: it
flips to failed and offers Retry and the logs, and Retry now actually works,
while its /health recovery poll still walks straight into the app if the slow
start finishes first. Either way the user gets out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HR6J9zKQop9TGGVUwjypnF
`_get_clone_prompt` catches everything and returns None so synthesis falls
back to `generate()`'s inline reference path. For a device OOM that is not a
fallback at all: the inline path runs the SAME encode on the SAME device —
producing identical output is the entire point of the precompute — so it is
guaranteed to hit the same wall moments later, on a GPU with even less
headroom than the first attempt found. Two reporters' backends died with a
Windows access violation (exit code -1073741819) seconds after this fallback
logged, mid-generation, on a card that had just refused an 86 MiB
allocation.
An OOM here is also the most recoverable kind. The allocator is typically
sitting on reserved-but-unallocated blocks — #1790's own log reports 90 MiB
reserved against that 86 MiB request — so drop them and try once more. If it
still will not fit, raise: the failure layer turns a device OOM into "close
other GPU-heavy apps or unload models, then retry", which is a far better
answer than walking into a native fault.
Every other failure still falls back silently, since for a non-memory fault
the inline path may genuinely succeed.
Fixes#1790.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HR6J9zKQop9TGGVUwjypnF
CodeRabbit's catch on #1799: git stores paths with `/` separators, so a `\`
that survives into a path component is part of a NAME. It is legal to commit
one from Linux or macOS and impossible to check out on Windows, where git
refuses it under `core.protectNTFS` — the same checkout-time failure, before
any test runs, that the stray `:memory:.ses` caused.
The rule moves into a pure `windows_hostile_reason` so it can be exercised
directly: the repo cannot carry a fixture for each hostile shape without
becoming the very thing the test rejects. Both directions are pinned — every
shape Windows refuses, and ordinary paths that merely resemble one (a file
called `console.md`, `com10.py`, a component containing but not ending in a
dot).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HR6J9zKQop9TGGVUwjypnF
Two Apple Silicon reporters were told "it most likely crashed or was killed
mid-request" while generating. Neither bug report carried a crash marker,
because none had been recorded — the app had no evidence for the one thing
it asserted, and the advice that follows that sentence is Retry and Clean &
Retry, which rebuilds the whole Python environment to fix a backend that had
not died.
Two causes, both fixed here.
The desktop shell learns the backend died from a ~2 s poll: it has to notice
the child exit before it can write the marker. `apiFetch` asked for that
marker exactly once, at the instant the transport gave up, so it raced the
poll and lost either way round — a backend that really died was reported
with the vague sentence instead of its exit code and crash notice, and one
that never died was reported as dead anyway. `streamDropError` already waits
that poll out (#1119); the request path never did. The loop is now a shared
`awaitBackendCrashMarker`, used by both, with a shorter budget here because
the transport cascade has already cost the user a few seconds.
And the copy itself overshot what it could know. By construction it is
reached only once a crash has been looked for and not found, so it no longer
names one: it says the backend stopped answering with no crash recorded, and
that a heavy job holding the engine is the likelier story — which on a
memory-pressured Mac mid-generation it is. Updated in all 21 locales, since
a translation still asserting a crash would be the same bug in another
language.
The #1337 test that required the crash wording is updated with it: #1337
established that the backend had answered seconds earlier, not what silenced
it, and requiring the stronger claim is what pinned this in place.
Fixes#1802.
Fixes#1805.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HR6J9zKQop9TGGVUwjypnF
The launcher waited a flat five minutes from spawn for the backend to
report ready, then killed it and tried again. On a host where the cold
start genuinely takes longer — the reporter's project lived on a mapped
network drive, and `import torch` off one is slow the first time, as is a
first CUDA load or a cold spinning disk — that deadline expired *while the
backend was still importing*. The respawn threw away the warm page cache
and raced the same clock, so the app could never start, and it blamed the
backend: "the backend never reported ready". Launching that same backend by
hand reached ready in well under a minute once the cache was warm.
A backend answering `/startup/progress` with `status: "starting"` is not
one we have to guess about: it bound its socket, it is serving HTTP, and it
is naming the step it is on. Killing it cannot make the retry faster, and
the launcher knows nothing the user doesn't. So keep waiting while it
answers, and keep narrating each step. The budget still governs silence —
nothing answering, or a self-reported `failed` — where a slow backend and a
wedged one really are indistinguishable and the existing stderr-tail
failure is the right answer.
The splash needed the same correction. Its stall watchdog keys on
`bootstrap_status`, which sits on `starting_backend` for the whole of a slow
start, so it would have called the launch stuck at six minutes anyway; the
proof of life arrives on the separate `bootstrap-log` stream. Output now
counts as activity, and a genuinely silent backend still trips the watchdog
so the info-less spinner of #879 stays fixed.
Fixes#1791.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HR6J9zKQop9TGGVUwjypnF
A stray sqlite session artifact named `:memory:.ses` was committed by
accident on this branch. Git on Windows rejects a path containing `:` with
`error: invalid path` and exits 128 during **checkout** — so both Windows
jobs went red before a single build or test step ran, pointing at a file
nobody had edited, while Linux and macOS stayed green.
Drop the file, ignore the `*.ses` artifact class, and add a guard that scans
the index on every platform for paths Windows cannot represent: illegal
characters, components ending in a space or dot, and reserved DOS device
names. The failure now surfaces as a named test on every runner instead of
as a checkout crash on one leg of the matrix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HR6J9zKQop9TGGVUwjypnF
Third review round on the PR, both findings in the new test file.
CodeRabbit: the disconnect regression called deadlines.for_task directly, so it
would have passed even if Scheduler._budget_for stopped coercing a missing
worker to the CPU budget -- the very thing it exists to pin. It now builds a
real WorkerPool and Scheduler, assigns the task to the 4 GB worker, asserts the
bound budget is the CPU one, disconnects the worker and asserts the
recomputation is not shorter. Forcing under_provisioned=False in _budget_for
fails it with `assert 300 == 600`.
CodeRabbit: the two env-var tests deleted the variables and reloaded
model_manager inside a finally, which runs BEFORE pytest restores them -- so on
a machine that already exports either var, the module constants would describe
an environment pytest was about to put back, and every later test would read
the mismatch. Both use monkeypatch.context() now, so the environment is restored
before the reload.
Second review round on the PR.
CodeRabbit: the repo-wide dispatch assertion accepted any nested min_vram_gb
keyword, so a budget computed with 0 or another engine's floor would pass while
the guard used the right one. It now compares the two expressions.
CodeRabbit: the awaiting-side deadline test restated gpu_gateway's formula
instead of calling it, so it would not have noticed that function starting to
select a shorter ceiling. It calls _default_deadline now, on cuda and rocm.
CodeRabbit: the docs said an explicit OMNIVOICE_GENERATE_TIMEOUT_S is honoured
"everywhere" while also saying the CPU var governs under-provisioned cards --
the two cannot both be true. Verified against the code (both vars set, 4 GB
cuda, engine floor 6 GB -> 200s, the accelerated value) and documented as a
precedence table rather than prose. The accelerated var deliberately wins on an
under-provisioned host: that is what keeps "lower it to fail fast everywhere"
working. Pinned by a test so the table cannot drift from the behaviour.
CodeRabbit also flagged that Scheduler._budget_for recomputes with no worker
after a disconnect, dropping under_provisioned to False. That cannot shorten
anything: no worker means no execution_device, which _base_execution_seconds
already coerces to "cpu" -- the same budget the floor raises an
under-provisioned card to. Added a test pinning that rather than persisting a
dispatch-time budget on the attempt. The residual case it describes -- an
operator who raised the accelerated budget ABOVE the CPU one sees a shorter
recomputation once the worker is gone -- predates this change and applies to
every GPU worker, not just under-provisioned ones, so it belongs in its own fix.
Review findings on the PR, fixed here rather than left for a fourth report.
Greptile (P1): the control plane sets a remote attempt's deadline, so the same
inversion reached remote workers. Its suggested fix -- thread the engine floor
into generate_timeout_s() -- would read the wrong machine: that function probes
THIS host, so a Mac control plane dispatching to a 4 GB Windows worker learns
nothing (MPS is excluded by design), and a 4 GB box dispatching to a 24 GB
worker would wrongly get the longer budget. The worker already advertises both
figures it takes -- free_memory_bytes and min_memory_bytes, both set in
worker/capabilities.py -- so ConnectedWorker.under_provisioned() decides from
those, and deadlines.for_task() floors the execution budget at what the same job
would get on a CPU. The task-level ceiling in gpu_gateway._default_deadline is
computed before a worker is bound and already asks for the CPU budget, so it
still covers the raised lease; a test pins that.
CodeRabbit (major): /convert had the identical split -- min_vram_gb to the
guard so a timeout could name the card, and a budget computed without it.
CodeRabbit (minor): the docs promised the CPU-class floor for any GPU, while
the code scopes it to dedicated-VRAM families. Reworded to say CUDA/ROCm and to
say why MPS is excluded.
CodeRabbit (minor): the call-site assertion compared global occurrence counts,
so one dispatch could drop both arguments while another gained an extra and the
total still matched. It now walks the AST and checks each dispatch on its own,
and the pairing is additionally enforced repo-wide across backend/api/routers:
a dispatch that knows the engine's floor well enough to explain a timeout must
know it well enough to set the budget.
Three inline capability-selection loops in ConnectedWorker collapse into one
_capability_for(), so the new predicate cannot select a different capability
than execution_device() does.
A GPU with less VRAM than the engine declares it needs pages to system RAM
over PCIe, so it renders slower than the same machine's CPU. The compute-time
budget picked its value from the device family alone, so that card was treated
as fast hardware and given 300s -- half the 600s a plain CPU host gets. It is
the slowest configuration the app supports and it had the shortest watchdog.
Everything else already acted on the verdict. resolve_routing() raises the
caveat, the synth preflight warns before the user waits, and _timeout_guidance()
names the card in the failure. Each TTS generate dispatch even hands the guard
the engine's floor on the line above the timeout that ignored it. #1226 and
#1222 were the same 4 GB cards on the same engine; both were closed by making
the app explain the timeout better, never by correcting the budget behind it.
generate_timeout_s() now floors an under-provisioned accelerator at the CPU
budget. The length scaling is unchanged, and an explicitly configured
OMNIVOICE_GENERATE_TIMEOUT_S is still honoured verbatim, so an operator who
lowered the watchdog to fail fast keeps that. The floor is a max(), never an
assignment, so a raised accelerated budget is never cut down. Engines that
declare no floor, a failed VRAM probe, and MPS (whose vram_gb is a unified-
memory heuristic, not a dedicated pool) are all untouched.
The three-clause "is this host under-provisioned" test was written out inline
in the caveat and in the timeout message, which is how the budget came to
disagree with the warning printed beside it; it is now one predicate,
under_provisioned_vram(), that all three read.
Reported on a GTX 1650 (4 GB) running the omnivoice engine, whose breadcrumbs
show the budget ending the job on the dot: 372s and 301s are exactly
300 + max(0, len - 1200) / 40 for the two takes.
Fixes#1804.
An OpenAI-compatible ASR answering in json/text format returns no
timestamps, and services/asr_backend.py records that honestly as
`end: None` rather than inventing a number. The segment list called
`.toFixed()` on it unconditionally, so the render threw and the whole
Transcriptions view went blank — a transcript that merely lacked timings
became one the user could not read at all.
Show whichever bound is known and nothing when neither is, so the text
stays readable either way. Non-finite values are treated as unknown too,
so a bad timing prints nothing rather than NaN.
Fixes#1798.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HR6J9zKQop9TGGVUwjypnF
2026-09-04 03:26:51 +05:30
440 changed files with 32889 additions and 6865 deletions
@@ -85,14 +85,20 @@ names: there is no `desktop=prod` (note the **hyphen** in `desktop-prod`).
Requires [Rust](https://rustup.rs/) and platform-specific Tauri dependencies — see the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
After installing Rust with rustup on macOS/Linux, either open a new terminal or
load Cargo into the current one before starting the desktop app:
After installing Rust with rustup (or `uv` with its installer), a terminal that
was already open still has the old `PATH`. The desktop launchers (`bun desktop`,
`bun desktop-prod`, `bun desktop-fresh`) detect this and add `~/.cargo/bin` /
`~/.local/bin` for that run, printing a one-line note; to make it permanent,
open a new terminal, or on macOS/Linux load Cargo into the current one:
```bash
source"$HOME/.cargo/env"
bun desktop
```
If Rust is genuinely not installed, the launchers stop up front with the
install command instead of failing later inside `cargo metadata`.
On Linux, errors such as `Package gdk-3.0 was not found`, `pango.pc` missing,
or `javascriptcoregtk-4.1` missing mean the native packages above were not
installed; changing `PKG_CONFIG_PATH` does not fix libraries that are absent.
@@ -33,6 +33,11 @@ Binding for every AI agent (Claude, Codex, Cursor, review bots, …). CLAUDE.md
-`frontend/package.json` dep changes require regenerating root `bun.lock` (Docker runs `--frozen-lockfile`).
- Issues: absorb or decline — never defer to a future version. Check the open-PR queue before implementing community-reported fixes.
## Shared select controls
- Use `frontend/src/components/SearchableSelect.jsx` for all new or redesigned select boxes. Reuse `VoiceSelector` for voice choices. Do not introduce native `<select>` controls.
- Provide a localized `ariaLabel`; use `menuPortal` inside scrolling or clipping containers. Preserve keyboard selection and disabled states.
## Agent skills
Project development skills are pinned in `skills-lock.json` and installed under
@@ -8,25 +8,58 @@ the frozen-backend fallback mirror it for their toolchains.
## [Unreleased]
## [0.5.2] — 2026-09-10
**Highlights**
- Fix current-user Windows installer validation and nested resource cleanup (#730)
- Supertonic-3 and PocketTTS show their license Accept button again, so they can be enabled (#2017)
- An engine that can't run on your platform says so, instead of telling you to install it (#2018)
- MOSS-TTS-v1.5, Confucius4-TTS, dots.tts, Supertonic-3 and PocketTTS install in one click, each in its own environment, so switching engines and back never breaks a working one (#2015, #2016)
- A pronunciation entry that is stored but not applied yet says so, instead of looking like it did not match (#1949)
- A bare 500 report now names the backend error class, so two unrelated faults stop filing the same issue (#1773)
- A rejected dubbing source language now names the code it rejected (#1960)
- The first-run install log is kept on disk instead of vanishing with the setup screen (#1847)
- `bun run desktop` reclaims port 3900 from a backend the app itself left running, instead of refusing to start (#1974)
- A dictation shortcut another app already owns now says so, instead of silently doing nothing (#1858)
- Quitting on Windows is no longer reported as a crash on the next launch (#1898)
- A Reduce motion switch in Settings, for calm without changing your whole system (#1857)
- A light theme, and System Auto now follows a light-mode OS instead of staying dark (#1973) — thanks @CoDe-ReDz!
- Generating from a one-character input now says the input was too short, instead of quoting a convolution error (#1826)
- First run asks about text size before the install, not after it (#1849)
- Cloning without a reference clip now says so, instead of naming library parameters you cannot set (#1879)
- Upgrading torch for an RTX 50-series card no longer trades one startup crash for another, and the upgrade is documented (#1931)
- A generation timeout now points at the compute-time budget in Settings rather than an environment variable (#1808)
- An engine you have not installed now says so, instead of reporting a failed check (#1866)
- The Accessibility prompt no longer floats over first-run setup and every other app until you grant it (#1845, #1886)
- The last onboarding step offers to install a speech-to-text model instead of failing three times when none is installed (#1856)
- A download that fails because the folder sits behind a mount point Windows will not cross now says so, and where to move it (#1957)
- A GPU that is merely short on free memory is no longer told to reinstall its drivers (#1812) — thanks @michaelhuamanflores!
- An error thrown by a browser extension is filtered on Safari and the macOS app too, not only on Chromium (#1901) — thanks @Chang-Jin-Lee!
- Choosing the China mirror no longer re-races the network on every dependency step, which cost seconds per step on blocked connections (#1892) — thanks @yuezheng2006!
- The backend log panel reports a log it cannot read instead of quietly showing less (#1847) — thanks @Chang-Jin-Lee!
- The floating dictation bubble adds pause, resume, stop, close, and a multiline preview (#1952)
- Transcriptions checks model readiness and offers an inline download and shortcut hints (#1952)
- Transcriptions' missing-model prompt lists every dictation model by accuracy vs latency, languages and size, so you install the one that fits — or switch to one already on disk (#1952)
- The Engines menu's Transcription tab picks the dictation model under Sherpa-ONNX, and that choice now also drives Sherpa transcription (#1952)
- A failure with no stage attached no longer borrows another stage's advice, so a text-to-speech error stops telling you the video server dropped the download (#1943)
- A generation failure that the app cannot classify now names the backend error class, so two unrelated faults stop arriving as the same untriageable report (#1800)
- Transcriptions dictation wakes the desktop recorder, presents one contextual start action, and centers its microphone icon with the label (#1902)
- Colab transcription and dubbing now include an explicit ASR model setup step (#1922) — thanks @nidhi-singh02!
- Apple Silicon now shows one canonical OmniVoice choice in the engine picker while retaining its automatic crash-isolated sidecar runtime (#1913)
- Validate current-user Windows installers under a standard account on hosted runners (#1883)
- Model downloads survive a flaky connection instead of restarting from zero (#1940)
- `bun run dev` recovers on Windows instead of demanding Task Manager (#1941)
- The desktop app builds and opens from a fresh clone again (#1818) — thanks @flutterkage2k!
- GPUs with less VRAM than the engine needs no longer get half the compute-time budget a CPU gets (#1806) — thanks @VishvakR!
- Gallery voice previews play again — the quality guard was rejecting good renders as silent (#1819) — thanks @flutterkage2k!
- Tilde-separated number ranges are spoken clearly without running their endpoints together (#1821) — thanks @flutterkage2k!
- Voice modes use themed tabs, with Synthesize and Convert pinned below their scrolling forms (#1823)
- Fix current-user Windows installer validation and nested resource cleanup (#1873)
- Keep generated frontend assets available while building the current-user Windows installer (#1881)
- Voice cloning now starts with a clear upload-or-record choice, reveals recording and reference details only when needed, and keeps sampling controls under Production Overrides (#1817)
### Changed
### Added
### Docs
### Fixed
## [0.5.2] — 2026-09-02
**Highlights**
- The first-run welcome line uses an instruction accepted by OmniVoice and VoiceDesign engines (#1861) — thanks @psiberfunk!
- audio.cpp joins the engine lineup as an opt-in CPU backend for Breeze-TTS-2 (English + Chinese, clone + voice design, explicit Model Catalogue install, no Python venv) (#1891)
- audio.cpp uses installed native CUDA, HIP, Metal, and Vulkan providers and preserves device routing across remote workers (#1926)
- Show estimated and measured model, dependency, cache, and temporary disk costs in the engine catalogue (#1718)
- Preview builds now stay newer than Stable even when automatic post-release version bumps are disabled (#1762)
- CosyVoice setup guidance now separates downloaded model files from the runtime that makes the engine available (#1761)
@@ -40,6 +73,27 @@ the frozen-backend fallback mirror it for their toolchains.
### Changed
- Tauri 2.11.5 with refreshed plugins (dialog, updater, log, opener, positioner, single-instance), React 19.3, TanStack Query 5.102, lucide 1.43, posthog-js 1.428, and the rest of the npm workspace on current minors; jsdom 30, jest-dom 7, concurrently 10, taze 21 (#1952)
- eslint ignores `src-tauri/`, so a local Tauri build no longer floods `lint:hooks` with parse errors from generated assets (#1952)
- Casting uses responsive SVG voice cards and searchable speaker menus that stay above surrounding panels (#1823)
- Dubbing aligns output settings, brings review status forward, and simplifies transcript and glossary editing; Launchpad files and voices reflow into responsive grids (#1823)
- Transcript segments use three readable rows for text, timing/status and voice controls, with heights that adapt to wrapping (#1823)
- Dragging the waveform pans horizontally while a click still seeks, keeping the timed transcript aligned (#1823)
- Bulk segment editing uses searchable voice and language menus, readable language names and a responsive selection toolbar (#1823)
- Dubbing overlays playback controls on video, combines waveform and transcript in a compact timeline, and removes header/action background fills (#1823)
- Dubbing uses compact casting, translation and output controls with responsive rows to leave more room for editing (#1823)
- Export uses grouped format settings, themed track menus and switches, with a pinned filename summary and download action (#1823)
- Dubbing output settings use icon-labelled switches, themed track and speaker menus, and clearer timing/transcript controls (#1823)
- Casting voice menus use searchable themed options with SVG preset icons instead of native dropdowns (#1823)
- Dubbing groups casting and translation controls with readable labels, SVG icons, searchable menus, and compact timeline spacing (#1823)
- Production Overrides use readable icon-labelled controls and accessible Denoise/Postprocess switches (#1823)
- Expanded navigation uses a theme-accent tint with subtle static wave gradients (#1823)
- Convert groups source audio, target voice, and timing options into clearer controls; design choices include theme-matched SVG icons (#1823)
- The expandable sidebar reveals workspace labels with restrained active states; language menus adapt to multiple columns on wider screens (#1823)
- Voice design and recording use themed, keyboard-accessible selectors with clearer spacing and labels (#1823)
- Voice tabs and upload/record controls have subtle SVG motion; Text adds clipboard paste and the upload area fills available height (#1823)
- The title-bar label cycles through active speech, transcription, and LLM engines; bundled model labels correctly say OmniVoice (#1823)
- The top-bar Engines panel groups Speech, Transcription, and LLM choices into tabs, with compact memory controls and no duplicate pickers (#1823)
- Voice Design simplified: the 12-row fine-grained block collapses to one summary line with a five-field editor, English accent and Chinese dialect merge into a single field, and the starting-point chips now show 5 with an overflow toggle (#1793)
### Added
@@ -55,6 +109,11 @@ the frozen-backend fallback mirror it for their toolchains.
### Docs
- PowerShell Docker setup now generates the administrator key without requiring Python on the host (#1993) — thanks @yangfan-yf-yf!
- The torch upgrade an RTX 50-series card needs is written down, with the second pin file the resolver checks and the command that proves the kernels are there (#1931)
- Docker quick starts now explain the AMD64-only images and direct Apple Silicon users to the native macOS app (#1921) — thanks @yangfan-yf-yf!
- audio.cpp (Breeze-TTS-2) is now a documented opt-in engine: prebuilt binary install, explicit GGUF download, voice modes, and the weights' research/non-commercial terms (#1891)
- `docs/STRUCTURE.md` describes the tree as it is today, and a test now keeps its counts honest (#1981) — thanks @Dawcraft!
- Local gigastt is now documented as a supported OpenAI-compatible ASR endpoint, with loopback privacy distinguished from remote servers (#1736) — thanks @ekhodzitsky!
- The CosyVoice guide now states that packaged builds have no one-click runtime installer and records the exact readiness checks exposed by [Discussion 1631](https://github.com/debpalash/VoiceStudio/discussions/1631) (#1761)
- A production private-API guide now covers pinned containers, root credentials, network isolation, streaming proxies, health checks, upgrades, and benchmark evidence (#1720)
@@ -62,6 +121,82 @@ the frozen-backend fallback mirror it for their toolchains.
### Fixed
- One-click engine installs no longer inherit VoiceStudio's own PyTorch pin, which made MOSS-TTS-v1.5 and Confucius4 impossible to install (#2024)
- Uninstalling a translation engine no longer removes a package VoiceStudio or another engine still needs (#2019)
- Closing the dictation pill on Windows removes it from the screen: an empty dark rectangle used to stay there, always on top, until the app was quit (#2009)
- The dictation pill on Windows no longer sits inside a bordered card wider than the pill itself (#2009)
- Dictation uses the model you picked instead of one remembered from before the backend started, so it stops reporting no speech-to-text model while one is installed — and when none is, the main window offers the download (#2012)
- The remote-worker loop-responsiveness tests no longer turn a build red over milliseconds of scheduling noise on shared CI hardware (#1990)
- Remote GPU workers work when the machine running VoiceStudio is on Windows: a staged input is now identified the same way on every operating system, instead of with a path only Windows can read (#2005)
- The pronunciation list badges an IPA or CMU entry as not applied yet, so you can see it without running a test (#1949) — thanks @utkarsha741!
- A remote-worker test no longer fails at random on Windows CI: it waited for a background thread by spinning the event loop that thread's work needed (#1990)
- The isolated backend test session passes on a stock Windows checkout, and CI now runs it there so it stays that way (#1990)
- Windows contributors can run the test suite without Developer Mode: tests that create a symlink now skip instead of failing with `WinError 1314` (#1990)
- The crash details dialog now says what the exit code means and what to try, instead of showing a raw number and a log (#1927)
- A crash report now carries the backend's actual last words: the log tail is captured after the dying process's final output lands, not the instant it exits (#1850)
- The first-run setup screen no longer mislabels a step when the bootstrap restarts itself: Rust now says which attempt each stage and log line belongs to, instead of the screen guessing from a once-a-second poll (#1900)
- A port-3900 conflict now names who is actually holding it, and gives the command that ends an orphaned backend, instead of telling you to quit an app that has no window (#1933) — thanks @Chang-Jin-Lee!
- Windows desktop launches no longer freeze at "Loading ML runtime (PyTorch)": the parent-liveness watchdog polls the stdin pipe instead of leaving a read pending, which deadlocked numpy's OpenBLAS initializer (#1952, #1955)
- `bun desktop-prod` and `bun desktop-fresh` find Rust and uv from a terminal opened before they were installed, as `bun desktop` already did; a missing Rust toolchain fails up front with the install steps (#1952)
- Voice synthesis progress no longer races to a fabricated 95%; it stays indeterminate until the active generation path reports real progress (#1907) — thanks @psiberfunk!
- The Backend log tab keeps showing history across a log rollover, instead of going nearly empty until new lines arrive (#1920)
- Clearing the logs now empties the rotated log files too, so it frees the space it appears to (#1920)
- An error thrown by a browser extension no longer offers to file itself as a VoiceStudio bug (#1901)
- Clearing the desktop logs no longer wipes the backend's stderr, which is the only record a native crash leaves behind and is meant to survive a respawn (#1510)
- Long audiobook chapters now use the same device- and text-length-aware synthesis timeout as other TTS routes (#1910) — thanks @psiberfunk!
- Interrupted audiobook renders can resume cached chapters after tab navigation, and their chapter cache is available from the recovery card (#1911) — thanks @psiberfunk!
- System-check details and storage paths beginning with a number or a slash no longer render with their leading text moved to the end of the line (#1848) — thanks @psiberfunk!
- An unavailable engine's row now links to that engine's guide, so the generic "check installation and configuration" message has somewhere to send you (#1866) — thanks @psiberfunk!
- The backend log now records which engine failed a health check and whether its probe raised, instead of a line that identified neither (#1866) — thanks @psiberfunk!
- The first-run Activity log counts every line instead of freezing at 200 while the install is still running, and Copy now hands back the whole run rather than the last 200 lines (#1847) — thanks @psiberfunk!
- A first-run failure that happened early in a long install keeps its specific advice, instead of falling back to the generic retry hint once the log scrolled past 200 lines (#1847) — thanks @psiberfunk!
- Opening the log panel no longer clips the Launchpad's heading and slides the feature cards up over it — the page scrolls instead of squashing itself (#1859) — thanks @psiberfunk!
- Segmented model downloads split files into 16 MB ranges instead of one range per connection, so a dropped connection refetches one range rather than restarting the file (#1940)
- The download accelerator is kept across retries after a transient network failure and resumes from its manifest, instead of falling back to a from-zero `snapshot_download` (#1940)
- `dev-backend.mjs` stops the backend by process tree on Windows, so an orphaned uvicorn no longer holds port 3900 and turns a source reload into three phantom crashes (#1941)
- `clear-dev-ports.mjs` can free a stuck development port on Windows again, bound to the inspected process instance so a recycled pid is never terminated (#1941)
- Checkout-ownership matching no longer resolves POSIX paths with the host's separator, which made the guard's own test fail on Windows (#1941)
- Install documentation help now prints correctly on Windows consoles using legacy encodings (#1815) — thanks @dajiaohuang!
- Saved transcriptions with missing or invalid timestamps now remain readable (#1799) — thanks @yunaremaia and @tvbht!
- Transcribing with an engine that reports no segment end no longer fails with a server error; the null timing is passed through the way the segment list already expects (#1904) — thanks @aeroglu!
- Copying a saved transcription now uses the shared clipboard helper and reports failed copies accurately (#1803) — thanks @tvbht!
- Voice reference preparation reclaims allocator memory before one bounded retry, then reports persistent GPU out-of-memory failures (#1811)
- `bun run desktop` now opens on a fresh clone: the Vite alias for `@tauri-apps/plugin-dialog` no longer assumes a nested `frontend/node_modules`, which bun's workspace hoisting leaves empty (#1818) — thanks @flutterkage2k!
- Slow backend startups remain running with progress updates, and Retry interrupts startup without stale timeout failures (#1809)
- Backend connection errors report crashes only when recorded evidence exists, and diagnostic waits honor cancellation (#1810)
- A CUDA or ROCm GPU with less VRAM than the engine needs now gets the CPU compute-time budget instead of the shorter accelerated one, since it pages to system RAM and renders slower than the CPU would — applied to local generation, voice conversion, and remote worker deadlines alike (#1806) — thanks @VishvakR!
- Gallery previews no longer fail with "the voice engine returned no audible audio" on perfectly good renders: the degenerate-buzz guard measured spectral flatness over the whole clip (so the value tracked clip length) against a threshold calibrated on a synthetic signal, and rejected real speech in every language tested (#1819) — thanks @flutterkage2k!
- Speak tilde separators in integer, signed, and decimal ranges in English, Korean, Japanese, and Chinese (#1821) — thanks @flutterkage2k!
- Keep recording and conversion work safe while switching methods, synchronize dubbing language controls, and localize timeline controls and timing warnings (#1841)
- Audiobook is now a Write → Cast → Produce tab workspace matching the voice workspace, with the warnings/progress/result rail pinned below (#1841)
- Gallery uses a workspace header with zone tabs, hairline section dividers, theme-token cards, and borderless import rows (#1841)
- Gallery cards reset native button faces, cluster icon actions in the header so Use voice never wraps, and use a roomier grid floor (#1841)
- Gallery filters gain name search, removable iconified pills with clear-all, and dimension icons on every facet (#1841)
- Dubbing playback starts before waveform decoding, automatic cast names are readable, and transcript timestamps have more room (#1823)
- The title-bar engine button stays compact and stable while cycling labels, with engine names aligned right (#1823)
- Long dubbing segment errors wrap in a bounded scrollable notice instead of widening the editor (#1823)
- Voice dropdowns match their field width, use theme accents, and show recent voices only once (#1823)
- Language menus no longer show a pale frame around their search header (#1823)
- The notification count stays inside the title bar instead of clipping above the bell (#1823)
- The workspace engine menu opens beside its button instead of at the opposite edge of the page (#1823)
- Cloning reuses the dubbing language picker with flags, search, and single selection, opening above the pinned synthesis controls (#1823)
- The first-run welcome line uses an instruction accepted by OmniVoice and VoiceDesign engines (#1861) — thanks @psiberfunk!
- The header status dot now honors OS Reduce Motion instead of pulsing regardless (#1862) — thanks @psiberfunk!
- Onboarding reads Hugging Face tokens locally, preserves Windows CLI logins, and requires successful discovery before replacing saved credentials (#1852) — thanks @psiberfunk!
- The logs panel no longer reports “All clear” before log retrieval succeeds or while logs contain warnings or errors (#1870) — thanks @motodriver!
- MOSS accelerator routing and status match runtime selection, with CPU fallback when device probing fails (#1830) — thanks @li-lizhe!
- On macOS, the header status dot and kicker no longer render underneath the overlaid traffic lights (#1863) — thanks @psiberfunk!
- The capture widget can hide after recording and recover from being left visible while idle (#1865) — thanks @psiberfunk!
- macOS retains the shared desktop window sizing, resize limits, and file-drop behavior when native chrome is applied (#1865) — thanks @psiberfunk!
- On macOS, the header no longer shows Windows-style minimize/maximize/close buttons alongside the native traffic lights (#1865) — thanks @psiberfunk!
- Release retries replace their own partially uploaded installers without colliding with existing assets (#1871)
- Timed-out voice engines finish process cleanup before retrying, and old timeout callbacks cannot kill replacement engines (#1872)
- Fast macOS process exits no longer turn a completed shutdown into a permission error (#1809)
- The bootstrap splash no longer shows fabricated first-run install steps on a warm start or repair sync — a step now renders done only once it was actually observed (#1894)
- A deliberate, clean quit killed by the desktop shell's short shutdown grace no longer gets reported as a crash on next launch — the run sentinel now clears before the slower shutdown steps instead of after (#1895)
- Model Catalogue engine rows stack into one column on narrow shells instead of clipping actions off-screen (#1891)
- Simplified Chinese locale completed: all 486 missing keys translated and the parity ratchet tightened to zero (#1877) — thanks @yearth!
- The generation compute-time budget is now a Settings control (Performance & Device) instead of an env-var-only setting the timeout error recommended with no UI path — the error copy points there too, and long CPU/MPS renders get an upfront heads-up before they start (#1787)
- Windows: the backend can now start when the install path contains non-English characters (e.g. a CJK username) on a non-UTF-8 system code page — a new or broken Python environment now builds at an ASCII-safe path automatically (a healthy existing one is never relocated), and a specific error message names the cause and a working fix if the interpreter still crashes in `site` (#1783)
- Exports and other native-picker actions no longer 403 with "Invalid or expired desktop authorization" when the desktop app and backend resolve different data directories, e.g. dev mode or a custom data folder (#1781)
The Voice workspace starts with three tabs: **From audio** for cloning, **By design** for creating a voice, and **Convert** for speech-to-speech conversion. Each tab displays its own workflow, with Synthesize Audio or Convert pinned below the scrolling form. The top-bar **Engines** panel combines engine selection, loaded models, and unload/flush controls; <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd> opens it. The searchable language picker shares Dubbing’s flags and language list layout, selects one output language, and retains Auto and the full cloning catalogue. Language options flow into multiple columns when space allows. Expand **Workspaces** in the sidebar to reveal navigation labels; Escape collapses it.
Dubbing starts with file upload or URL import and nearby language choices. Its **Projects** panel lists previous dubs so they can be reopened by clicking anywhere on a card; action buttons operate independently. Advanced import options include captions and optional YouTube sign-in. Dubbing places playback controls over the video with background blur and combines the waveform and timed transcript in one compact editing surface. Drag the zoomed waveform left or right to pan; click to seek. Translation language and ISO-code controls stay synchronized; Auto clears any previous language code and dialect. Transcript items group editable text, timing and status, and voice controls into three readable rows that wrap with the panel width. Output Options stays compact with the active settings shown in its summary; expand it to change output, timing, or voice matching. Transcript, glossary, and paste controls share a toolbar above the segment editor. Project details, workflow steps, and Generate/Verify/Export actions use an unfilled header.
The Audiobook Script editor fills the available workspace beneath its markup toolbar; Voices and Book settings stay in their own tabs.
Output settings use aligned rows; review status appears before the collapsible transcript and glossary. Glossary terms have labelled entry fields and an explicit edit action. Launchpad arranges recent files and saved voices side by side when space allows, with responsive card grids and visible Open actions.
The casting board shows icon-based voice cards and searchable selectors for each speaker. Drag a card onto a speaker or choose a voice from that speaker’s menu.
<a id="install"></a>
## Install
@@ -68,7 +78,7 @@ Download a package from the [latest release](https://github.com/debpalash/VoiceS
| macOS 13.3+ | Apple Silicon DMG | [Install on macOS](docs/install/macos.md) |
| Windows 10/11 | x64 MSI; choose the current-user build when listed to install without admin access | [Install on Windows](docs/install/windows.md#install-pre-built-msi) |
| Linux | AppImage, x86_64 with glibc 2.39+ | [Install on Linux](docs/install/linux.md) |
| Docker | CUDA, ROCm, CPU, and worker-only GPU profiles | [Run with Docker](docs/install/docker.md) |
| Docker | Linux/AMD64 images; CUDA, ROCm, CPU, and worker-only GPU profiles | [Run with Docker](docs/install/docker.md) |
First launch creates a managed Python environment and downloads the default model. Later launches reuse both.
@@ -77,6 +87,11 @@ First launch creates a managed Python environment and downloads the default mode
### Quick Docker run
The published images are **`linux/amd64` only**. On Apple Silicon, use the
[native macOS app](docs/install/macos.md) for GPU acceleration. ARM64 hosts
should read the [architecture requirements](docs/install/docker.md#architecture)
before pulling an image.
```bash
docker run -d -p 127.0.0.1:3900:3900 -v omnivoice-data:/app/omnivoice_data --name voicestudio palashdeb/omnivoice-studio:stable
```
@@ -129,7 +144,7 @@ The desktop launcher configures Python dependencies on first run via `uv` automa
|---|---|
| **Voice Cloning** | Zero-shot synthesis from a short reference clip ([guide](docs/engines/README.md)) |
| **Voice Design** | Create a voice from age, accent, pitch, style, and delivery instructions ([expressive speech](docs/expressive-speech.md)) |
| **Video Dubbing** | Transcribe, translate, preserve speakers, synthesize, and export video ([export guide](docs/dubbing/export.md)) |
| **Video Dubbing** | Transcribe, translate, preserve speakers, synthesize, and export video; compact translation settings include track selection, and completed dubs flag timing issues for review ([export guide](docs/dubbing/export.md)) |
"TRANSFORMERS_IMPORT":"Your transformers install is incomplete, or a package it loads models through (torchaudio, torchvision) is missing or mismatched with your torch — a torch/torchvision version mismatch fails with exactly this wording. Reinstall them together at the pinned versions (`uv pip install --python .venv --reinstall torch==2.8.0 torchaudio==2.8.0 torchvision==0.23.0 transformers` in the project folder), then restart the backend. If only transcription is affected, switching ASR to faster-whisper (Model Catalogue → Models) also works around it.",
"WINDOWS_APP_CONTROL_BLOCKED":"Windows refused to load a file VoiceStudio needs — an Application Control policy (Smart App Control, WDAC, or AppLocker) blocked it. On a personal PC: Windows Security → App & browser control → Smart App Control → Off (Windows only lets you turn it off once — re-enabling requires a Windows reset), then restart VoiceStudio. On a managed/work PC, ask IT to allow the VoiceStudio install folder.",
"WINDOWS_PAGING_FILE_TOO_SMALL":"Windows ran out of virtual memory while mapping the model into memory — its paging file is smaller than the model needs. This is not the same as your RAM being full, and closing other apps usually won't fix it: Windows has to be allowed to back the mapping. Set a bigger paging file — Settings → System → About → Advanced system settings → Performance → Settings → Advanced → Virtual memory → Change: untick \"Automatically manage\", pick your system drive, choose \"Custom size\" and set both Initial and Maximum to at least 32768 MB (more than the model's size), then OK and restart Windows. A smaller/quantized engine (OmniVoice GGUF, Supertonic-3) also avoids the large mapping entirely.",
"WINDOWS_UNTRUSTED_MOUNT":"Windows refused to walk a folder on the way to this file because the path crosses a mount point it does not trust (WinError 448). That is a Windows rule about the VOLUME, not about VoiceStudio or the file itself — it turns up on Dev Drives, on mounted VHD/ReFS volumes, and on junctions pointing into another user profile, so retrying the same link cannot help. Point VoiceStudio at a folder on an ordinary local drive instead: Settings → Storage → data directory, or the download/output folder named in the message. If that folder has to stay where it is, trust the volume with `fsutil devdrv trust <drive>:` from an elevated prompt and restart.",
"INPUT_TOO_SHORT":"The input was too short for this engine to process — its first convolution needs more frames than the text (or the reference clip) produced. This is a hard limit of the model, not a transient failure, so retrying the same input will fail the same way. Give it a few more words, or a longer reference clip: a short phrase rather than one or two characters, and about a second of speech rather than a fragment.",
"CLONE_REFERENCE_MISSING":"This engine was asked to clone a voice but got no reference audio to clone FROM, and the model folder carries no built-in voice either. Pick a voice profile that has a saved reference clip, or record/upload a few seconds of clean speech as the reference, then generate again. A designed voice with no saved reference cannot be cloned from — synthesize with it directly instead.",
"MEDIA_TOOL_MISSING":"VoiceStudio's media engine (ffmpeg/ffprobe) wasn't on the system path when a component went looking for it. Open Settings → Audio tools and use Download/Repair to fetch the bundled copy, then retry — a restart picks it up for everything. If you'd rather use a system install, install ffmpeg (macOS: `brew install ffmpeg`; Windows: `winget install Gyan.FFmpeg`; Linux: your package manager) and restart VoiceStudio, or point FFMPEG_PATH / OMNIVOICE_FFPROBE_PATH at the binaries in Settings.",
"AUDIO_IO_FAILED":"An audio file couldn't be read or written at the OS level. Check the drive isn't full, that the output and temp folders exist and are writable, and that antivirus or OneDrive isn't locking them (add a VoiceStudio exclusion if you use one).",
"VIDEO_DOWNLOAD_OS_ERROR":"The OS refused a file operation while saving the downloaded video — this is a disk/folder problem, not a network one, so retrying the same link won't help. The download is written to a job folder under your VoiceStudio data directory (Settings → Storage shows the path): check that drive isn't full, that the folder exists and is writable, and that antivirus or a cloud-sync client (OneDrive, Dropbox) isn't locking it — add a VoiceStudio exclusion if you use one. If your data directory sits on a synced or network drive, move it to a local one.",
"omnivoice-gguf":"Bundled — runs the C++ omnivoice-tts binary in bin/. Quants download lazily from Serveurperso/OmniVoice-GGUF on first generate.",
"supertonic3":"uv sync --extra supertonic (CPU-only ONNX, 31 langs, ~400 MB model on first use; OpenRAIL-M model license)",
"pockettts":"uv sync --extra pockettts (Kyutai, CPU-only, ~100 MB model on first use; MIT code + CC-BY-4.0 weights; HF-gated, review terms and set HF_TOKEN)",
"moss-tts-v15":"git clone OpenMOSS/MOSS-TTS + set OMNIVOICE_MOSS_TTS_V15_DIR (own venv, transformers==5.0; 8B, ~16 GB weights; CUDA/CPU, no MPS; Apache-2.0)",
"moss-tts-v15":"git clone OpenMOSS/MOSS-TTS + set OMNIVOICE_MOSS_TTS_V15_DIR (own venv, transformers==5.0; 8B, ~16 GB weights; CUDA/ROCm/XPU/NPU/CPU, no MPS; Apache-2.0)",
"dots-tts":"git clone rednote-hilab/dots.tts + set OMNIVOICE_DOTS_TTS_DIR (own venv, transformers==4.57; 2B, ~9 GB weights; CUDA/CPU, Linux/macOS only — no Windows; Apache-2.0)",
1. **Nothing at the root is a runtime artifact.** Outputs, temp files, local DBs, crash logs — all go to `~/Library/Application Support/OmniVoice/` (or the OS equivalent), *never* into the repo. The one exception is `omnivoice_data/`, which exists as a bind-mount anchor for Docker.
2. **No ad-hoc scripts at the root.** One-off debug scripts live in `scripts/`. Tests live in `tests/`. Benchmarks live in `scripts/benchmarks/` (when we create them).
2. **No ad-hoc scripts at the root.** One-off debug scripts live in `scripts/`. Tests live in one of the three homes below, never at the root.
3. **Each subdirectory owns one concern.** If you can't describe what goes in a directory in one sentence, it's wrong.
4. **Every package has a manifest.**`backend/`, `frontend/`, `omnivoice/` each have their own deps declared via `pyproject.toml` / `package.json` — they are independently testable.
4. **Every package has a manifest.**`backend/`, `frontend/`, `omnivoice/` each have their own deps declared via `pyproject.toml` / `package.json` — they are independently testable. The JS lockfile is the **repo-root**`bun.lock` (Bun workspace), and `deploy/Dockerfile` installs from it with `--frozen-lockfile`.
## Where tests live
Three homes, each with its own runner. CI runs all three inside the single `test` job in
`ci.yml`, as separate steps. The split is deliberate, not drift:
| Home | Runner | Why it's separate |
|---|---|---|
| `tests/` | `pytest tests/` — the `testpaths` default | The main suite. Its `conftest.py` points `OMNIVOICE_DATA_DIR` at a throwaway dir so a run can never touch the developer's real app state (#878). |
| `backend/tests/` | `pytest backend/tests/` — its own pytest session (the `Run pytest (backend/tests, isolated)` step) | Runs as an isolated session against `backend/`'s bare imports. Its `conftest.py` sets the same hermetic data dir; **never** reintroduce module-level `sys.modules` stubs there — they leak process-wide at collection time and poison mixed runs. |
| `frontend/src/**/*.test.{js,jsx,ts,tsx}` | `bun run test` (vitest, jsdom) | Co-located with the component under test. `frontend/e2e*/` hold the Playwright suites; `tests/frontend/` is the older `node:test` set. |
| The TTS model (independent of the studio) | `omnivoice/` |
| A new TTS/ASR engine adapter | `backend/engines/<engine>/` |
| Everything executable but not user-facing | `scripts/` |
| Tests | `tests/` |
| Prebuilt platform sidecars | `bin/` |
| Python tests | `tests/` (or `backend/tests/` when the isolated session is required) |
| Frontend unit tests | next to the component, as `*.test.jsx` |
| Developer + user docs (Markdown) | `docs/` |
| Architecture decision records (ADRs) | `docs/adr/` |
| Agent-facing docs | `docs/agents/` |
| Runnable demos and sample data | `examples/` |
| Runtime data (never committed) | `~/Library/Application Support/OmniVoice/` on Mac |
@@ -137,10 +197,10 @@ Removed in the 2026-07-12 cleanup pass (all preserved in git history):
| Dir | Why it was there | Where it went |
|---|---|---|
| `.planning/` (74 files) | GSD-era planning archive: phases, quick plans, issue clusters. The GSD workflow was retired 2026-07-08. | Deleted; the four load-bearing decision docs moved to `docs/adr/`. |
| `specs/` | spec-kit specs for features 001–007 — all shipped. | Deleted. |
| `specs/` | spec-kit specs for features 001–007 — all shipped. | Deleted; `docs/specs/` is the current home. |
| `design/` | ASCII mockups of the pre-React target UX, superseded by the shipped app. | Deleted. |
| `.agents/` | Rules for a third-party agent tool no longer in use. | Deleted. |
| `.agents/` | Rules for a third-party agent tool no longer in use. | Deleted — then reintroduced with a different job: `.agents/skills/` now holds the canonical skill copies pinned by `skills-lock.json`. |
- **Tests mirror source paths**where a mirror exists: `tests/backend/` mirrors `api/ core/ engines/ services/`, so `backend/services/ffmpeg_utils.py` → `tests/backend/services/test_ffmpeg_utils.py`. Everything else stays flat — `tests/backend/test_*.py` for backend-wide cases, `tests/test_*.py` for cross-cutting ones. A React component's test sits next to the component.
- **One-off scripts** go into `scripts/` with a descriptive name, not `test_*.py` at the root.
- **New top-level directories** require a PR that updates *this file*.
| MOSS-TTS-v1.5 (8B) | [moss-tts-v15](moss-tts-v15.md) | CUDA · CPU | ✅ | clone + env var |
| dots.tts (2B) | [dots-tts](dots-tts.md) | CUDA · CPU (not Windows) | ✅ | clone + env var |
| OmniVoice (subprocess) | [omnivoice-subprocess](omnivoice-subprocess.md) | CUDA · MPS · CPU | ✅ | opt-in pick, no install |
| OmniVoice (subprocess) | [omnivoice-subprocess](omnivoice-subprocess.md) | CUDA · MPS · CPU | ✅ | opt-in pick off MPS; automatic via default OmniVoice on MPS |
@@ -25,6 +25,18 @@ is an LLM-based multilingual / cross-lingual zero-shot voice-cloning TTS.
Like IndexTTS-2 / MOSS-TTS-v1.5 / dots.tts, it runs in its **own subprocess venv**
so its dependency stack never touches the default VoiceStudio interpreter.
## One-click install
**Model Catalogue → Engines → Confucius4-TTS → Install** does the steps below
for you, on Windows, Linux and macOS. It installs into its own folder under VoiceStudio's data directory, with its own Python environment. Nothing it installs touches VoiceStudio itself or any other engine, so you can switch to it and back without breaking what already worked. **Uninstall** in the same row removes only that folder. On an NVIDIA machine it installs
the CUDA build of PyTorch; elsewhere it installs the CPU build. The ~5 GB of
weights still download on first synthesis.
The first synthesis downloads the weights, which takes a while on a slow
connection. The generation stays alive while the download makes progress;
if a stalled download runs out of time, raise the compute-time budget in
**Settings → Performance & Device** and try again.
## Install
```bash
@@ -88,3 +100,13 @@ sr = model.sample_rate # 22050
@@ -26,6 +26,17 @@ pins `transformers>=5.3` — the same isolation primitive used by
which VoiceStudio does not auto-wire.
- **VRAM:** ~9 GB checkpoint; a 12–16 GB CUDA GPU is the realistic target.
## One-click install
On Linux and macOS, **Model Catalogue → Engines → dots.tts → Install** does the
steps below for you. It installs into its own folder under VoiceStudio's data directory, with its own Python environment. Nothing it installs touches VoiceStudio itself or any other engine, so you can switch to it and back without breaking what already worked. **Uninstall** in the same row removes only that folder. It is not offered on Windows, where upstream
publishes no install. The ~9 GB checkpoint still downloads on first synthesis.
The first synthesis downloads the weights, which takes a while on a slow
connection. The generation stays alive while the download makes progress;
if a stalled download runs out of time, raise the compute-time budget in
**Settings → Performance & Device** and try again.
## Install
dots.tts is **not** bundled (large checkpoint + conflicting `transformers`).
@@ -115,3 +126,9 @@ dots.tts runs in a dedicated sidecar venv (it pins `transformers==4.57`,
which conflicts with the parent's `transformers>=5.3`). For why that adds
disk and how uv keeps the cost down, see
[Engine venvs & disk usage](disk-usage.md).
The upstream runtime selects CUDA or CPU internally. Automatic precision follows
that selection: bfloat16 on CUDA, float32 otherwise, including XPU/NPU/MPS hosts
where this runtime executes on CPU. `OMNIVOICE_DOTS_TTS_PRECISION` remains an
explicit override. If the CUDA availability probe raises, the automatic precision
default stays float32; upstream remains responsible for its device selection.
@@ -23,10 +23,24 @@ interpreter, so MOSS runs behind
8 GB GPUs when quantized; the bf16 Transformers path used here is ~16 GB
of weights, so a 16 GB+ GPU is the realistic CUDA target. It also runs on
**CPU** (fp32) — correct but slow.
- **Device:**CUDA when present, else CPU. **There is no MPS path** —
upstream documents only CUDA/CPU and the custom modelling code is
untested on Apple Silicon, so VoiceStudio never routes MOSS to MPS. On a
Mac it runs on CPU.
- **Device:**the sidecar uses a runtime-available PyTorch CUDA/ROCm, XPU,
or registered NPU backend, otherwise CPU. The isolated engine venv needs the
matching torch/vendor integration. A failed accelerator probe falls back to
CPU, including in older venvs without the unified accelerator API. MPS still uses CPU. XPU/NPU routing is
covered by mocked loader tests; physical-device synthesis has not been
validated by this change.
## One-click install
On a machine with an NVIDIA GPU, **Model Catalogue → Engines → MOSS-TTS-v1.5 →
Install** does every step below for you. It installs into its own folder under VoiceStudio's data directory, with its own Python environment. Nothing it installs touches VoiceStudio itself or any other engine, so you can switch to it and back without breaking what already worked. **Uninstall** in the same row removes only that folder. The ~16 GB of weights still
download on first synthesis. On a CPU-only host the button is not offered; use
the manual install.
The first synthesis downloads the weights, which takes a while on a slow
connection. The generation stays alive while the download makes progress;
if a stalled download runs out of time, raise the compute-time budget in
**Settings → Performance & Device** and try again.
## Install
@@ -48,9 +62,13 @@ into an isolated venv on demand.
| Emotion ("excited", "sad", graded intensity) | IndexTTS2's emotion controls — Audiobook tab's Production Overrides, or the `/ws/tts` API — or CosyVoice 3 instruct | Opt-in engines only |
| The same take again | Pin the seed / lock the profile | Default engine |
## Recovering an interrupted audiobook
Switching away from the Audiobook tab explicitly interrupts synthesis at a chapter boundary. The Audiobook recovery card lets you resume with its cached chapters, and **Open chapter cache** reveals the chapter audio cache.
## Why bracket tags work at all (and when they don't)
Everything you type in the text box reaches the active engine **verbatim** —
the pipeline goes out of its way not to break tags:
- Tilde-separated integer, signed, and decimal ranges get a spoken separator in
English, Korean, Japanese, and Chinese; malformed chains and product codes
are left unchanged.
- The text-normalization pass (numbers, abbreviations) skips every `[…]` span
(`backend/services/text_normalization.py`).
- The long-text chunker never cuts inside a bracket tag
@@ -12,11 +12,19 @@ own microphone audio to the versioned WebSocket API. See the
## Use it
1. Choose an installed dictation model in the Model Catalogue.
1. Install a dictation model from the Model Catalogue, then pick it in **Settings → Voice** or from the top-bar Engines menu (**Transcription → Sherpa-ONNX dictation → Dictation model**). The same choice is what the Sherpa-ONNX engine loads for dubbing and batch transcription.
2. Set the shortcut and hold/toggle behavior in **Settings → Hotkey**.
3. Put the cursor in a text field, press the shortcut, speak, then release or
press again.
The **Transcriptions** page offers the same recorder as one contextual
**Start dictation** action: it appears in the empty state before the first
transcript and moves to the page header once history exists. A desktop start
wakes the recorder window before dispatch, so a hidden WebView cannot silently
miss the request. The in-app action confirms listener receipt, then resolves
only after microphone startup is accepted. Disabled, rejected, timed-out, or
failed starts are reported back on the page.
Whisper Tiny is the recommended default on macOS, Windows, and Linux. It
auto-detects more than 90 languages. Parakeet TDT v3 remains available for its
25 supported European languages, but it is not selected automatically.
@@ -67,3 +75,13 @@ changes. `dotool` needs direct write access to `/dev/uinput`; `ydotool` 1.0+
needs a running `ydotoold` with that access and a user-readable socket.
VoiceStudio checks these prerequisites before selection. Tray-started Wayland
dictation always stays copy-only.
### Transcriptions model setup
Transcriptions checks the active dictation model before enabling **Start dictation**. If weights are missing, the page lists every dictation model, grouped by the trade-off you are choosing between — best accuracy (offline, transcribes after you stop) versus lowest latency (streaming, live text while you speak) — with languages and download size on each row, so you install the one that fits your work rather than only the recommended default. A model already on disk can be switched to without a download. The chosen model's name, download size, and installation progress are shown. Downloads require an explicit click. Failed downloads can be retried, and model state refreshes when returning from Settings. Once installation is verified, Start dictation becomes available; recording never starts automatically. Existing transcription history remains accessible during setup.
### Floating recording controls
The recording bubble includes **Pause / Resume**, **Stop**, and **Close**. Pause disables microphone tracks, stops sending new audio, and freezes the elapsed recording timer while preserving the session. Resume continues the same session. Stop (or the recording shortcut) finishes and transcribes, including when paused. Close cancels pending recording/transcription and releases the microphone; text already delivered to another app cannot be retracted. Pausing is manual, not triggered by silence or desktop inactivity.
The Transcriptions page displays the effective recording shortcut and the platform paste shortcut. The floating bubble places its live transcript below the controls in a multiline preview, so controls cannot squeeze the text into a few characters.
@@ -190,3 +190,20 @@ Hit a wall? See [docs/install/troubleshooting.md](troubleshooting.md).
The in-app error UI (the React error boundary that fires on backend errors)
includes an **"Open docs for this error"** button — that button deeplinks
back into this docs tree at the right section for the error class.
### Desktop window chrome
The main window uses native macOS traffic lights with an overlay title bar;
window sizing, resize limits, and application file-drop behavior match the
other desktop platforms. The platform configuration repeats the complete window
list because Tauri replaces arrays when merging it with the shared config.
The capture widget remains a separate borderless window created at runtime.
Its window-scoped Tauri capability permits hiding after recording or idle
reconciliation on every desktop platform.
### Fast process shutdown
A process that exits while shutdown is signalling it can report a macOS
permission error. VoiceStudio accepts this only after confirming the original
process exited without being reaped, then still waits for nested operations to
drain. Live-process permission errors and lost process ownership remain failures.
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.