Compare commits

..
Author SHA1 Message Date
Palash Debnath 38c2405fa1 Merge pull request #2007 from debpalash/release/v0.5.2
docs(changelog): cut the 0.5.2 release section
2026-09-10 10:09:20 -07:00
Palash Debnath 97b153df3a test(changelog): count versions the way the app's changelog parser reads them
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.
2026-09-10 09:47:54 -07:00
Palash Debnath 4e37e21745 chore(release): v0.5.2
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.
2026-09-10 09:42:08 -07:00
Palash Debnath 3bfdeeddba Merge pull request #2024 from debpalash/fix/uv-no-app-config
fix(engines): engine installs never inherit VoiceStudio's own uv config
2026-09-10 09:42:02 -07:00
Palash Debnath ac35351000 Merge remote-tracking branch 'origin/main' into fix/uv-no-app-config
# Conflicts:
#	CHANGELOG.md
2026-09-10 09:11:59 -07:00
Palash Debnath 764fed09bc Merge pull request #2018 from debpalash/fix/license-reason-passthrough
fix(engines): say when a license or the platform is what blocks an engine
2026-09-10 09:04:07 -07:00
Palash Debnath 4fdde15079 Merge pull request #2023 from debpalash/fix/translation-uninstall-guard
fix(translation): uninstalling an engine never removes what others need
2026-09-10 09:00:07 -07:00
Palash Debnath 14a8a9cd46 docs(changelog): link the uv config entry to #2024 2026-09-10 08:46:57 -07:00
Palash Debnath 1af1ecc5bd fix(engines): engine installs never inherit VoiceStudio's own uv config
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.
2026-09-10 08:46:49 -07:00
Palash Debnath e0879027d5 Merge remote-tracking branch 'origin/main' into fix/license-reason-passthrough
# Conflicts:
#	CHANGELOG.md
#	tests/test_engine_unavailable_reason_1866.py
2026-09-10 08:41:45 -07:00
Palash Debnath 5298c8e5e1 Merge pull request #2016 from debpalash/feat/isolated-engine-installs
feat(engines): isolated one-click installs for five engines
2026-09-10 08:38:39 -07:00
Palash Debnath 1de4fedad7 docs(changelog): file the translation guard under Fixed, not Highlights 2026-09-10 08:33:55 -07:00
Palash Debnath c5f32d020a fix(translation): uninstalling an engine never removes what others need
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.
2026-09-10 08:26:53 -07:00
Palash Debnath f5ea363724 fix(engines): name the missing MPS on Apple Silicon, and hold the MLX test on every host
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.
2026-09-10 08:22:20 -07:00
Palash Debnath ae68dd5bfd fix(engines): a half-finished install is repaired, not reported installed
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.
2026-09-10 08:12:17 -07:00
Palash Debnath 2fd4cb3caf test(engines): cover dots.tts's own Windows reason in the platform category 2026-09-10 07:53:10 -07:00
Palash Debnath e532c9d8dd fix(engines): say when an engine can't run on this platform
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.
2026-09-10 07:52:46 -07:00
Palash Debnath 5516750a4f fix(engines): accept Confucius4's requirements.txt-only source layout
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.
2026-09-10 07:48:52 -07:00
Palash Debnath c079b721ed feat(engines): Supertonic-3 and PocketTTS install into their own venvs
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.
2026-09-10 07:44:42 -07:00
Palash Debnath 664c9ea4b5 fix(engines): keep the license reason so Supertonic-3 and PocketTTS can be enabled
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.
2026-09-10 07:34:06 -07:00
Palash Debnath faa3d39836 feat(engines): isolated one-click installs for MOSS-TTS-v1.5, Confucius4 and dots.tts
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.
2026-09-10 07:28:42 -07:00
Palash Debnath b0cae840c4 Merge pull request #2010 from debpalash/fix/pill-visibility-desync
fix(dictation): pill window, card and stale model on Windows (#2009, #2012)
2026-09-10 07:18:22 -07:00
Palash Debnath 566aad7bba fix(dictation): mirror the widget's shadow setting in the macOS overlay
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.
2026-09-10 06:53:49 -07:00
Palash Debnath e9965b2cb4 fix(dictation): never show the pill natively when Tauri's show fails
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.
2026-09-10 06:36:06 -07:00
Palash Debnath 52887ae44d fix(dictation): no card around the pill, and the pill uses your model (#2009, #2012)
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.
2026-09-10 06:34:26 -07:00
Palash Debnath b0f1558db0 fix(dictation): tell Tauri the pill is visible, not just Windows (#2009)
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.
2026-09-10 05:57:22 -07:00
Palash Debnath dea884a22d Merge pull request #2006 from debpalash/fix/worker-artifact-id-posix
fix(worker): identify a staged input the same way on every OS (#2005)
2026-09-10 05:31:39 -07:00
Palash Debnath bee9fa25ff Merge remote-tracking branch 'origin/main' into fix/worker-artifact-id-posix
# Conflicts:
#	CHANGELOG.md
2026-09-10 05:02:02 -07:00
Palash Debnath 06c15ce37f fix(worker): identify a staged input the same way on every OS (#2005)
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.
2026-09-10 04:51:36 -07:00
Palash Debnath bb9149ce1f Merge pull request #2004 from debpalash/fix/grpc-loop-budget
test(worker): size the loop-responsiveness budget against what it measures
2026-09-10 04:40:54 -07:00
Palash Debnath 4c10e802ab test(worker): size the loop-responsiveness budget against what it measures
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.
2026-09-10 04:15:06 -07:00
Palash Debnath c021fac1d8 Merge pull request #2003 from debpalash/land/2002-inert-badge
feat(pronunciation): badge a stored-but-inert entry in the list (#1949)
2026-09-10 04:05:28 -07:00
Palash Debnath 24e0737e0d fix(pronunciation): badge only the entries the backend calls inert
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.
2026-09-10 03:38:44 -07:00
Palash Debnath 06f7c4af7b feat(pronunciation): badge a stored-but-inert entry in the list (#1949)
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.
2026-09-10 03:26:44 -07:00
Palash Debnath c9a587ea75 Merge pull request #2000 from debpalash/land/1998-powershell-key
docs(docker): make the PowerShell key URL-safe too (#1998)
2026-09-10 03:21:39 -07:00
Palash Debnath cef2b73e02 Merge remote-tracking branch 'origin/main' into land/1998-powershell-key 2026-09-10 02:52:35 -07:00
Palash Debnath 50b1184df3 Merge pull request #1996 from debpalash/fix/1933-port-holder
fix(backend): name who actually holds port 3900 (#1933)
2026-09-10 02:52:16 -07:00
Palash Debnath c48e8c4ff5 Merge remote-tracking branch 'origin/main' into land/1998-powershell-key 2026-09-10 02:22:57 -07:00
Palash Debnath 0c9f1bd7c4 Merge remote-tracking branch 'origin/main' into fix/1933-port-holder
# Conflicts:
#	CHANGELOG.md
2026-09-10 02:22:49 -07:00
Palash Debnath b6a0f96e90 Merge pull request #1992 from debpalash/fix/1900-attempt-id
fix(bootstrap): emit an attempt id so the splash stops guessing (#1900)
2026-09-10 02:22:28 -07:00
Palash Debnath 93771a0201 Merge pull request #2001 from debpalash/fix/worker-barrier-flake
test(worker): stop spinning the loop the awaited work needs
2026-09-10 02:22:22 -07:00
Palash Debnath a4f87bad6e Merge remote-tracking branch 'origin/main' into fix/1933-port-holder
# Conflicts:
#	CHANGELOG.md
#	frontend/src-tauri/src/backend.rs
2026-09-10 01:53:46 -07:00
Palash Debnath 703c665c6c Merge remote-tracking branch 'origin/main' into fix/worker-barrier-flake 2026-09-10 01:52:50 -07:00
Palash Debnath f301db1b02 Merge remote-tracking branch 'origin/main' into land/1998-powershell-key 2026-09-10 01:52:41 -07:00
Palash Debnath c90d32845a Merge remote-tracking branch 'origin/main' into fix/1900-attempt-id
# Conflicts:
#	CHANGELOG.md
2026-09-10 01:52:33 -07:00
Palash Debnath 9a7f5fd7ed Merge pull request #1994 from debpalash/fix/1850-crash-tail
fix(crash): capture the dying backend's last words, not the log so far (#1850)
2026-09-10 01:52:06 -07:00
Palash DebnathandClaude Opus 5 25393b4654 test(worker): stop spinning the loop the awaited work needs
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
2026-09-10 01:42:15 -07:00
Palash DebnathandClaude Opus 5 e11270990b fix(backend): identify the port holder by the marker header, not a body sniff
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
2026-09-10 01:13:28 -07:00
Palash DebnathandClaude Opus 5 f21ad9a27d fix(crash): close the far end of a dead run's slice, and settle one at a time
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
2026-09-10 01:11:46 -07:00
Palash Debnath fc45ec3b5b Merge remote-tracking branch 'origin/main' into land/1998-powershell-key 2026-09-10 01:08:11 -07:00
Palash Debnath 1eda03827d Merge remote-tracking branch 'origin/main' into fix/1900-attempt-id 2026-09-10 01:08:03 -07:00
Palash Debnath 555210bbdb Merge remote-tracking branch 'origin/main' into fix/1933-port-holder 2026-09-10 01:07:55 -07:00
Palash Debnath 78d6dbe802 Merge remote-tracking branch 'origin/main' into fix/1850-crash-tail 2026-09-10 01:07:46 -07:00
Palash Debnath e3efe4a39a Merge pull request #1999 from debpalash/fix/windows-backend-tests
test: make the isolated backend session pass on Windows, and gate it there
2026-09-10 01:07:27 -07:00
Palash DebnathandClaude Opus 5 36a9515320 docs(docker): make the PowerShell key URL-safe too
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
2026-09-10 01:00:14 -07:00
Palash Debnath 9618f26f6e Merge remote-tracking branch 'origin/main' into fix/1900-attempt-id
# Conflicts:
#	CHANGELOG.md
2026-09-10 00:58:27 -07:00
Palash Debnath 9e12c7b181 Merge remote-tracking branch 'origin/main' into fix/1933-port-holder
# Conflicts:
#	CHANGELOG.md
2026-09-10 00:58:18 -07:00
Palash Debnath baaef34a4a Merge remote-tracking branch 'origin/main' into fix/1850-crash-tail
# Conflicts:
#	CHANGELOG.md
2026-09-10 00:58:09 -07:00
Palash Debnath 5b842280c8 Merge pull request #1997 from debpalash/fix/1931-blackwell-docs
docs+test: finish the RTX 50-series story (#1931)
2026-09-10 00:56:32 -07:00
Palash DebnathandClaude Opus 5 9d71c7cedd test: pin the port diagnosis to the matcher, not to one sentence
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
2026-09-10 00:45:34 -07:00
Palash Debnath 770fe35da9 Merge pull request #1995 from debpalash/fix/1927-crash-hint
fix(crash): say what the exit code means in the crash details (#1927)
2026-09-10 00:36:16 -07:00
Palash DebnathandClaude Opus 5 db48c7851c test: make the isolated backend session pass on Windows, and gate it there
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
2026-09-10 00:34:58 -07:00
Palash DebnathandClaude Opus 5 512c2fa7d6 fix(bootstrap): bind the attempt id to the work it labels
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
2026-09-10 00:15:33 -07:00
Yang Fan 55e6d9a07b docs(docker): generate PowerShell keys without Python 2026-09-10 15:14:00 +08:00
Palash DebnathandClaude Opus 5 192c1f5127 fix(crash): pin the run slice before settling, and keep every drainer
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
2026-09-10 00:10:53 -07:00
Palash DebnathandClaude Opus 5 7610a0d86e fix(backend): show the listener before ending it, never a blind kill
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
2026-09-10 00:08:22 -07:00
Palash Debnath 2afea02c94 Merge remote-tracking branch 'origin/main' into fix/1933-port-holder
# Conflicts:
#	CHANGELOG.md
2026-09-10 00:05:45 -07:00
Palash Debnath 46b1fb2f57 Merge remote-tracking branch 'origin/main' into fix/1927-crash-hint
# Conflicts:
#	CHANGELOG.md
2026-09-10 00:05:39 -07:00
Palash Debnath ac427fb821 Merge remote-tracking branch 'origin/main' into fix/1850-crash-tail
# Conflicts:
#	CHANGELOG.md
2026-09-10 00:05:35 -07:00
Palash Debnath 924172e012 Merge pull request #1986 from debpalash/fix/1960-name-the-language
fix(dub): name the source language code that was rejected
2026-09-10 00:04:53 -07:00
Palash DebnathandClaude Opus 5 14d6b90836 docs+test: finish the RTX 50-series story (#1931)
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
2026-09-10 00:03:56 -07:00
Palash Debnath 2cde7663a1 Merge remote-tracking branch 'origin/main' into fix/1900-attempt-id
# Conflicts:
#	CHANGELOG.md
2026-09-10 00:00:42 -07:00
Palash Debnath 2bf922d9e5 Merge pull request #1993 from debpalash/land/1987-followup
docs(docker): finish the ARM64 Compose guidance (#1987)
2026-09-10 00:00:11 -07:00
Palash Debnath d52a4c89a7 Merge pull request #1991 from debpalash/fix/windows-symlink-tests
test: let a stock Windows checkout run the symlink tests (#1990)
2026-09-09 23:59:58 -07:00
Palash DebnathandClaude Opus 5 6be80a4b3d fix(backend): name who actually holds port 3900 (#1933)
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
2026-09-09 23:59:09 -07:00
Palash DebnathandClaude Opus 5 f67e5da289 fix(crash): say what the exit code means in the crash details (#1927)
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
2026-09-09 23:51:50 -07:00
Palash Debnath b945ddd163 Merge remote-tracking branch 'origin/main' into tmp/1986
# Conflicts:
#	CHANGELOG.md
2026-09-09 23:48:36 -07:00
Palash Debnath 525a2809eb Merge pull request #1989 from debpalash/land/1981-structure
docs: refresh STRUCTURE.md and pin its counts to the tree (#1981)
2026-09-09 23:48:11 -07:00
Palash DebnathandClaude Opus 5 1b7fcf92c3 fix(crash): capture the dying backend's last words, not the log so far (#1850)
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
2026-09-09 23:47:18 -07:00
Palash DebnathandClaude Opus 5 78a6c489c9 docs(docker): finish the ARM64 Compose guidance (#1987)
@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
2026-09-09 23:41:44 -07:00
Palash Debnath 5e4f863c4d Merge pull request #1988 from debpalash/land/1987-docker-arch
docs(docker): document the amd64-only images and the ARM64 workaround (#1987)
2026-09-09 23:39:41 -07:00
Palash Debnath 12794f3dae Merge pull request #1985 from debpalash/fix/1773-classic-error-class
fix(errors): carry the backend error class onto a classic 500
2026-09-09 23:39:30 -07:00
Palash DebnathandClaude Opus 5 0ce5cca1b5 fix(bootstrap): emit an attempt id so the splash stops guessing (#1900)
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
2026-09-09 23:38:39 -07:00
Palash Debnath 744a0550d3 Merge remote-tracking branch 'origin/main' into tmp/1986
# Conflicts:
#	CHANGELOG.md
2026-09-09 23:25:50 -07:00
Palash DebnathandClaude Opus 5 56e9e42ac2 test: let a stock Windows checkout run the symlink tests (#1990)
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
2026-09-09 23:25:16 -07:00
Palash Debnath c2f27c39d2 Merge remote-tracking branch 'origin/main' into tmp/1985
# Conflicts:
#	CHANGELOG.md
2026-09-09 23:24:18 -07:00
Palash Debnath 60b696f37f Merge pull request #1984 from debpalash/fix/1949-phoneme-visible
fix(pronunciation): say when an IPA/CMU entry is stored but not applied
2026-09-09 23:23:18 -07:00
Palash DebnathandClaude Opus 5 5237f7849a docs: keep STRUCTURE.md's README annotation in English
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
2026-09-09 23:22:58 -07:00
Palash DebnathandClaude Opus 5 a5f4d61eda docs: keep STRUCTURE.md's counts honest with a test
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
2026-09-09 23:06:59 -07:00
Palash Debnath c7d0e314d9 Merge branch 'pr/1981' into land/1981-structure 2026-09-09 23:03:14 -07:00
Palash DebnathandClaude Opus 5 c4f214858b docs(docker): cover Compose in the ARM64 guidance
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
2026-09-09 23:00:44 -07:00
Palash Debnath 7aa2333823 Merge remote-tracking branch 'origin/pr/1987' into land/1987-docker-arch 2026-09-09 23:00:14 -07:00
Palash Debnath fe012ad3a5 Merge remote-tracking branch 'origin/main' into fix/1960-name-the-language
# Conflicts:
#	CHANGELOG.md
2026-09-09 22:58:31 -07:00
Palash Debnath 1b7e858886 Merge remote-tracking branch 'origin/main' into fix/1773-classic-error-class
# Conflicts:
#	CHANGELOG.md
2026-09-09 22:58:26 -07:00
Palash Debnath fd74865f7c Merge remote-tracking branch 'origin/main' into fix/1949-phoneme-visible
# Conflicts:
#	CHANGELOG.md
2026-09-09 22:58:22 -07:00
Palash Debnath 0f237a9b25 Merge pull request #1983 from debpalash/fix/1847-bootstrap-log-file
fix(bootstrap): keep the first-run install log after setup finishes
2026-09-09 22:57:53 -07:00
Yang Fan ae25a6a594 docs: clarify Docker image architecture requirements 2026-09-10 13:51:58 +08:00
Palash DebnathandClaude Opus 5 aa984075a7 test(dub): pair each rejected code with its own response
The two requests in this case send different bad codes; asserting one string
against both bodies passed on whichever happened to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
2026-09-09 22:46:12 -07:00
Palash DebnathandClaude Opus 5 80e0b91ce8 test(dub): assert the substance of the rejection, not its exact wording
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
2026-09-09 22:44:48 -07:00
Palash DebnathandClaude Opus 5 d1e4c847cb fix(dub): name the source language code that was rejected
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
2026-09-09 22:42:27 -07:00
Palash Debnath 91515310f0 Merge remote-tracking branch 'origin/main' into fix/1773-classic-error-class
# Conflicts:
#	CHANGELOG.md
2026-09-09 22:40:35 -07:00
Palash Debnath 6087085d43 Merge remote-tracking branch 'origin/main' into fix/1949-phoneme-visible
# Conflicts:
#	CHANGELOG.md
2026-09-09 22:40:30 -07:00
Palash Debnath 2f66e95958 Merge remote-tracking branch 'origin/main' into fix/1847-bootstrap-log-file
# Conflicts:
#	CHANGELOG.md
2026-09-09 22:40:26 -07:00
Palash Debnath f905230fd8 Merge pull request #1982 from debpalash/fix/1974-dev-port-ownership
fix(dev): reclaim the port from a backend the app itself left running
2026-09-09 22:39:58 -07:00
Palash DebnathandClaude Opus 5 b6ed598770 fix(errors): carry the backend error class onto a classic 500
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
2026-09-09 22:25:56 -07:00
Palash Debnath ac09da3c24 Merge remote-tracking branch 'origin/main' into fix/1949-phoneme-visible
# Conflicts:
#	CHANGELOG.md
2026-09-09 22:22:37 -07:00
Palash Debnath ef8636ee78 Merge remote-tracking branch 'origin/main' into fix/1847-bootstrap-log-file
# Conflicts:
#	CHANGELOG.md
2026-09-09 22:22:31 -07:00
Palash Debnath 72249a8d8e Merge remote-tracking branch 'origin/main' into fix/1974-dev-port-ownership
# Conflicts:
#	CHANGELOG.md
2026-09-09 22:22:26 -07:00
Palash Debnath 8e2928ca56 Merge pull request #1980 from debpalash/fix/1858-shortcut-registration
fix(dictation): say when another app already owns the shortcut
2026-09-09 22:22:01 -07:00
Palash DebnathandClaude Opus 5 de962d164a fix(pronunciation): say when an IPA/CMU entry is stored but not applied
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
2026-09-09 22:11:05 -07:00
Palash Debnath 3d76f1ca26 Merge remote-tracking branch 'origin/main' into fix/1847-bootstrap-log-file
# Conflicts:
#	CHANGELOG.md
#	frontend/src-tauri/src/bootstrap.rs
2026-09-09 22:06:20 -07:00
Palash Debnath 6203fe41e9 Merge remote-tracking branch 'origin/main' into fix/1974-dev-port-ownership
# Conflicts:
#	CHANGELOG.md
2026-09-09 22:04:53 -07:00
Palash Debnath 8e87755845 Merge remote-tracking branch 'origin/main' into fix/1858-shortcut-registration
# Conflicts:
#	CHANGELOG.md
2026-09-09 22:04:48 -07:00
Palash Debnath 3013cdc241 Merge pull request #1979 from debpalash/fix/1898-windows-quit
fix(windows): stop reporting every deliberate quit as a crash
2026-09-09 22:04:19 -07:00
Palash DebnathandClaude Opus 5 939ed4b12d fix(bootstrap): keep the first-run install log after setup finishes
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
2026-09-09 21:48:53 -07:00
Palash Debnath 9e768c9552 Merge remote-tracking branch 'origin/main' into fix/1974-dev-port-ownership
# Conflicts:
#	CHANGELOG.md
2026-09-09 21:45:27 -07:00
Palash Debnath 18d2d1e076 Merge remote-tracking branch 'origin/main' into fix/1858-shortcut-registration
# Conflicts:
#	CHANGELOG.md
2026-09-09 21:45:22 -07:00
Palash Debnath 8d0b826c92 Merge remote-tracking branch 'origin/main' into fix/1898-windows-quit
# Conflicts:
#	CHANGELOG.md
2026-09-09 21:45:17 -07:00
Palash Debnath 939bdc7248 Merge pull request #1978 from debpalash/fix/1857-reduced-motion
feat(a11y): add an in-app Reduce motion switch
2026-09-09 21:44:50 -07:00
DawcraftandClaude Opus 5 71d4114354 docs: correct the CI and mirror-path claims in STRUCTURE.md
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
2026-09-10 13:32:07 +09:00
Palash DebnathandClaude Opus 5 5f7df6ac52 fix(dev): reclaim the port from a backend the app itself left running
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
2026-09-09 21:31:19 -07:00
Palash Debnath 12216ecc9a Merge remote-tracking branch 'origin/main' into fix/1858-shortcut-registration
# Conflicts:
#	CHANGELOG.md
2026-09-09 21:27:39 -07:00
Palash Debnath 1339fa6814 Merge remote-tracking branch 'origin/main' into fix/1898-windows-quit
# Conflicts:
#	CHANGELOG.md
2026-09-09 21:27:38 -07:00
Palash Debnath 4b5b19ba47 Merge remote-tracking branch 'origin/main' into fix/1857-reduced-motion
# Conflicts:
#	CHANGELOG.md
#	frontend/src/i18n/locales/ar.json
#	frontend/src/i18n/locales/de.json
#	frontend/src/i18n/locales/es.json
#	frontend/src/i18n/locales/fr.json
#	frontend/src/i18n/locales/hi.json
#	frontend/src/i18n/locales/id.json
#	frontend/src/i18n/locales/it.json
#	frontend/src/i18n/locales/ja.json
#	frontend/src/i18n/locales/ko.json
#	frontend/src/i18n/locales/nl.json
#	frontend/src/i18n/locales/pl.json
#	frontend/src/i18n/locales/pt.json
#	frontend/src/i18n/locales/ru.json
#	frontend/src/i18n/locales/sv.json
#	frontend/src/i18n/locales/th.json
#	frontend/src/i18n/locales/tr.json
#	frontend/src/i18n/locales/uk.json
#	frontend/src/i18n/locales/vi.json
#	frontend/src/i18n/locales/zh-CN.json
#	frontend/src/i18n/locales/zh-TW.json
2026-09-09 21:27:37 -07:00
Palash Debnath 7d1f44f8bd Merge pull request #1977 from debpalash/land/1975-light-theme
fix(a11y): land the light theme, with one token raised to clear WCAG AA
2026-09-09 21:26:39 -07:00
Palash DebnathandClaude Opus 5 647ddc842b fix(dictation): say when another app already owns the shortcut
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
2026-09-09 21:20:32 -07:00
DawcraftandClaude Opus 5 638392994a docs: refresh STRUCTURE.md to match the tree as it is today
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
2026-09-10 13:19:44 +09:00
Palash DebnathandClaude Opus 5 dba3074376 fix(windows): stop reporting every deliberate quit as a crash
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
2026-09-09 21:16:13 -07:00
Palash DebnathandClaude Opus 5 d0422b8913 feat(a11y): add an in-app Reduce motion switch
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
2026-09-09 21:11:13 -07:00
Palash DebnathandClaude Opus 5 a31b50c5cb fix(a11y): land the light theme, with one token raised to clear WCAG AA
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
2026-09-09 21:06:19 -07:00
Palash Debnath cb4aaa1580 Merge remote-tracking branch 'origin/pr/1975' into land/1975-light-theme 2026-09-09 21:04:07 -07:00
Palash Debnath d0ab15c376 Merge pull request #1972 from debpalash/fix/1826-input-too-short
fix(errors): explain a too-short input instead of quoting torch's conv error
2026-09-09 20:59:29 -07:00
CoDe-ReDz 212a1afb69 fix(i18n): replace english placeholder labels with localized translations 2026-09-09 19:55:46 -07:00
CoDe-ReDz 58a4674316 fix(a11y): address bot review feedback (contrast, header, i18n, tests) 2026-09-09 19:43:30 -07:00
CoDe-ReDz bcc47882e9 a11y: Add Solarized Light theme and wire OS auto-sync
Resolves #1973. Addresses eye strain halation by adding a WCAG AA compliant light theme and exposing the Auto preference.
2026-09-09 19:08:39 -07:00
Palash Debnath 355f7465c8 Merge remote-tracking branch 'origin/main' into fix/1826-input-too-short
# Conflicts:
#	CHANGELOG.md
2026-09-09 16:29:07 -07:00
Palash Debnath 9426006d3c Merge pull request #1971 from debpalash/fix/1849-uiscale-order
fix(setup): offer the text-size control before first run, not after it
2026-09-09 16:28:44 -07:00
Palash Debnath f82ec3f579 Merge remote-tracking branch 'origin/main' into fix/1826-input-too-short
# Conflicts:
#	CHANGELOG.md
#	backend/core/failure.py
2026-09-09 16:12:00 -07:00
Palash Debnath b4fc4cb53f Merge remote-tracking branch 'origin/main' into fix/1849-uiscale-order
# Conflicts:
#	CHANGELOG.md
2026-09-09 16:11:23 -07:00
Palash Debnath c6d7a19f42 Merge pull request #1970 from debpalash/fix/1879-clone-reference
fix(errors): say 'no reference clip' instead of naming library parameters
2026-09-09 16:10:58 -07:00
Palash DebnathandClaude Opus 5 c902a42f6b fix(errors): explain a too-short input instead of quoting torch's conv error
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
2026-09-09 15:55:38 -07:00
Palash Debnath 7a24b459bc Merge remote-tracking branch 'origin/main' into fix/1849-uiscale-order
# Conflicts:
#	CHANGELOG.md
2026-09-09 15:52:55 -07:00
Palash Debnath 0862b3efed Merge remote-tracking branch 'origin/main' into fix/1879-clone-reference
# Conflicts:
#	CHANGELOG.md
2026-09-09 15:52:51 -07:00
Palash Debnath ac63eff33d Merge pull request #1969 from debpalash/fix/1931-torchaudio-backend
fix(startup): guard the torchaudio API removed in 2.9, and document the Blackwell path
2026-09-09 15:52:29 -07:00
Palash DebnathandClaude Opus 5 f92e33f8e3 fix(setup): offer the text-size control before first run, not after it
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
2026-09-09 15:41:03 -07:00
Palash Debnath 70aecf7ad0 Merge remote-tracking branch 'origin/main' into fix/1879-clone-reference
# Conflicts:
#	CHANGELOG.md
2026-09-09 15:36:52 -07:00
Palash Debnath 164534016a Merge remote-tracking branch 'origin/main' into fix/1931-torchaudio-backend
# Conflicts:
#	CHANGELOG.md
2026-09-09 15:36:47 -07:00
Palash Debnath 7b7b491ba0 Merge pull request #1968 from debpalash/fix/small-batch-1
fix(errors): point a generation timeout at Settings, not an environment variable
2026-09-09 15:36:25 -07:00
Palash DebnathandClaude Opus 5 0a019b5d7c fix(errors): say 'no reference clip' instead of naming library parameters
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
2026-09-09 15:24:12 -07:00
Palash Debnath 4da62e664d Merge remote-tracking branch 'origin/main' into fix/1931-torchaudio-backend
# Conflicts:
#	CHANGELOG.md
2026-09-09 15:20:05 -07:00
Palash Debnath 508943ae71 Merge remote-tracking branch 'origin/main' into fix/small-batch-1
# Conflicts:
#	CHANGELOG.md
2026-09-09 15:20:00 -07:00
Palash Debnath 42b4bf856f Merge pull request #1967 from debpalash/fix/1866-engine-reason
fix(engines): say why an engine is unavailable instead of reporting a failed check
2026-09-09 15:19:40 -07:00
Palash DebnathandClaude Opus 5 c119afb023 fix(startup): guard the torchaudio API removed in 2.9, and document the Blackwell path
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
2026-09-09 15:06:18 -07:00
Palash Debnath 0ba91e6d82 Merge remote-tracking branch 'origin/main' into fix/small-batch-1
# Conflicts:
#	CHANGELOG.md
2026-09-09 15:03:10 -07:00
Palash Debnath 28d9d2fbe8 Merge remote-tracking branch 'origin/main' into fix/1866-engine-reason
# Conflicts:
#	CHANGELOG.md
2026-09-09 15:03:05 -07:00
Palash Debnath 5069872752 Merge pull request #1966 from debpalash/fix/1845-setup-pill
fix(dictation): stop the Accessibility prompt owning the screen indefinitely
2026-09-09 15:02:45 -07:00
Palash DebnathandClaude Opus 5 030436a549 fix(errors): point a generation timeout at Settings, not an environment variable
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
2026-09-09 14:50:58 -07:00
Palash Debnath b9324b84b3 Merge remote-tracking branch 'origin/main' into fix/1866-engine-reason
# Conflicts:
#	CHANGELOG.md
2026-09-09 14:45:56 -07:00
Palash Debnath 353cc40fdc Merge remote-tracking branch 'origin/main' into fix/1845-setup-pill
# Conflicts:
#	CHANGELOG.md
2026-09-09 14:45:52 -07:00
Palash Debnath a4cb4afe37 Merge pull request #1965 from debpalash/fix/1856-dictation-step
fix(setup): stop the last onboarding step failing three times with no model
2026-09-09 14:45:32 -07:00
Palash DebnathandClaude Opus 5 075ab7b68b fix(engines): say why an engine is unavailable instead of reporting a failed check
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
2026-09-09 14:37:27 -07:00
Palash Debnath e5a0a553e9 Merge remote-tracking branch 'origin/main' into fix/1845-setup-pill
# Conflicts:
#	CHANGELOG.md
2026-09-09 14:27:16 -07:00
Palash Debnath b7eb5428c7 Merge remote-tracking branch 'origin/main' into fix/1856-dictation-step
# Conflicts:
#	CHANGELOG.md
2026-09-09 14:27:12 -07:00
Palash Debnath bb4e99a336 Merge pull request #1964 from debpalash/fix/1957-untrusted-mount
fix(errors): explain a Windows untrusted-mount failure instead of echoing WinError 448
2026-09-09 14:26:49 -07:00
Palash DebnathandClaude Opus 5 accef57865 fix(dictation): stop the Accessibility prompt owning the screen indefinitely
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
2026-09-09 14:07:53 -07:00
Palash Debnath d07fa1ffff Merge remote-tracking branch 'origin/main' into fix/1957-untrusted-mount
# Conflicts:
#	CHANGELOG.md
2026-09-09 14:04:34 -07:00
Palash DebnathandClaude Opus 5 d313045b13 fix(setup): stop the last onboarding step failing three times with no model
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
2026-09-09 14:03:03 -07:00
Palash Debnath 6a44733297 Merge pull request #1962 from debpalash/land/queue2
Land the reviewed PR queue, part two: log panel, bootstrap mirror, setup diagnostics
2026-09-09 13:58:18 -07:00
Palash DebnathandClaude Opus 5 fd7d2d94dd fix(errors): explain a Windows untrusted-mount failure instead of echoing WinError 448
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
2026-09-09 13:47:51 -07:00
Palash Debnath f82f35ee97 Merge pull request #1963 from debpalash/land/queue3
Land the reviewed PR queue, part three: zh-CN locale and null capture timings
2026-09-09 13:43:45 -07:00
Palash Debnath 1c37c479be Merge remote-tracking branch 'origin/main' into land/queue2
# Conflicts:
#	CHANGELOG.md
2026-09-09 13:43:21 -07:00
Palash DebnathandClaude Opus 5 b55098f044 fix: repair the Colab cell edit and point the clear test at the split resolver
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
2026-09-09 13:43:17 -07:00
Palash Debnath 854609e459 Merge pull request #1952 from debpalash/feat/local-workspace-dictation-polish
Improve dictation controls and creative workspace layouts
2026-09-09 13:41:19 -07:00
Palash DebnathandClaude Opus 5 7d46563ea8 fix(i18n): finish the zh-CN locale and make its own parity suite pass
#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
2026-09-09 13:24:06 -07:00
Palash Debnath b6143cce08 Merge remote-tracking branch 'origin/pr/1959' into land/queue3 2026-09-09 13:18:49 -07:00
Palash Debnath 512aad5c48 Merge remote-tracking branch 'origin/main' into land/1952
# Conflicts:
#	frontend/src/pages/AudiobookTab.jsx
2026-09-09 13:17:13 -07:00
Palash Debnath 8526ff56f7 Merge remote-tracking branch 'origin/main' into land/queue2
# Conflicts:
#	CHANGELOG.md
2026-09-09 13:15:23 -07:00
Palash Debnath a68f85c51b Merge pull request #1958 from debpalash/land/queue1
Land the reviewed PR queue: audiobook, engines, bootstrap, setup and download fixes
2026-09-09 13:13:25 -07:00
Palash DebnathandClaude Opus 5 df7dab43b6 fix(i18n): keep the China-mirror comments out of the CJK guard
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
2026-09-09 13:12:22 -07:00
Palash DebnathandClaude Opus 5 f48a33edcf fix: address the review findings on the PRs landed here
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
2026-09-09 13:09:12 -07:00
Palash DebnathandClaude Opus 5 791a46d9dd fix(errors): match WebKit's space-labelled frames so the filter works on Safari
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
2026-09-09 13:02:24 -07:00
Palash Debnath 61eb27dbba Merge remote-tracking branch 'origin/pr/1892' into land/queue2 2026-09-09 13:02:15 -07:00
Palash Debnath 19d371f9d6 Merge remote-tracking branch 'origin/pr/1951' into land/queue2 2026-09-09 13:02:15 -07:00
Palash Debnath c760347339 Merge remote-tracking branch 'origin/pr/1925' into land/queue2
# Conflicts:
#	CHANGELOG.md
#	backend/api/routers/system.py
2026-09-09 13:02:15 -07:00
Palash Debnath ee8a8fb928 Merge remote-tracking branch 'origin/pr/1924' into land/queue2
# Conflicts:
#	CHANGELOG.md
2026-09-09 13:01:46 -07:00
Palash Debnath d7177be66c Merge remote-tracking branch 'origin/pr/1923' into land/queue2
# Conflicts:
#	CHANGELOG.md
2026-09-09 13:01:46 -07:00
Palash Debnath 88e59326fd Merge remote-tracking branch 'origin/pr/1920' into land/queue2
# Conflicts:
#	CHANGELOG.md
2026-09-09 13:01:45 -07:00
Palash Debnath f02880cc5c Merge remote-tracking branch 'origin/pr/1897' into land/queue2
# Conflicts:
#	CHANGELOG.md
2026-09-09 13:01:45 -07:00
Palash Debnath f9ff185a22 Merge remote-tracking branch 'origin/main' into land/1952
# Conflicts:
#	CHANGELOG.md
2026-09-09 12:58:49 -07:00
Palash Debnath cc96de27b8 Merge remote-tracking branch 'origin/main' into land/queue1 2026-09-09 12:58:32 -07:00
Palash DebnathandClaude Opus 5 e8af4fae12 fix(engines): give audiocpp its docs link and pin docs_url in the registry shape
#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
2026-09-09 12:58:29 -07:00
Palash Debnath e0309be21f Merge pull request #1955 from debpalash/fix/windows-backend-watchdog-hang
fix(backend): stop Windows desktop launches freezing at "Loading ML runtime"
2026-09-09 12:56:20 -07:00
Chang-Jin-LeeandClaude Opus 5 2e4465a3f4 fix(capture): pass a null segment end through instead of rounding it
`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>
2026-09-10 04:33:18 +09:00
Palash Debnath 9ffc9d52d7 Merge remote-tracking branch 'origin/main' into land/1952
# Conflicts:
#	CHANGELOG.md
#	bun.lock
#	frontend/package.json
#	frontend/src/components/CaptureWidget.jsx
#	frontend/src/pages/Transcriptions.jsx
2026-09-09 12:31:06 -07:00
Palash Debnath cea6678ea7 Merge pull request #1956 from debpalash/fix/1800-stream-error-class
fix(errors): make an unclassified failure report say something true and specific
2026-09-09 12:30:09 -07:00
Palash Debnath 40199e39cf Merge remote-tracking branch 'origin/main' into work/1955
# Conflicts:
#	CHANGELOG.md
2026-09-09 12:29:36 -07:00
Palash DebnathandClaude Opus 5 c21696b9ae style: format the two files #1930 left unformatted
`bun run format:check` is a CI gate and SetupWizard.jsx plus its test came in
unformatted. No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
2026-09-09 12:28:05 -07:00
Palash Debnath 67817b89bf Merge remote-tracking branch 'origin/pr/1942' into land/queue1
# Conflicts:
#	CHANGELOG.md
2026-09-09 12:25:35 -07:00
Palash Debnath 20bc24c312 Merge remote-tracking branch 'origin/pr/1930' into land/queue1 2026-09-09 12:25:35 -07:00
Palash Debnath 810abf9187 Merge remote-tracking branch 'origin/pr/1919' into land/queue1
# Conflicts:
#	CHANGELOG.md
2026-09-09 12:25:35 -07:00
Palash Debnath cfb318c50b Merge remote-tracking branch 'origin/pr/1918' into land/queue1
# Conflicts:
#	CHANGELOG.md
#	frontend/src/components/BootstrapSplash.jsx
2026-09-09 12:25:34 -07:00
Palash Debnath 2e1e52f26e Merge remote-tracking branch 'origin/pr/1917' into land/queue1
# Conflicts:
#	CHANGELOG.md
2026-09-09 12:24:12 -07:00
Palash Debnath 2f663011e5 Merge remote-tracking branch 'origin/pr/1916' into land/queue1
# Conflicts:
#	CHANGELOG.md
2026-09-09 12:24:11 -07:00
Palash Debnath 773c4728c8 Merge remote-tracking branch 'origin/pr/1915' into land/queue1
# Conflicts:
#	CHANGELOG.md
2026-09-09 12:24:11 -07:00
Palash Debnath 6b68c4d9fe Merge remote-tracking branch 'origin/pr/1914' into land/queue1
# Conflicts:
#	CHANGELOG.md
2026-09-09 12:24:11 -07:00
Palash Debnath 5f5bb04f41 Merge remote-tracking branch 'origin/pr/1912' into land/queue1
# Conflicts:
#	CHANGELOG.md
2026-09-09 12:24:10 -07:00
Palash Debnath cea00a4889 Merge pull request #1908 from psiberfunk/codex/fix-synthesis-progress-indeterminate
fix(studio): show honest synthesis progress
2026-09-09 12:22:07 -07:00
Palash Debnath 6268eacf35 Merge pull request #1903 from psiberfunk/codex/fix-transcriptions-dictation
fix(dictation): make Transcriptions capture actionable
2026-09-09 12:22:01 -07:00
Palash Debnath a2653824ce Merge pull request #1896 from psiberfunk/fix/bootstrap-splash-observed-stages-1894
fix(bootstrap): stop showing first-run install steps on warm starts
2026-09-09 12:21:55 -07:00
Palash Debnath 71056bcdb4 Merge pull request #1890 from psiberfunk/fix/widget-pill-shadow-clip
fix(dictation): stop clipping the widget pill's drop shadow into a rectangle
2026-09-09 12:21:49 -07:00
Palash Debnath b5441bea36 Merge pull request #1888 from psiberfunk/fix/macos-dock-reopen
fix(desktop): handle Dock-icon reopen on macOS
2026-09-09 12:21:44 -07:00
Palash Debnath d7751ad7ce Merge pull request #1885 from psiberfunk/fix/capture-pill-setup-tooltip
fix(dictation): give the setup-state pill label a hover tooltip
2026-09-09 12:21:38 -07:00
Palash Debnath 2b027cb54a Merge branch 'main' into fix/1859-launchpad-scroll-shrink 2026-09-09 12:20:27 -07:00
Palash Debnath d2fb292217 Merge pull request #1938 from debpalash/dependabot/npm_and_yarn/frontend/vitest-4.1.11
chore(deps-dev): bump vitest from 4.1.9 to 4.1.11 in /frontend
2026-09-09 12:18:24 -07:00
Palash DebnathandClaude Opus 5 7afb82bb43 style: format the four files this branch left unformatted
`bun run format:check` is a CI gate and these four came in unformatted. No
behaviour change — running the formatter in write mode touches nothing else
in the tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ypcgSsh5j2PEonSJiAU1S
2026-09-09 12:14:47 -07:00
Palash DebnathandClaude Opus 5 d630db29f5 fix(errors): stop a context-free failure borrowing another stage's remediation
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
2026-09-09 12:13:25 -07:00
Palash DebnathandClaude Fable 5.1 f49d31d5b3 test(backend): only accept positive startup evidence in the Windows watchdog test
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
2026-09-09 12:06:46 -07:00
Palash DebnathandClaude Opus 5 4f4d21ff5b fix(errors): name the backend error class on an unclassified streaming failure
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
2026-09-09 12:06:40 -07:00
Palash DebnathandClaude Opus 5 b82205fbab fix(deps): regenerate the workspace lockfile for the vitest bump
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
2026-09-09 12:01:45 -07:00
Palash DebnathandClaude Opus 5 7601f0f1a8 fix(dictation): drop the token border the picker's nesting rail reintroduced
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
2026-09-09 12:00:42 -07:00
Palash DebnathandClaude Fable 5.1 5c7f8a8e7a test(backend): Windows integration regression for the desktop stdin watchdog hang
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
2026-09-09 11:51:17 -07:00
Palash DebnathandClaude Opus 5 7dcb88452c fix(deps): regenerate the root lockfile for this branch's refreshed pins
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
2026-09-09 11:49:29 -07:00
Palash Debnath db50111c54 fix(sidebar): restore project rename, lost when the Dub landing became history-only
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)
2026-09-09 11:48:22 -07:00
Palash Debnath 6b7c2bac8f feat(transcriptions): let the user pick which dictation model to install
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)
2026-09-09 11:47:38 -07:00
Palash DebnathandClaude Opus 5 48d1b22fd9 feat(dictation): model picker in the engine quick-switch, and a recoverable Windows dev stack
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
2026-09-09 11:47:31 -07:00
Palash DebnathandClaude Fable 5.1 6a6dd83efa docs(changelog): note the Windows backend startup hang fix (#1955)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCDZcBpP6QQa4dzUa8z6rh
2026-09-09 11:40:13 -07:00
Palash DebnathandClaude Fable 5.1 09a260bb26 fix(backend): stop Windows desktop launches freezing at "Loading ML runtime"
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
2026-09-09 11:39:50 -07:00
Palash Debnath a754faec04 docs: link dictation changes to PR 1952 2026-09-09 18:58:27 +05:30
Palash Debnath 8fc003ad7c Merge remote-tracking branch 'origin/main' into feat/local-workspace-dictation-polish 2026-09-09 18:55:28 +05:30
Palash Debnath 99a534882c feat: improve dictation controls and creative workspaces 2026-09-09 18:55:19 +05:30
Chang-Jin-LeeandClaude Opus 5 81f889f3ff fix(errors): anchor the JSC frame pattern so a header cannot pose as one
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>
2026-09-09 22:13:00 +09:00
michaelhuamanfloresandClaude Sonnet 5 693a43b26c fix(setup): stop misreporting low-VRAM caveat as kernel-launch risk
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>
2026-09-09 07:47:18 -05:00
Chang-Jin-Lee d848c2eb3b Merge remote-tracking branch 'upstream/main' into fix/1847-bootstrap-log-retention 2026-09-09 12:55:59 +09:00
Chang-Jin-Lee d5d28ddabf Merge remote-tracking branch 'upstream/main' into fix/1866-engine-unavailability-reason 2026-09-09 12:55:59 +09:00
Chang-Jin-Lee 3622102ace Merge remote-tracking branch 'upstream/main' into fix/1848-bidi-start-truncation 2026-09-09 12:55:59 +09:00
Chang-Jin-Lee 31d7b4d251 Merge remote-tracking branch 'upstream/main' into fix/1859-launchpad-scroll-shrink 2026-09-09 12:55:59 +09:00
Chang-Jin-Lee 5e3af17d34 Merge remote-tracking branch 'upstream/main' into fix/1901-extension-error-reports 2026-09-09 12:55:59 +09:00
Chang-Jin-Lee 67ef5e22eb Merge remote-tracking branch 'upstream/main' into fix/tauri-clear-preserves-backend-stderr 2026-09-09 12:55:06 +09:00
Chang-Jin-Lee a84dc9e8ef Merge remote-tracking branch 'upstream/main' into fix/system-logs-rotation 2026-09-09 12:54:56 +09:00
marreiradigital b15fa1209a fix(download): log nao promete um fallback que ainda nao aconteceu
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.
2026-09-08 23:47:42 -04:00
marreiradigital 8a2c9a4828 Merge branch 'main' into fix/windows-dev-stack-and-download-resume
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`.
2026-09-08 23:28:28 -04:00
marreiradigital adebe98e0f test(download): resolve tambem o import pre-existente em runtime
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.
2026-09-08 23:25:47 -04:00
marreiradigital 97e0e70bf2 fix(download): entrega o caminho simples so na ultima tentativa
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.
2026-09-08 23:25:30 -04:00
marreiradigital 66f7ef8cfe fix(download): reentra no acelerador na proxima tentativa apos queda
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.
2026-09-08 23:12:14 -04:00
marreiradigital d01fb5e7cc docs(changelog): aponta as entradas para as issues corretas
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.
2026-09-08 22:49:58 -04:00
marreiradigital bc22276fec docs(changelog): registra as correcoes de download e de dev no Windows
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.
2026-09-08 22:46:56 -04:00
marreiradigital db9e9d7fa1 fix(dev): permite destravar porta de dev presa no Windows
`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.
2026-09-08 22:46:48 -04:00
marreiradigital e098d1280c fix(dev): normaliza caminho POSIX sem vazar a semantica do host
`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.
2026-09-08 22:46:29 -04:00
marreiradigital 2127aa7716 fix(dev): mata a arvore de processos do backend no Windows
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).
2026-09-08 22:45:44 -04:00
marreiradigital 0d3fb07c1f fix(download): segmenta em blocos limitados e retoma o acelerador
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)`.
2026-09-08 22:45:31 -04:00
dependabot[bot] 3ad31feccf chore(deps-dev): bump vitest from 4.1.9 to 4.1.11 in /frontend
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.9 to 4.1.11.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.11
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-09 00:18:48 +00:00
Palash Debnath 18f56a7940 Merge pull request #1926 from debpalash/feat/audiocpp-gpu-routing
feat(audiocpp): route native GPU backends
2026-09-08 23:53:34 +05:30
Palash Debnath 870b6f93ba fix(audiocpp): harden GPU runtime routing 2026-09-08 23:33:40 +05:30
Som 4c0bf2e010 fix(i18n): correct grammatical agreement in de/es/fr/pt/ru/sv consent.desc
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).
2026-09-08 15:38:20 +00:00
Som 7d6f5c5b0c fix(setup): give the dictation step its own distinct rail label
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.
2026-09-08 15:36:54 +00:00
Palash Debnath 4f4dc7e068 fix(audiocpp): budget unknown GPU memory safely 2026-09-08 21:02:44 +05:30
Som 56a420a898 fix(setup): stop repeating the consent and dictation step titles
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
2026-09-08 15:26:11 +00:00
Palash Debnath d5c0fa6f75 fix(worker): derive every engine capacity 2026-09-08 20:52:24 +05:30
Palash Debnath fe53e02693 fix(audiocpp): harden async and worker routing 2026-09-08 19:45:48 +05:30
Palash Debnath 56f8b616cc test(audiocpp): make CPU thread assertion portable 2026-09-08 19:10:44 +05:30
Palash Debnath f1016cdedd fix(audiocpp): budget low-memory Vulkan GPUs 2026-09-08 18:42:32 +05:30
Palash Debnath 68194c004d docs(changelog): note audio.cpp GPU routing 2026-09-08 18:32:46 +05:30
Palash Debnath 23dd8728d1 fix(audiocpp): preserve GPU routing across workers 2026-09-08 18:30:12 +05:30
Chang-Jin-LeeandClaude Opus 5 22ab31e0c7 fix(system): correct the Linux log path in the docstring, and follow the writer's override
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>
2026-09-08 21:46:35 +09:00
Palash Debnath fc237d3929 fix(audiocpp): harden native GPU routing 2026-09-08 18:10:51 +05:30
Palash Debnath 260519d4ee Merge branch 'main' into feat/audiocpp-gpu-routing
# Conflicts:
#	CHANGELOG.md
#	backend/engines/audiocpp/__init__.py
#	backend/engines/audiocpp/bootstrap.py
2026-09-08 17:29:25 +05:30
psiberfunk 9b1f2149ec Merge upstream main into fix/bootstrap-splash-observed-stages-1894 2026-09-08 07:23:38 -04:00
psiberfunk 311ff43de0 Merge upstream main into fix/run-sentinel-clear-before-slow-shutdown-1895 2026-09-08 07:22:34 -04:00
Palash Debnath 8f140e550d Merge pull request #1891 from debpalash/work/local-audiocpp-catalogue
feat(audiocpp): add CPU Breeze-TTS-2 backend and responsive catalogue rows
2026-09-08 16:43:12 +05:30
Palash Debnath 25c41e630c fix(audiocpp): gate readiness on model 2026-09-08 16:29:28 +05:30
Palash Debnath ffc5c07949 fix(audiocpp): require explicit model install 2026-09-08 16:21:08 +05:30
Palash Debnath 503407272e feat(audiocpp): route native GPU backends 2026-09-08 15:05:23 +05:30
Palash Debnath 6b2683eb32 fix(audiocpp): replace stale model aliases 2026-09-08 14:51:42 +05:30
Chang-Jin-LeeandClaude Opus 5 4999bac7a8 fix(system): stop the Tauri tab's Clear from wiping the backend's stderr
/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>
2026-09-08 18:04:05 +09:00
Palash Debnath 132fdf313e fix(audiocpp): support cross-filesystem model aliases 2026-09-08 14:34:00 +05:30
Palash Debnath 9bfa3ec751 fix(audiocpp): ship verified CPU runtime 2026-09-08 14:27:19 +05:30
Chang-Jin-LeeandClaude Opus 5 c637166f02 fix(errors): read the origin off the first stack FRAME, not the header
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>
2026-09-08 16:58:27 +09:00
Chang-Jin-LeeandClaude Opus 5 658269d42f fix(errors): stop offering to report a browser extension's exception
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>
2026-09-08 16:50:18 +09:00
nidhi-singh02 ae85dd36e4 fix(colab): install ASR model before transcription and dubbing 2026-09-08 13:17:49 +05:30
Chang-Jin-LeeandClaude Opus 5 17fda0d79f fix(system): survive a rollover racing the read, and stop trusting the scan
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>
2026-09-08 16:46:18 +09:00
Palash Debnath c6fbedbecb fix(audiocpp): resolve security review findings 2026-09-08 13:09:56 +05:30
Palash Debnath e2b5b3d6ed fix(audiocpp): validate the real pinned runtime 2026-09-08 13:02:35 +05:30
Chang-Jin-LeeandClaude Opus 5 15c6cc698d fix(launchpad): constrain the readiness checklist too
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>
2026-09-08 14:42:19 +09:00
Chang-Jin-LeeandClaude Opus 5 3beebc6d57 fix(system): tail the backend log across a rollover, and clear the backups
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>
2026-09-08 14:39:49 +09:00
Chang-Jin-LeeandClaude Opus 5 a9d90936ce fix(launchpad): let the page scroll instead of compressing its own hero
`.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>
2026-09-08 14:27:54 +09:00
Chang-Jin-LeeandClaude Opus 5 4b601ee6c1 fix(bootstrap): hold the run in a ref, and dedup only the handover seam
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>
2026-09-08 14:15:58 +09:00
Chang-Jin-LeeandClaude Opus 5 03c1396b14 fix(bootstrap): keep the whole first-run log, render only the tail
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>
2026-09-08 14:03:04 +09:00
Chang-Jin-LeeandClaude Opus 5 276bf626da docs(changelog): match the narrowed health-log wording
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>
2026-09-08 13:44:36 +09:00
Chang-Jin-LeeandClaude Opus 5 09a582c65a fix(engines): narrow the health log field to what the probe did
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>
2026-09-08 13:43:36 +09:00
Chang-Jin-LeeandClaude Opus 5 adfe7b8588 fix(engines): give an unavailable engine row somewhere to send the user
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>
2026-09-08 13:29:57 +09:00
Chang-Jin-LeeandClaude Opus 5 d3eb502395 fix(setup): isolate start-truncated paths from the bidi algorithm
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>
2026-09-08 12:28:28 +09:00
psiberfunk a88ad00bdf fix(audiobook): show the recovery manifest path 2026-09-07 23:02:36 -04:00
psiberfunk ceef6d66bd docs: place OmniVoice fix in highlights 2026-09-07 22:37:19 -04:00
psiberfunk 035add4c5c fix(audiobook): keep recovery actions available 2026-09-07 22:36:25 -04:00
psiberfunk 31abce74b6 test(engines): cover hidden MPS compatibility 2026-09-07 22:33:41 -04:00
psiberfunk 7668d045fa fix(audiobook): refresh resumable jobs after resume 2026-09-07 22:03:02 -04:00
psiberfunk 7157fd0fbe fix(engines): preserve hidden MPS compatibility state 2026-09-07 21:56:46 -04:00
psiberfunk fb98bbfaf6 fix(audiobook): surface interrupted renders 2026-09-07 21:55:40 -04:00
psiberfunk a935c08d6a fix(audiobook): scale chapter timeouts 2026-09-07 21:45:55 -04:00
psiberfunk 8d60929236 fix(engines): hide redundant OmniVoice MPS sidecar 2026-09-07 21:43:58 -04:00
psiberfunk 6e651ecbbd test(studio): cover progress prop forwarding 2026-09-07 20:16:38 -04:00
psiberfunk 4a53fd2668 fix(studio): show honest synthesis progress 2026-09-07 20:08:50 -04:00
psiberfunk 955c62e671 test(dictation): pin timeout completion race 2026-09-07 18:26:10 -04:00
psiberfunk 89c5d46660 test(dictation): cover acknowledgement failure 2026-09-07 18:15:40 -04:00
psiberfunk 70091bf97c fix(dictation): close delivery races 2026-09-07 18:11:12 -04:00
psiberfunk 0189b2a84f fix(dictation): report capture acceptance 2026-09-07 18:06:15 -04:00
psiberfunk 0d7625ee1a fix(dictation): make transcription capture actionable 2026-09-07 17:41:04 -04:00
psiberfunkandClaude Opus 5 5d99271865 style(test): satisfy oxfmt on the widget pill shadow test
"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>
2026-09-07 15:20:16 -04:00
psiberfunkandClaude Opus 5 b5879343a0 fix(bootstrap): treat leaving failed as a new attempt
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>
2026-09-07 14:05:19 -04:00
psiberfunkandClaude Opus 5 5e9538a049 fix(bootstrap): open the attempt boundary when a retry starts, not when polled
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>
2026-09-07 14:02:15 -04:00
psiberfunkandClaude Opus 5 ece08bd790 fix(bootstrap): derive observed stages from logs, and reset them on retry
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>
2026-09-07 13:57:02 -04:00
psiberfunkandClaude Opus 5 e3e8e80888 fix(lifecycle): scope the sentinel-clear comment honestly for Windows
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>
2026-09-07 13:52:42 -04:00
psiberfunkandClaude Opus 5 4ad5aa2831 fix(lifecycle): clear the run sentinel before the slow shutdown work
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>
2026-09-07 13:48:37 -04:00
psiberfunkandClaude Opus 5 1c344bf9ec fix(bootstrap): stop showing first-run install steps on warm starts
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>
2026-09-07 13:48:18 -04:00
杨月政andCursor 37aea507a4 fix(setup): lengthen HF connectivity probe timeouts to 8s
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>
2026-09-07 23:18:33 +08:00
杨月政andCursor 9979f1a84e fix(bootstrap): apply China PyPI mirror on repair uv sync
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>
2026-09-07 23:04:24 +08:00
Palash Debnath af216d8411 docs: reference local work draft PR in changelog 2026-09-07 20:21:47 +05:30
Palash Debnath 65ea0dc376 feat: preserve audio.cpp backend and responsive catalogue work 2026-09-07 20:20:54 +05:30
psiberfunkandClaude Opus 5 3105647afb test(capture): assert the error detail, not just a differing title
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>
2026-09-07 10:07:14 -04:00
psiberfunkandClaude Opus 5 50be1944b4 test(capture): cover the error branch of the pill title
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>
2026-09-07 10:02:23 -04:00
psiberfunkandClaude Opus 5 e014d63253 test(widget): pin the pill's content width instead of bounding it
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>
2026-09-07 10:00:58 -04:00
psiberfunkandClaude Opus 5 55bcfbdb6a test(macos): make the pill-visible reopen case actually discriminating
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>
2026-09-07 10:00:26 -04:00
psiberfunkandClaude Opus 5 12b398b662 fix(dictation): stop clipping the widget pill's drop shadow into a rectangle
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>
2026-09-07 09:43:32 -04:00
Palash Debnath 9790d28922 Merge pull request #1883 from debpalash/codex/fix-windows-nonadmin-smoke
fix(ci): prepare hosted Windows policy for non-admin MSI smoke
2026-09-07 18:43:14 +05:30
psiberfunkandClaude Opus 5 7736e9cc60 fix(macos): key Reopen on the main window, not any visible window
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>
2026-09-07 09:09:34 -04:00
psiberfunkandClaude Opus 5 fdeb8a40fc fix(desktop): handle Dock-icon reopen on macOS (#1887)
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>
2026-09-07 09:06:09 -04:00
psiberfunkandClaude Opus 5 ebbe74b14b fix(dictation): give the setup-state pill label a hover tooltip
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>
2026-09-07 09:00:12 -04:00
Palash Debnath 6a733a4c52 docs: reference installer smoke fix in changelog 2026-09-07 18:21:21 +05:30
Palash Debnath 5cde34c113 fix(ci): prepare hosted Windows policy for non-admin MSI smoke 2026-09-07 18:20:43 +05:30
Palash Debnath bd84169ff2 Merge pull request #1881 from debpalash/codex/fix-per-user-resource-snapshot
fix(windows): preserve frontend resources during per-user MSI build
2026-09-07 17:13:27 +05:30
Palash Debnath 4486b479a1 test: run generated asset hook inside MSI fixture 2026-09-07 16:44:14 +05:30
Palash Debnath 74aa444485 docs: reference installer resource fix PR 2026-09-07 16:43:01 +05:30
Palash Debnath e1e96ae5a2 fix: preserve frontend resources during per-user MSI build 2026-09-07 16:41:20 +05:30
Palash Debnath 0d39a5b283 test: exercise MSI build hooks with changing resource filenames 2026-09-07 16:40:12 +05:30
yearthmain e059c4f330 docs(changelog): note the zh-CN locale completion (#1877)
Signed-off-by: yearthmain <yearthmain@gmail.com>
2026-09-07 16:56:13 +08:00
yearthmain 8998faa98b fix(i18n): translate all 486 missing zh-CN keys, tighten parity ratchet to 0
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>
2026-09-07 16:47:30 +08:00
292 changed files with 22091 additions and 4007 deletions
+8 -2
View File
@@ -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.
+30 -1
View File
@@ -443,11 +443,37 @@ jobs:
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as the main pytest job
HF_HUB_CACHE: ${{ runner.temp }}/pockettts-empty-hf-cache
# The isolated backend session, on Windows. The `test` job runs it on
# Linux only, which is how four tests that CANNOT pass on Windows shipped
# unnoticed: two reach for os.WNOHANG and os.waitid (POSIX-only, an
# AttributeError before the first assertion), one asserts a RuntimeError
# that `backend_drain_fd` returns None instead of raising off POSIX, and
# one raced the OS reaping a crashed child — a race Linux won and Windows
# lost every time. All four were invisible to CI and hit every Windows
# contributor on their first `pytest` run. Forty seconds closes the class.
- name: Isolated backend session (Windows)
if: runner.os == 'Windows' && matrix.backend_supported
run: uv run --no-sync pytest backend/tests/ -q --tb=short
env:
HF_HUB_OFFLINE: "1"
# Artifact commits depend on native Windows rename/replace semantics;
# Linux emulation cannot exercise sharing rules or path parsing.
# test_worker_task_store and test_worker_inbound_transport joined this
# step after a Windows run found a real portability bug the Linux-only
# `test` job could not see: a staged input's artifact id was built with
# os.path.join, so a Windows control plane persisted and shipped
# `inputs\<sha>.wav` — which a Linux worker cannot resolve. These suites
# need no ffmpeg, so they cost seconds here.
- name: Remote-worker artifact paths (Windows)
if: runner.os == 'Windows' && matrix.backend_supported
run: uv run --no-sync pytest tests/test_worker_upload_server.py tests/test_worker_server_integrity.py -q --tb=short
run: >-
uv run --no-sync pytest
tests/test_worker_upload_server.py
tests/test_worker_server_integrity.py
tests/test_worker_task_store.py
tests/test_worker_inbound_transport.py
-q --tb=short
env:
HF_HUB_OFFLINE: "1"
HF_HUB_CACHE: ${{ runner.temp }}/worker-artifact-empty-hf-cache
@@ -464,6 +490,9 @@ jobs:
- name: Bundle canonical system and per-user templates with a tiny payload
shell: pwsh
run: ./scripts/diagnose-windows-wix.ps1
- name: Restore hosted Installer policy after failed standard-user installation
shell: powershell
run: ./scripts/test-msi-policy-cleanup.ps1
- name: Preserve verbose linker output and rendered authoring
if: always()
uses: actions/upload-artifact@v4
+1 -1
View File
@@ -795,7 +795,7 @@ jobs:
set -euo pipefail
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name '*Current*User*.msi' | head -1)
powershell.exe -NoProfile -ExecutionPolicy Bypass \
-File scripts/smoke-per-user-msi.ps1 -MsiPath "$(cygpath -w "$MSI")"
-File scripts/smoke-per-user-msi.ps1 -MsiPath "$(cygpath -w "$MSI")" -PrepareHostedRunner
# linuxdeploy re-links .DirIcon as an ABSOLUTE symlink into the build
# machine AFTER tauri's files-map has placed the real icon bytes — the
+5
View File
@@ -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
+119 -52
View File
@@ -8,21 +8,73 @@ the frozen-backend fallback mirror it for their toolchains.
## [Unreleased]
## [0.5.2] — 2026-09-10
**Highlights**
- 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 (#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)
- 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)
- MCP tools can now keep audio out of agent context by returning files and accepting base-path-confined file inputs (#1760) — thanks @agudmund!
- Hear a dub line as you type it — an opt-in live preview streams TTS for the edited segment (#1769) — thanks @mvanhorn!
- Studio gains a Convert method: re-say any clip in one of your saved voices, speech to speech, fully local (#1765) — thanks @mvanhorn!
- Hardsub video export gains an opt-in karaoke word-highlight caption style (#1764) — thanks @mvanhorn!
- The batch queue can now watch a folder: new videos dropped into it are dubbed automatically (#1768) — thanks @mvanhorn!
- The audiobook player now shows the chapter text and highlights the word being narrated (#1766) — thanks @mvanhorn!
- The dub editor gains a casting board: drag voice chips onto speakers, dropdowns stay in sync (#1767) — thanks @mvanhorn!
### 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)
@@ -42,27 +94,84 @@ the frozen-backend fallback mirror it for their toolchains.
- 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
- The audiobook result is now a synced-lyrics player: chapter text follows playback with the current word highlighted and click-to-seek, timed from the render's own chapter durations with a karaoke-style even split — no ASR pass, fully local (#1766) — thanks @mvanhorn!
- The dub CAST strip expands into a project-level casting board: drag voice chips (clone profiles, design presets, Default) onto speaker rows — or pick from a keyboard listbox — writing the same per-speaker cast fields as the existing dropdowns (#1767) — thanks @mvanhorn!
- Studio's new Convert method turns a dropped or recorded clip into an existing voice profile's voice, with optional source-duration matching (#1765) — thanks @mvanhorn!
- Opt-in watch folder on the batch queue: pick a directory once and new videos are auto-enqueued with your last Add-to-queue settings, with pause/stop controls and copy-in-progress protection — files upload as bytes, paths never leave the app (#1768) — thanks @mvanhorn!
- Hardsub export can now burn karaoke word-highlight captions: an opt-in Line | Karaoke control renders a word-timed ASS sweep from timings persisted at transcription, with an even-split fallback for older jobs and translated tracks, plus a `GET /dub/ass/{job_id}` sidecar (#1764) — thanks @mvanhorn!
- Windows releases now include an independently updatable per-user MSI that installs and uninstalls without elevation (#1713)
- Dub segments can now stream live TTS while you edit a translated line — opt-in toggle, existing `/ws/tts` socket, shared generation admission, exports still render at full quality (#1769) — thanks @mvanhorn!
- Engine status and diagnostic bundles now record loaded execution provider, device, precision, fallback stage, accelerator identity, runtime versions, and parent-process memory visibility (#1717)
### 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)
- RX 6700 XT/gfx1031 over WSL2 ROCDXG is now explicitly unverified until a published end-to-end GPU workload proves the mapped path (#1716)
### 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)
@@ -79,57 +188,15 @@ the frozen-backend fallback mirror it for their toolchains.
- Confucius accelerator routing tolerates failed device probes, and dots.tts keeps safe default precision on non-CUDA hosts (#1831) — 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)
## [0.5.2] — 2026-09-02
**Highlights**
- 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)
- MCP tools can now keep audio out of agent context by returning files and accepting base-path-confined file inputs (#1760) — thanks @agudmund!
- Hear a dub line as you type it — an opt-in live preview streams TTS for the edited segment (#1769) — thanks @mvanhorn!
- Studio gains a Convert method: re-say any clip in one of your saved voices, speech to speech, fully local (#1765) — thanks @mvanhorn!
- Hardsub video export gains an opt-in karaoke word-highlight caption style (#1764) — thanks @mvanhorn!
- The batch queue can now watch a folder: new videos dropped into it are dubbed automatically (#1768) — thanks @mvanhorn!
- The audiobook player now shows the chapter text and highlights the word being narrated (#1766) — thanks @mvanhorn!
- The dub editor gains a casting board: drag voice chips onto speakers, dropdowns stay in sync (#1767) — thanks @mvanhorn!
### Changed
- 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
- The audiobook result is now a synced-lyrics player: chapter text follows playback with the current word highlighted and click-to-seek, timed from the render's own chapter durations with a karaoke-style even split — no ASR pass, fully local (#1766) — thanks @mvanhorn!
- The dub CAST strip expands into a project-level casting board: drag voice chips (clone profiles, design presets, Default) onto speaker rows — or pick from a keyboard listbox — writing the same per-speaker cast fields as the existing dropdowns (#1767) — thanks @mvanhorn!
- Studio's new Convert method turns a dropped or recorded clip into an existing voice profile's voice, with optional source-duration matching (#1765) — thanks @mvanhorn!
- Opt-in watch folder on the batch queue: pick a directory once and new videos are auto-enqueued with your last Add-to-queue settings, with pause/stop controls and copy-in-progress protection — files upload as bytes, paths never leave the app (#1768) — thanks @mvanhorn!
- Hardsub export can now burn karaoke word-highlight captions: an opt-in Line | Karaoke control renders a word-timed ASS sweep from timings persisted at transcription, with an even-split fallback for older jobs and translated tracks, plus a `GET /dub/ass/{job_id}` sidecar (#1764) — thanks @mvanhorn!
- Windows releases now include an independently updatable per-user MSI that installs and uninstalls without elevation (#1713)
- Dub segments can now stream live TTS while you edit a translated line — opt-in toggle, existing `/ws/tts` socket, shared generation admission, exports still render at full quality (#1769) — thanks @mvanhorn!
- Engine status and diagnostic bundles now record loaded execution provider, device, precision, fallback stage, accelerator identity, runtime versions, and parent-process memory visibility (#1717)
### Docs
- 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)
- RX 6700 XT/gfx1031 over WSL2 ROCDXG is now explicitly unverified until a published end-to-end GPU workload proves the mapped path (#1716)
### Fixed
- 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)
+10 -3
View File
@@ -59,7 +59,9 @@
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 Dubbings 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 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.
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.
@@ -76,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.
@@ -85,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
```
@@ -229,7 +236,7 @@ Engine support is capability-specific. Check cloning, language, platform, memory
| [**Sherpa-ONNX**](docs/engines/sherpa-onnx.md) | 20+ | No | No | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| [**IndexTTS 2.5** ⚡](docs/engines/indextts.md) | ZH · EN · JA · ES · AR | Yes | No | CUDA/CPU | CPU | CUDA/CPU | Bilibili model license¹ |
| [**OmniVoice GGUF** ⚡](docs/engines/omnivoice-gguf.md) | 600+ | Yes | Yes | CUDA/CPU | MPS/CPU | CUDA/CPU | [AGPL-3.0](LICENSE) app · [review the derivative model terms](https://huggingface.co/Serveurperso/OmniVoice-GGUF#license)³ |
| [**OmniVoice (subprocess)** ⚡](docs/engines/omnivoice-subprocess.md) | 600+ | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0 code, CC-BY-NC weights](https://huggingface.co/k2-fsa/OmniVoice#license)³ |
| [**OmniVoice (subprocess; opt-in off MPS)** ⚡](docs/engines/omnivoice-subprocess.md) | 600+ | Yes | Yes | CUDA/CPU | MPS via default OmniVoice | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0 code, CC-BY-NC weights](https://huggingface.co/k2-fsa/OmniVoice#license)³ |
| [**PocketTTS** ⚡](docs/engines/pockettts.md) | EN · FR · DE · PT · IT · ES | Yes | No | CPU | CPU | CPU | CC-BY-4.0, gated² |
| [**Supertonic 3** ⚡](docs/engines/supertonic3.md) | 31 | No | No | CPU | CPU | CPU | OpenRAIL-M |
| [**MOSS-TTS-v1.5** ⚡](docs/engines/moss-tts-v15.md) | 31 | Yes | No | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
+114 -2
View File
@@ -32,17 +32,129 @@ def _public_routing_reason(status: object, diagnostic: object) -> str:
return _ROUTING_BY_STATUS.get(status, _ROUTING_UNAVAILABLE)
# Categories for WHY an engine is unavailable. The probe's own sentence cannot
# cross the boundary — it carries exception text, local paths and sometimes
# credentials — but "Engine unavailable. Check installation and configuration."
# told the user nothing at all, and "Last error: A previous engine check
# failed." reads like a crash rather than "you have not installed this yet"
# (#1866). Classifying the private diagnostic into an owned sentence keeps the
# boundary intact and still names the kind of problem and the place to fix it.
_UNAVAILABLE_NOT_INSTALLED = (
"This engine's package isn't installed yet. Install it from "
"Model Catalogue → Engines."
)
# An engine gated behind an in-app license review (Supertonic-3, PocketTTS).
# The Model Catalogue shows its Accept button only when the reason matches
# /license not accepted/i (EngineCompatibilityMatrix.reasonMentionsLicense), so
# this sentence must keep those words: collapsing it into the generic line hid
# the only way to enable those engines.
_UNAVAILABLE_LICENSE = (
"License not accepted yet. Review and accept it in "
"Model Catalogue → Engines to enable this engine."
)
# An engine that cannot run on this machine at all: Apple-Silicon-only MLX,
# PyTorch with no Intel Mac build. "Isn't installed yet" or "check
# installation" sent people after an install that could never work.
_UNAVAILABLE_PLATFORM = (
"This engine doesn't run on this computer's platform. Its guide lists "
"the platforms it supports."
)
# Apple Silicon whose PyTorch cannot use the GPU (MPS): the platform is
# right, the installation is not. MLX-Audio / MLX-Whisper need MPS (#390).
_UNAVAILABLE_NO_MPS = (
"This engine needs Apple's GPU (MPS), and this installation's PyTorch "
"can't use it. Updating macOS or reinstalling VoiceStudio usually "
"restores it."
)
_UNAVAILABLE_NEEDS_CONFIG = (
"This engine needs to be configured before it can run. Open "
"Model Catalogue → Engines to finish setting it up."
)
_UNAVAILABLE_FILE_MISSING = (
"A file this engine needs is missing or unreadable. Reinstall it from "
"Model Catalogue → Engines."
)
# The same two cases for an engine the app cannot install for you. "Install it
# from Model Catalogue → Engines" sent people to a page with no Install button
# for that engine — most of the catalogue — which reads as the app being
# broken. The row's own guide link (``docs_url``) is the real next step.
_UNAVAILABLE_NOT_INSTALLED_MANUAL = (
"This engine isn't installed yet, and it has no one-click install. "
"Its guide lists the install steps."
)
_UNAVAILABLE_FILE_MISSING_MANUAL = (
"A file this engine needs is missing or unreadable. Its guide lists the "
"install steps."
)
_MANUAL_INSTALL_VARIANT = {
_UNAVAILABLE_NOT_INSTALLED: _UNAVAILABLE_NOT_INSTALLED_MANUAL,
_UNAVAILABLE_FILE_MISSING: _UNAVAILABLE_FILE_MISSING_MANUAL,
}
# Matched against the lowered probe text. Ordered most specific first: a
# missing file often also says "not installed", and the file case has the more
# useful remedy of the two.
_UNAVAILABLE_SIGNATURES = (
# First: its probe text also says "Open Model Catalogue", and the
# license is the one gap only the user can close.
(_UNAVAILABLE_LICENSE, ("license not accepted",)),
# Before the install and file checks: a platform reason often also says
# "unavailable" or names a missing wheel, and no install can fix it. Not
# "apple silicon only": mlx-audio says that on an M-series Mac too, when
# the package is merely missing and installing does help.
(_UNAVAILABLE_PLATFORM, (
"requires apple silicon", "not supported on this platform",
"unavailable on intel macs", "no macos x86_64 wheel",
"no windows install", "not supported on windows",
)),
(_UNAVAILABLE_NO_MPS, ("torch mps unavailable",)),
(_UNAVAILABLE_FILE_MISSING, (
"file is missing", "file is empty", "file is unreadable",
"script missing", "binary", "not found at",
)),
(_UNAVAILABLE_NEEDS_CONFIG, (
"environment variable", "configure a server endpoint", "api key",
"unconfigured", "set the", "base url",
)),
(_UNAVAILABLE_NOT_INSTALLED, (
"not installed", "package missing", "not available", "no module named",
"import ", "unavailable:", "failed to load",
)),
)
def _public_unavailable_reason(diagnostic: object) -> str:
"""Map a private availability probe to an accurate stable category."""
private = diagnostic.lower() if isinstance(diagnostic, str) else ""
for public, markers in _UNAVAILABLE_SIGNATURES:
if any(marker in private for marker in markers):
return public
return _UNAVAILABLE
def public_backends(entries: list[dict]) -> list[dict]:
"""Copy registry entries while replacing service diagnostics.
Availability probes may contain exception text, local paths, tracebacks, or
credentials. Installation hints are registry-authored and remain intact.
credentials. Registry-authored fields are not probe output and remain
intact: ``install_hint``, ``setup_snippet`` and ``docs_url`` are all
VoiceStudio-owned constants keyed on the engine id, so an unavailable row
still has something actionable to show and somewhere to send the user
(#1866) even though ``reason``/``last_error`` are replaced here.
"""
safe: list[dict] = []
for entry in entries:
item = dict(entry)
if item.get("reason") is not None:
item["reason"] = _UNAVAILABLE
reason = _public_unavailable_reason(item["reason"])
# Only a row that explicitly says it has NO one-click install gets
# the manual wording. Rows without the field (ASR, LLM,
# translation — some of which have installers of their own) keep
# the line that points at Model Catalogue.
if item.get("one_click_install") is False:
reason = _MANUAL_INSTALL_VARIANT.get(reason, reason)
item["reason"] = reason
if item.get("last_error") is not None:
item["last_error"] = _PREVIOUS_FAILURE
if item.get("routing_reason") is not None:
+17 -1
View File
@@ -718,6 +718,10 @@ def _remote_chapter_call(chapter, *, engine_id, default_voice, voice_map,
"expressive": opts.to_manifest(), "watermark": bool(watermark_enabled()),
}
signature = hashlib.sha256(json.dumps(params, sort_keys=True, default=str).encode()).hexdigest()
# The worker synthesizes from ``spans``, but the gateway and scheduler read
# top-level ``text`` to scale the remote execution deadline. Add this after
# the signature so existing content-addressed remote cache keys still hit.
params["text"] = "\n".join(row["text"] for row in rows)
wav_path = os.path.join(cache_dir, f"remote-{signature}.wav")
def decode(result):
@@ -739,7 +743,7 @@ async def _run_chapter(chapter, *, operation="audiobook", decision, job, default
voice_map, lexicon, cache_dir):
"""Run one chapter through the gateway; local preparation stays lazy."""
from services import gpu_gateway
from services.tts_backend import active_backend_id
from services.tts_backend import active_backend_id, get_backend_class
engine_id = active_backend_id()
remote, remote_cache = _remote_chapter_call(
@@ -753,15 +757,27 @@ async def _run_chapter(chapter, *, operation="audiobook", decision, job, default
return remote_cache, float(info.duration), True, None
async def prepare_local():
from services.model_manager import generate_timeout_s
synth, sr, resolve, local_engine = await _prepare_synth(
default_voice, language=language, opts=opts, voice_map=voice_map
)
try:
timeout_engine = get_backend_class(local_engine)
except ValueError:
# Tests and third-party integrations may inject a synth under a
# non-catalogue id. Keep the canonical host/text policy available;
# registered production engines still add their routing metadata.
timeout_engine = None
return gpu_gateway.LocalCall(
fn=lambda: _render_chapter_cached(
chapter, synth, sr, local_engine, resolve, cache_dir, lexicon,
language, opts, voice_map,
),
what="Audiobook chapter",
timeout=generate_timeout_s(
remote.params["text"], engine=timeout_engine
),
)
return await gpu_gateway.run(
+20 -4
View File
@@ -28,6 +28,17 @@ router = APIRouter()
logger = logging.getLogger("omnivoice.capture")
def _timing(value):
"""A segment timing, or ``None`` when the engine could not determine one.
``dict.get(key, 0)`` hands back a stored ``None`` rather than the default,
because the key is present — so rounding it raised and took a transcript
that was otherwise fine down with it (#1904). Pass the null through instead:
the segment list renders whichever half of the range is known.
"""
return round(value, 2) if isinstance(value, (int, float)) else None
def _truthy(value: Optional[str]) -> bool:
"""Parse a multipart form flag. Treats '1'/'true'/'yes'/'on'/'auto'
(any case) as on; everything else — including None — as off."""
@@ -162,10 +173,15 @@ async def transcribe_audio(
from services.text_polish import polish_text
full_text = polish_text(full_text)
# Calculate audio duration from segments if available
# Calculate audio duration from segments if available. A segment whose
# timing the engine could not determine carries end=None (sherpa's
# _sherpa_result when the sample rate yields no duration, and every
# plain-text OpenAI-compatible response), so measure only the ones that
# have a number and keep 0.0 when none do.
duration = 0.0
if segments:
duration = max(s.get("end", 0) for s in segments)
ends = [e for e in (s.get("end") for s in segments) if isinstance(e, (int, float))]
duration = max(ends) if ends else 0.0
detected_lang = result.get("language", language or "unknown")
@@ -194,8 +210,8 @@ async def transcribe_audio(
"text": full_text,
"segments": [
{
"start": round(s.get("start", 0), 2),
"end": round(s.get("end", 0), 2),
"start": _timing(s.get("start", 0)),
"end": _timing(s.get("end", 0)),
"text": s.get("text", "").strip(),
}
for s in segments
+29 -4
View File
@@ -55,6 +55,17 @@ from services.text_polish import polish_text
router = APIRouter()
logger = logging.getLogger("omnivoice.capture_ws")
def _timing(value):
"""A segment timing, or ``None`` when the engine could not determine one.
``dict.get(key, 0)`` returns a stored ``None`` rather than the default, so
rounding it raised (#1904). The null is the honest answer here — this module
emits it deliberately for un-endpointed utterances — and the segment list
renders whichever half of the range is known.
"""
return round(value, 2) if isinstance(value, (int, float)) else None
SPEECH_PROTOCOL = "voicestudio.speech.v1"
PLATFORM_STREAM_PATH = "/v1/audio/transcriptions/stream"
@@ -349,6 +360,7 @@ async def ws_transcribe(websocket: WebSocket):
audio_chunks: list[bytes] = []
total_bytes = 0
last_audio_time = time.monotonic()
paused = False
running = True
partial_text = ""
# Track whether the client initiated the disconnect. When True the
@@ -366,7 +378,7 @@ async def ws_transcribe(websocket: WebSocket):
message as the authoritative result and skip the duplicate HTTP
POST that used to run on every dictation.
"""
nonlocal total_bytes, last_audio_time, running, client_disconnected
nonlocal total_bytes, last_audio_time, running, client_disconnected, paused
try:
while running:
msg = await websocket.receive()
@@ -397,6 +409,10 @@ async def ws_transcribe(websocket: WebSocket):
total_bytes += len(data)
last_audio_time = time.monotonic()
continue
if msg.get("text") in ("PAUSE", "RESUME"):
paused = msg["text"] == "PAUSE"
last_audio_time = time.monotonic()
continue
if _is_end_control(msg.get("text")):
# Client signals end-of-audio but stays connected for `final`.
running = False
@@ -429,6 +445,9 @@ async def ws_transcribe(websocket: WebSocket):
if not running:
break
if paused:
continue
# Check silence timeout
if time.monotonic() - last_audio_time > SILENCE_TIMEOUT_S and total_bytes > MIN_BUFFER_BYTES:
running = False
@@ -1187,13 +1206,19 @@ async def _transcribe_buffer_full(
from services.refinement import collapse_repetitive_artifacts
full_text = collapse_repetitive_artifacts(full_text)
duration = max((s.get("end", 0) for s in segments), default=0.0)
# end=None means the engine could not determine the timing — this
# module writes exactly that in its own streaming payloads, and
# sherpa's _sherpa_result does too when the sample rate yields no
# duration. Measure only real numbers, and pass the nulls through
# rather than rounding them (#1904).
ends = [e for e in (s.get("end") for s in segments) if isinstance(e, (int, float))]
duration = max(ends) if ends else 0.0
return {
"text": full_text,
"segments": [
{"start": round(s.get("start", 0), 2),
"end": round(s.get("end", 0), 2),
{"start": _timing(s.get("start", 0)),
"end": _timing(s.get("end", 0)),
"text": s.get("text", "").strip()}
for s in segments
],
+12
View File
@@ -86,6 +86,18 @@ def list_dictation_models():
}
@router.get("/dictation/readiness", dependencies=[Depends(require_local)])
def dictation_readiness(model_id: str | None = None) -> dict:
"""Check capture's model selection without loading or downloading weights."""
from services.asr_backend import asr_model_missing_error
missing = asr_model_missing_error(
purpose="dictation",
sherpa_model_id=model_id or _read_prefs()["model_id"],
)
return {"ready": missing is None, "missing": missing}
@router.get("/dictation/prefs", dependencies=[Depends(require_local)])
def get_dictation_prefs():
return _read_prefs()
+20 -2
View File
@@ -540,12 +540,30 @@ _DUB_SOURCE_LANG_CODES = frozenset({
def _source_lang_override(value: str | None) -> str | None:
"""Normalize a user-selected source language; auto/und means detect."""
"""Normalize a user-selected source language; auto/und means detect.
A rejection NAMES the code it rejected. "Invalid source language code" on
its own cannot be acted on or reported usefully: it does not say which of
the ninety-odd 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 (#1960).
The value is a language code the user chose from a menu — not private
data — and the neighbouring engine validator already echoes its input the
same way.
"""
code = (value or "").strip().lower()
if code in {"", "auto", "und"}:
return None
if code not in _DUB_SOURCE_LANG_CODES:
raise HTTPException(status_code=400, detail="Invalid source language code")
raise HTTPException(
status_code=400,
detail=(
f"Invalid source language code: {code!r}. Pick a language from "
"the Dubbing source-language menu, or leave it on auto-detect."
),
)
return code
+73 -3
View File
@@ -42,10 +42,26 @@ _FAMILIES = {
}
def _catalogue_active_id(family: str, module) -> str:
"""Return the active id represented by the public engine catalogue."""
active = module.active_backend_id()
if family != "tts" or active != "omnivoice-subprocess":
return active
from core.device_caps import detect_host_caps
try:
return "omnivoice" if detect_host_caps().family == "mps" else active
except Exception:
return active
def _family_payload(family: str, module):
"""Public inventory plus whether an environment pin owns this family."""
return {
"active": module.active_backend_id(),
# MPS hides the explicit compatibility row, so legacy configs report
# the visible canonical equivalent as active to picker consumers.
"active": _catalogue_active_id(family, module),
"env_override": bool(os.environ.get(f"OMNIVOICE_{family.upper()}_BACKEND")),
"backends": public_backends(module.list_backends()),
}
@@ -188,6 +204,11 @@ async def uninstall_translation_engine(engine_id: str):
pkg = entry.get("pip_package")
if not pkg:
return {"status": "no_op", "engine": engine_id}
# The builtin flag is a promise someone has to remember to make; this
# check does not depend on it (#2019).
blocked = translation_engines.uninstall_blocker(engine_id)
if blocked:
raise HTTPException(status_code=blocked[0], detail=blocked[1])
rc, out = await translation_engines.run_pip(["uninstall", "-y", pkg])
if rc != 0:
raise HTTPException(status_code=500, detail=f"pip uninstall {pkg} failed ({rc}): {out[-1000:]}")
@@ -230,6 +251,11 @@ def install_sidecar_engine(engine_id: str):
from services import sidecar_install
try:
return sidecar_install.start_install(engine_id)
except sidecar_install.HostUnsupported as exc:
# The engine has an installer, but not one that can work on this
# machine. 409, not 404: the route is right, the host is the problem,
# and the message (a VoiceStudio-owned sentence) says what to do.
raise HTTPException(status_code=409, detail=str(exc))
except KeyError:
raise HTTPException(
status_code=404,
@@ -354,6 +380,9 @@ def engine_health(engine_id: str):
)
t0 = perf_counter()
# Stable exception class when the probe itself raised, None when it merely
# returned not-available. Never the exception text — see the log line below.
raised_class: str | None = None
if hasattr(cls, "health_check"):
# SubprocessBackend path — spawn sidecar (if not running) and ping.
# ``health_check`` already swallows its own exceptions per Plan
@@ -364,6 +393,7 @@ def engine_health(engine_id: str):
ok, msg = instance.health_check()
except Exception as exc:
ok, msg = False, f"{type(exc).__name__}: {exc}"
raised_class = type(exc).__name__
else:
# In-process backend — `is_available()` is the classmethod-level
# liveness check. Cheap and side-effect-free for every shipping
@@ -372,6 +402,7 @@ def engine_health(engine_id: str):
ok, msg = cls.is_available()
except Exception as exc:
ok, msg = False, f"{type(exc).__name__}: {exc}"
raised_class = type(exc).__name__
# Engine-owned output can contain much more than shaped HF tokens: local
# paths, arbitrary credentials, source lines, or a nested traceback.
@@ -379,7 +410,38 @@ def engine_health(engine_id: str):
latency_ms = (perf_counter() - t0) * 1000.0
if not ok:
logger.warning("Engine health check failed; details withheld")
# The response tells the user to "check the backend log for details",
# and docs/engines/*.md asks a user diagnosing an unavailable engine to
# copy that engine's log lines. The old line named neither the engine
# nor anything about the probe, so neither instruction could be
# followed (#1866).
#
# `probe=` reports what the PROBE DID, not what went wrong. It cannot
# classify the cause: SubprocessBackend.health_check() swallows its own
# exceptions per Plan 02-01's contract, so a dead sidecar and a package
# that was never installed both arrive here as `returned-unavailable`.
# Separating those needs structured failure metadata from the probes
# themselves, which is a wider change than this one.
#
# Still no diagnostic text and still not the caller-supplied id: the
# engine id comes off the resolved registry class and a raised probe
# contributes only its exception class, the same shape
# core.public_errors.public_failure() logs as `class=`.
# tests/test_response_safety.py pins that boundary and passes
# unchanged.
#
# The id is a class attribute off the registry rather than caller
# input, but this line is a log-injection surface either way, so it is
# flattened to a single token before it goes in.
engine_label = str(getattr(cls, "id", None) or cls.__name__)
engine_label = "".join(
c if (c.isalnum() or c in "-_.") else "-" for c in engine_label
)[:64]
logger.warning(
"Engine health check failed; engine=%s probe=%s, details withheld",
engine_label or "unknown",
f"raised:{raised_class}" if raised_class else "returned-unavailable",
)
return {
"id": engine_id,
"ok": bool(ok),
@@ -589,7 +651,15 @@ def select_engine(req: SelectEngineRequest):
if not family:
raise HTTPException(400, f"Unknown family: {req.family}. Expected one of tts/asr/llm.")
module, pref_key = family
available = {b["id"]: b for b in module.list_backends()}
# MPS intentionally hides the redundant explicit OmniVoice sidecar from
# the picker, but existing scripts and saved preferences may still submit
# that supported compatibility id directly.
rows = (
module.list_backends(include_hidden=True)
if req.family == "tts"
else module.list_backends()
)
available = {b["id"]: b for b in rows}
if req.backend_id not in available:
raise HTTPException(400, f"Unknown {req.family} backend: {req.backend_id!r}")
entry = available[req.backend_id]
+69 -17
View File
@@ -804,7 +804,14 @@ def _oom_friendly_reraise(e):
) from e
def _generate_timeout_s(text: str, *, execution_device=None, min_vram_gb=0.0) -> float:
def _generate_timeout_s(
text: str,
*,
execution_device=None,
min_vram_gb=0.0,
hardware_family=None,
vram_gb=None,
) -> float:
"""Wall-clock budget for one generate, scaled to the request.
Thin alias for the canonical helper, which moved to
@@ -819,7 +826,11 @@ def _generate_timeout_s(text: str, *, execution_device=None, min_vram_gb=0.0) ->
"""
from services.model_manager import generate_timeout_s
return generate_timeout_s(
text, execution_device=execution_device, min_vram_gb=min_vram_gb,
text,
execution_device=execution_device,
min_vram_gb=min_vram_gb,
hardware_family=hardware_family,
vram_gb=vram_gb,
)
@@ -1441,6 +1452,8 @@ async def generate_speech(
# local fallback call's timeout device-neutral so the closure is valid
# without pretending the control plane describes the remote worker.
_routing = {"effective_device": None}
_routing_hardware_family = None
_routing_vram_gb = None
if not _remote:
# Single-active-engine memory discipline: hand back any OTHER resident
@@ -1497,11 +1510,16 @@ async def generate_speech(
# 4090 from a Mac control plane would be refused by a gate describing
# a machine that is about to do nothing.
from core.device_caps import detect_host_caps
from services.engine_routing import resolve_routing, routing_notice
_routing = resolve_routing(
getattr(backend_cls, "gpu_compat", ("cpu",)), detect_host_caps(),
_engine_min_vram_gb,
from services.engine_routing import (
routing_notice,
runtime_compute_profile_async,
)
_routing = await runtime_compute_profile_async(
backend_cls, detect_host_caps()
)
_engine_min_vram_gb = _routing["min_vram_gb"]
_routing_hardware_family = _routing.get("runtime_hardware_family")
_routing_vram_gb = _routing.get("runtime_vram_gb")
if _routing["routing_status"] == "unavailable":
# The engine needs an accelerator this host lacks and has no CPU path.
raise HTTPException(status_code=400, detail=_routing["routing_reason"])
@@ -1725,8 +1743,13 @@ async def generate_speech(
local=gpu_gateway.LocalCall(
_remote_only_local_call(_target_label),
what="TTS generate",
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb),
timeout=_generate_timeout_s(
text,
execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb,
hardware_family=_routing_hardware_family,
vram_gb=_routing_vram_gb,
),
min_vram_gb=_engine_min_vram_gb,
),
remote=_remote_call,
@@ -2020,8 +2043,13 @@ async def generate_speech(
),
what="TTS generate",
min_vram_gb=_engine_min_vram_gb,
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb),
timeout=_generate_timeout_s(
text,
execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb,
hardware_family=_routing_hardware_family,
vram_gb=_routing_vram_gb,
),
on_abandon=release,
)
)
@@ -2041,8 +2069,13 @@ async def generate_speech(
),
what="TTS generate",
min_vram_gb=_engine_min_vram_gb,
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb),
timeout=_generate_timeout_s(
text,
execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb,
hardware_family=_routing_hardware_family,
vram_gb=_routing_vram_gb,
),
on_abandon=release,
)
)
@@ -2082,8 +2115,13 @@ async def generate_speech(
# Budget scaled to THIS chunk (#1190) — the flat
# 300s here is what made long streamed renders fail
# even after the v0.3.22 scaled budget shipped.
timeout=_generate_timeout_s(chunk_text, execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb),
timeout=_generate_timeout_s(
chunk_text,
execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb,
hardware_family=_routing_hardware_family,
vram_gb=_routing_vram_gb,
),
on_abandon=release,
)
)
@@ -2243,8 +2281,13 @@ async def generate_speech(
_REMOTE_OP,
local=gpu_gateway.LocalCall(
_local_render, what="TTS generate",
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb),
timeout=_generate_timeout_s(
text,
execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb,
hardware_family=_routing_hardware_family,
vram_gb=_routing_vram_gb,
),
min_vram_gb=_engine_min_vram_gb,
on_abandon=release,
),
@@ -2369,7 +2412,16 @@ async def generate_speech(
raise HTTPException(status_code=503, detail=str(e)) from e
except ValueError as e:
logger.error("Validation failed: %s", e)
raise HTTPException(status_code=400, detail=str(e)) from e
# Most ValueErrors here are VoiceStudio's own validation messages and
# are exactly what the user should read. A few are raw library text
# naming parameters and files the user cannot act on — those get the
# owned remedy for their class instead (#1879). Unclassified ones keep
# passing through, so this cannot swallow a good message.
from core.failure import classify, public_hint_for_topic
_topic = classify(str(e))
_owned = public_hint_for_topic(_topic) if _topic else ""
raise HTTPException(status_code=400, detail=_owned or str(e)) from e
except Exception as e:
tb = traceback.format_exc()
logger.error("Inference failed: %s\n%s", e, tb)
+3 -4
View File
@@ -325,10 +325,9 @@ async def create_speech(req: SpeechRequest):
# Routing gate (#21 — no silent CPU fallback), identical to REST /generate.
from core.device_caps import detect_host_caps
from services.engine_routing import resolve_routing, routing_notice
_routing = resolve_routing(
getattr(backend, "gpu_compat", ("cpu",)), detect_host_caps(),
getattr(backend, "min_vram_gb", 0.0),
from services.engine_routing import routing_notice, runtime_compute_profile_async
_routing = await runtime_compute_profile_async(
backend, detect_host_caps()
)
if _routing["routing_status"] == "unavailable":
raise HTTPException(status_code=400, detail=_routing["routing_reason"])
+12 -1
View File
@@ -32,7 +32,11 @@ from pydantic import BaseModel
from api.dependencies import require_admin
from core.db import db_conn
from services.pronunciation import apply_pronunciation, entries_for_language
from services.pronunciation import (
apply_pronunciation,
entries_for_language,
inert_entries_for_language,
)
logger = logging.getLogger("omnivoice.pronunciation")
router = APIRouter(dependencies=[Depends(require_admin)])
@@ -250,11 +254,18 @@ def test_substitution(req: PronTestRequest):
).fetchall()
substituted = apply_pronunciation(req.text, rows, req.language)
applied = entries_for_language(rows, req.language)
# IPA/CMU rows are validated and stored but not applied yet, so a term that
# DOES match can still change nothing. Reporting them separately keeps the
# dry run honest — otherwise it says "no entries match", which is wrong and
# sends the user to re-type an entry that was already correct (#1949).
inert = inert_entries_for_language(rows, req.language)
return {
"input": req.text,
"substituted": substituted,
"changed": substituted != req.text,
"applied_terms": sorted(applied.keys(), key=len, reverse=True),
# Present but not honoured: [{term, type}, …]. Empty on the happy path.
"inert_entries": inert,
}
+79 -8
View File
@@ -381,11 +381,60 @@ def _is_retryable_download_error(exc: BaseException) -> bool:
return is_hf_connectivity_error(str(exc))
def _segmented_retry_plan(
exc: BaseException, attempt: int, max_attempts: int
) -> tuple[bool, bool]:
"""What to do after the segmented accelerator failed on ``attempt``.
Returns ``(disable_accelerator, reraise)``.
A dropped connection is not the accelerator's fault, so the error is
re-raised for the outer retry: the next attempt re-enters
:func:`_segmented_snapshot`, which resumes from the ``.part`` manifest.
Falling straight through to ``snapshot_download`` instead would finish the
install from a separate ``.incomplete`` file and strand that manifest the
restart-from-zero this exists to prevent.
The final attempt is always reserved for the plain path, so the accelerator
can never be the reason an install fails outright. The two flags are
decoupled for that handover: the attempt that exhausts the accelerator still
re-raises, so the plain path starts on the LAST attempt rather than the
second-to-last. Disabling and falling through in the same attempt would
abandon the resumable manifest one attempt early and restart through a
separate file which is the failure this whole helper exists to avoid.
"""
if not _is_retryable_download_error(exc):
return True, False # the accelerator cannot work here at all
if attempt >= max_attempts:
# Nothing left to hand over to: take the plain path now rather than
# re-raising out of the loop with no fallback ever tried.
return True, False
return attempt >= max_attempts - 1, True
def _segmented_retry_note(disable: bool, reraise: bool) -> str:
"""How to describe the outcome of :func:`_segmented_retry_plan` in the log.
Three distinct states, and reading only ``disable`` conflates two of them:
the attempt that exhausts the accelerator is disabled AND re-raises, so the
fallback starts on the NEXT attempt, not this one.
"""
if not disable:
return "kept for the next attempt (resumes from its manifest)"
if reraise:
return "exhausted — retrying once more, then snapshot_download takes over"
return "disabled for this install — falling back to snapshot_download now"
@router.post("/models/install")
async def install_model(req: InstallModelRequest):
"""Download one HF repo snapshot; progress goes through the shared
``/setup/download-stream`` SSE feed."""
if req.repo_id not in [m["repo_id"] for m in KNOWN_MODELS]:
model_spec = next(
(model for model in KNOWN_MODELS if model["repo_id"] == req.repo_id),
None,
)
if model_spec is None:
raise HTTPException(
status_code=400,
detail=(
@@ -393,6 +442,7 @@ async def install_model(req: InstallModelRequest):
+ ", ".join(m["repo_id"] for m in KNOWN_MODELS)
),
)
allow_patterns = list(model_spec.get("allow_patterns") or []) or None
target = (req.target or "").strip()
if target != "local":
from services import gpu_gateway # noqa: PLC0415
@@ -450,6 +500,8 @@ async def install_model(req: InstallModelRequest):
"revision": revision_for(req.repo_id),
"max_workers": _download_max_workers(),
}
if allow_patterns:
dl_kwargs["allow_patterns"] = allow_patterns
_tqdm_cls = hf_progress.tracked_tqdm_class()
if _tqdm_cls is not None:
dl_kwargs["tqdm_class"] = _tqdm_cls
@@ -493,6 +545,8 @@ async def install_model(req: InstallModelRequest):
"revision": dl_kwargs["revision"],
"dry_run": True,
}
if allow_patterns:
_preflight_kwargs["allow_patterns"] = allow_patterns
if _endpoint:
_preflight_kwargs["endpoint"] = _endpoint
try:
@@ -548,6 +602,11 @@ async def install_model(req: InstallModelRequest):
_max_attempts = 5
_attempt = 0
# The accelerator is retried across attempts so its manifest-based
# resume actually gets used; it is disabled for the rest of the
# install only when it fails for a reason that is NOT transient
# network trouble (i.e. the accelerator itself is unusable here).
_segmented_off = False
while True:
if req.repo_id in _cancelled:
raise _InstallCancelled()
@@ -555,11 +614,17 @@ async def install_model(req: InstallModelRequest):
try:
# Segmented accelerator (FDL-09, default ON): parallel
# byte-range fetch with real live progress, for the
# legacy-LFS path. Any failure falls through to
# snapshot_download — the accelerator can never compromise a
# correct install.
# legacy-LFS path. A failure that is not transient network
# trouble falls through to snapshot_download, and so does the
# install's last attempt — the accelerator can never
# compromise a correct install (see _segmented_retry_plan).
_snapshot_path = None
if _attempt == 1 and _segmented_enabled() and not _xet_active():
if (
not _segmented_off
and not allow_patterns
and _segmented_enabled()
and not _xet_active()
):
try:
_snapshot_path = _segmented_snapshot(
req.repo_id,
@@ -569,10 +634,16 @@ async def install_model(req: InstallModelRequest):
except _InstallCancelled:
raise
except Exception as _seg_err:
logger.info(
"segmented download for %s failed (%s); falling back to snapshot_download",
req.repo_id, _seg_err,
_segmented_off, _seg_reraise = _segmented_retry_plan(
_seg_err, _attempt, _max_attempts
)
logger.info(
"segmented download for %s failed (%s); accelerator %s",
req.repo_id, _seg_err,
_segmented_retry_note(_segmented_off, _seg_reraise),
)
if _seg_reraise:
raise
_snapshot_path = None
if _snapshot_path is None:
_snapshot_path = snapshot_download(**dl_kwargs) # nosec B615 -- immutable revision_for pin
+8 -3
View File
@@ -19,6 +19,7 @@ import sys
from fastapi import APIRouter
from api.schemas import SetupStatusResponse, PreflightResponse
from core.device_caps import KERNEL_RISK_MARKER
# MIN_FREE_GB + disk_free_bytes are single-sourced in ``.models`` (the lowest
# module in the setup import graph) so the wizard gate, the /models header, and
# the per-install disk guard can't drift apart.
@@ -174,8 +175,8 @@ def _detect_gpu() -> dict:
return info
def _probe_network(host: str = "huggingface.co", port: int = 443, timeout: float = 2.0) -> bool:
"""Tiny TCP connect test."""
def _probe_network(host: str = "huggingface.co", port: int = 443, timeout: float = 8.0) -> bool:
"""Tiny TCP connect test. 8s default — high-latency / China paths often exceed 23s."""
import socket
try:
with socket.create_connection((host, port), timeout=timeout):
@@ -498,10 +499,14 @@ def preflight():
_why = gpu_routing.get("routing_reason")
if _rs == "accelerated" and not _why:
r_status, r_detail, r_fix = "pass", f"{_eng}{_dev} (accelerated)", None
elif _rs == "accelerated": # driver/arch caveat
elif _rs == "accelerated" and KERNEL_RISK_MARKER in (_why or ""):
r_status, r_detail, r_fix = "warn", f"{_eng}{_dev}: {_why}", (
"GPU selected but may fail at kernel launch — update drivers / "
"reinstall torch for this GPU architecture.")
elif _rs == "accelerated": # low-VRAM caveat — not a driver/arch issue
r_status, r_detail, r_fix = "warn", f"{_eng}{_dev}: {_why}", (
"Unload other models before generating, keep the text short, "
"or pick a lighter engine.")
elif _rs == "cpu_fallback":
r_status, r_detail, r_fix = "warn", (
f"{_eng} runs on CPU here: {_why or 'no GPU path for this host'}"), (
+184 -37
View File
@@ -297,6 +297,142 @@ def _tail_file(path: str, tail: int):
return all_lines[-tail:], len(all_lines)
# Must track main.py's _WindowsSafeRotatingFileHandler(backupCount=3). The
# handler rolls omnivoice.log at 2 MB into .1/.2/.3, so up to 6 MB of history
# lives in files this module used to ignore entirely.
_LOG_BACKUP_COUNT = 3
def _rotated_log_paths(base: str) -> list[str]:
"""Existing `<base>.1 … .N`, newest first."""
return [p for p in (f"{base}.{i}" for i in range(1, _LOG_BACKUP_COUNT + 1)) if os.path.exists(p)]
def _tail_rolling(base: str, tail: int):
"""Tail `base`, reaching into its rotated siblings when it runs short.
A rollover leaves omnivoice.log nearly empty, and the Backend tab then
showed a handful of lines or none while the failure the user was asked
to copy sat in omnivoice.log.1. Reading the current file first keeps the
common case at one file read; the backups are only touched when they are
the only place the requested lines can come from.
Returns (lines oldest-first, total lines across the files read, paths read
oldest-first). The total counts only the files it had to open it stops as
soon as `tail` is satisfied, so it is "how much is behind these lines",
not the size of the whole rotation set.
"""
chunks: list[list[str]] = []
paths: list[str] = []
total = 0
remaining = tail
candidates = [p for p in [base, *_rotated_log_paths(base)] if os.path.exists(p)]
for path in candidates:
if remaining <= 0:
break
try:
lines, count = _tail_file(path, remaining)
except FileNotFoundError:
# A rollover can rename a candidate between the existence check
# above and this open, and the handler holds no lock we can take
# from a route. Skip the vanished file rather than 500 the whole
# panel over one member of the set — the previous single-file
# version failed the request outright in the same situation.
#
# A roll landing mid-walk can also 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 here would mean reaching into logging's internals.
continue
except PermissionError as exc:
# Windows only, and only the sharing violation: the handler still
# holds the file it is rolling. Any other permission failure is a
# real misconfiguration and must not be hidden.
if os.name == "nt" and getattr(exc, "winerror", None) == 32:
continue
raise
if count == 0:
continue
chunks.append(lines)
paths.append(path)
total += count
remaining -= len(lines)
# Files were visited newest-first; the reader wants oldest-first.
out: list[str] = []
for chunk in reversed(chunks):
out.extend(chunk)
return out, total, list(reversed(paths))
def _tauri_plugin_log_candidates():
"""The `tauri-plugin-log` files — the shell's own log, and the only thing
the Tauri tab actually displays.
Split out from :func:`_tauri_log_candidates` so Clear can touch these and
leave the backend stdout/stderr redirect alone. See
:func:`clear_tauri_logs`.
"""
home = os.path.expanduser("~")
bid = "com.debpalash.omnivoice-studio"
if sys.platform == "darwin":
return [
os.path.join(home, "Library/Logs", bid, "tauri.log"),
os.path.join(home, "Library/Logs", bid, "VoiceStudio.log"),
]
if sys.platform.startswith("linux"):
data_dir = os.environ.get("XDG_DATA_HOME") or os.path.join(home, ".local/share")
return [
os.path.join(data_dir, bid, "logs", "tauri.log"),
os.path.join(home, ".config", bid, "logs", "tauri.log"),
]
if sys.platform.startswith("win"):
appdata = os.environ.get("APPDATA", home)
localappdata = os.environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local")
return [
os.path.join(localappdata, bid, "logs", "tauri.log"),
os.path.join(appdata, bid, "logs", "tauri.log"),
]
return []
def _backend_redirect_log_candidates():
"""`backend.log` / `backend_err.log` — the spawned backend's stdout and
stderr, written by `src-tauri/src/backend.rs::backend_log_path()`.
Deliberately NOT cleared by the Tauri tab's Clear button.
`open_err_log_for_run()` opens `backend_err.log` **append-only** so "a
respawn must not destroy the previous run's evidence" (#1510), rotates it
to `.1` rather than truncating, and its 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
of it.
`OMNIVOICE_LOG_DIR` is honoured first, in the same precedence
`backend_log_path()` uses. The backend is a child of the shell, so an
ambient override reaches both and a resolver that ignored it would look
in the per-OS default while the writer wrote somewhere else, which is the
divergence class this file already has one of (see #1782).
"""
override = (os.environ.get("OMNIVOICE_LOG_DIR") or "").strip()
if override:
return [
os.path.join(override, "backend.log"),
os.path.join(override, "backend_err.log"),
]
home = os.path.expanduser("~")
if sys.platform == "darwin":
base = os.path.join(home, "Library/Logs/OmniVoice")
elif sys.platform.startswith("linux"):
state_dir = os.environ.get("XDG_STATE_HOME") or os.path.join(home, ".local/state")
base = os.path.join(state_dir, "OmniVoice")
elif sys.platform.startswith("win"):
localappdata = os.environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local")
base = os.path.join(localappdata, "OmniVoice", "Logs")
else:
return []
return [os.path.join(base, "backend.log"), os.path.join(base, "backend_err.log")]
def _tauri_log_candidates():
"""Likely paths for Tauri-side logs, most useful first.
@@ -308,40 +444,15 @@ def _tauri_log_candidates():
`com.debpalash.omnivoice-studio` (frontend/src-tauri/tauri.conf.json).
- backend.rs::backend_log_path() redirects the spawned backend's
stdout/stderr to `backend.log` / `backend_err.log` under
`~/Library/Logs/OmniVoice` (macOS), `$XDG_STATE_HOME/VoiceStudio` falling
`~/Library/Logs/OmniVoice` (macOS), `$XDG_STATE_HOME/OmniVoice` falling
back to `~/.local/state/OmniVoice` (Linux), and
`%LOCALAPPDATA%\\OmniVoice\\Logs` (Windows). This is where uvicorn
startup banners and hard-crash tracebacks land keep all three OS
shapes listed or sidecar crashes become invisible off-macOS.
"""
home = os.path.expanduser("~")
bid = "com.debpalash.omnivoice-studio"
if sys.platform == "darwin":
return [
os.path.join(home, "Library/Logs", bid, "tauri.log"),
os.path.join(home, "Library/Logs", bid, "VoiceStudio.log"),
os.path.join(home, "Library/Logs/OmniVoice/backend.log"),
os.path.join(home, "Library/Logs/OmniVoice/backend_err.log"),
]
if sys.platform.startswith("linux"):
data_dir = os.environ.get("XDG_DATA_HOME") or os.path.join(home, ".local/share")
state_dir = os.environ.get("XDG_STATE_HOME") or os.path.join(home, ".local/state")
return [
os.path.join(data_dir, bid, "logs", "tauri.log"),
os.path.join(home, ".config", bid, "logs", "tauri.log"),
os.path.join(state_dir, "OmniVoice", "backend.log"),
os.path.join(state_dir, "OmniVoice", "backend_err.log"),
]
if sys.platform.startswith("win"):
appdata = os.environ.get("APPDATA", home)
localappdata = os.environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local")
return [
os.path.join(localappdata, bid, "logs", "tauri.log"),
os.path.join(appdata, bid, "logs", "tauri.log"),
os.path.join(localappdata, "OmniVoice", "Logs", "backend.log"),
os.path.join(localappdata, "OmniVoice", "Logs", "backend_err.log"),
]
return []
# Composed from the two halves so the read path keeps seeing every file
# while Clear can be narrowed to the shell's own log.
return _tauri_plugin_log_candidates() + _backend_redirect_log_candidates()
@router.get("/system/logs")
@@ -356,12 +467,24 @@ async def system_logs(tail: int = 200):
except Exception:
tail = 200
path = LOG_PATH if os.path.exists(LOG_PATH) else CRASH_LOG_PATH
if not os.path.exists(path):
if os.path.exists(LOG_PATH) or _rotated_log_paths(LOG_PATH):
base = LOG_PATH
else:
base = CRASH_LOG_PATH
if not os.path.exists(base) and not _rotated_log_paths(base):
return {"lines": [], "path": LOG_PATH, "exists": False}
path = base
try:
lines, total = await asyncio.to_thread(_tail_file, path, tail)
return {"lines": lines, "path": path, "exists": True, "total_lines": total}
lines, total, paths = await asyncio.to_thread(_tail_rolling, base, tail)
return {
"lines": lines,
"path": path,
"exists": True,
"total_lines": total,
# Which files the tail actually came from, oldest first. A bug
# report can then say whether it crossed a rollover.
"paths": paths,
}
except Exception as e:
raise HTTPException(
status_code=500,
@@ -467,9 +590,23 @@ def _read_from_pos(path: str, pos: int) -> list[str]:
@router.post("/system/logs/clear")
async def clear_system_logs():
"""Truncate the rolling runtime log and the crash log (what the Backend tab reads)."""
"""Truncate the rolling runtime log and the crash log (what the Backend tab reads).
Includes the rotated siblings. Truncating only omnivoice.log left up to
6 MB in .1/.2/.3, so Clear freed almost nothing and now that the tail
reaches into those files would have looked like it did nothing at all.
"""
cleared_any = False
for p in (LOG_PATH, CRASH_LOG_PATH):
# The full fixed name set rather than a snapshot of what exists: enumerating
# first leaves a window where a rollover creates a backup after the scan and
# its history survives a Clear that reported success. Names the handler can
# ever write are known up front, so there is nothing to enumerate.
targets = [
LOG_PATH,
*(f"{LOG_PATH}.{i}" for i in range(1, _LOG_BACKUP_COUNT + 1)),
CRASH_LOG_PATH,
]
for p in targets:
if os.path.exists(p):
try:
await asyncio.to_thread(_truncate_file, p)
@@ -502,10 +639,20 @@ def _truncate_file(path: str):
@router.post("/system/logs/tauri/clear")
async def clear_tauri_logs():
"""Truncate whichever Tauri-side log files we know about. OS-level rotation may recreate them."""
"""Truncate the shell's own log files. OS-level rotation may recreate them.
The backend stdout/stderr redirect is deliberately excluded. This button
lives on a tab that shows `tauri.log`, and truncating `backend_err.log`
from it destroyed evidence the user was never shown the one record of a
native death, which writes nothing to the Python log. `backend.rs`'s
`open_err_log_for_run()` opens that file append-only precisely so "a
respawn must not destroy the previous run's evidence" (#1510) and rotates
it to `.1` instead of truncating, so it manages its own size and does not
need clearing from here.
"""
cleared = []
failed = 0
for p in _tauri_log_candidates():
for p in _tauri_plugin_log_candidates():
if os.path.exists(p):
try:
await asyncio.to_thread(_truncate_file, p)
+7 -4
View File
@@ -174,11 +174,14 @@ async def ws_tts(websocket: WebSocket):
# close on `unavailable`, a one-time `routing` frame on
# cpu_fallback / accelerated-with-caveat (before any audio).
from core.device_caps import detect_host_caps
from services.engine_routing import resolve_routing, routing_notice
from services.engine_routing import (
routing_notice,
runtime_compute_profile_async,
)
from core.scrub import scrub_text
_routing = resolve_routing(
getattr(backend, "gpu_compat", ("cpu",)), detect_host_caps(),
getattr(backend, "min_vram_gb", 0.0))
_routing = await runtime_compute_profile_async(
backend, detect_host_caps()
)
if _routing["routing_status"] == "unavailable":
await websocket.send_json({
"type": "error",
+16 -2
View File
@@ -307,6 +307,16 @@ async def convert_speech(
GpuPoolBusyError,
run_on_gpu_pool_guarded,
)
from core.device_caps import detect_host_caps
from services.engine_routing import runtime_compute_profile_async
compute_profile = await runtime_compute_profile_async(
backend, detect_host_caps()
)
if compute_profile["routing_status"] == "unavailable":
raise HTTPException(
status_code=400,
detail=compute_profile["routing_reason"],
)
start_time = time.time()
_render = functools.partial(
@@ -324,9 +334,13 @@ async def convert_speech(
_render,
what="Voice convert",
timeout=_generate_timeout_s(
text, min_vram_gb=getattr(type(backend), "min_vram_gb", 0.0),
text,
execution_device=compute_profile["effective_device"],
min_vram_gb=compute_profile["min_vram_gb"],
hardware_family=compute_profile.get("runtime_hardware_family"),
vram_gb=compute_profile.get("runtime_vram_gb"),
),
min_vram_gb=getattr(type(backend), "min_vram_gb", 0.0),
min_vram_gb=compute_profile["min_vram_gb"],
)
except GpuPoolBusyError as e:
raise HTTPException(
+11
View File
@@ -28,6 +28,9 @@
# their own (weights live in referenced sub-repos). Such
# a cache is legitimately tiny, so the truncated-download
# (weights-missing) detector must NOT flag it incomplete.
# allow_patterns (optional) — restrict installation to these repository paths.
# Use for multi-package repos so an explicit install
# never downloads unrelated model variants.
# ─────────────────────────────────────────────────────────────────────────
models:
@@ -40,6 +43,14 @@ models:
required: true
curated_on: [all]
- repo_id: "audio-cpp/audio.cpp-gguf"
label: "Breeze-TTS-2 Q8_0 for audio.cpp (English + Chinese, clone + design)"
role: TTS
size_gb: 4.73
allow_patterns:
- "Breeze-TTS-2-GGUF/breeze-tts-2-q8_0.gguf"
note: "Optional audio.cpp model. Research/non-commercial weights and self-hosted outputs; install only after reviewing the license."
# ── ASR (optional — curated per platform) ─────────────────────────────
# No ASR model is required to boot: TTS-only installs work. Dubbing,
# dictation, and clone-reference transcription prompt for the curated
+14 -3
View File
@@ -28,6 +28,7 @@ import shutil
import sys
from core.config import DATA_DIR
from core.device_caps import KERNEL_RISK_MARKER
from core.scrub import scrub_text
from core.version import APP_VERSION
@@ -189,7 +190,7 @@ def _check_ram() -> dict:
def _check_engines() -> dict:
try:
from services.tts_backend import list_backends, active_backend_id
backends = list_backends()
backends = list_backends(include_hidden=True)
active = active_backend_id()
except Exception as e:
return _check("engines", "TTS engines", WARN, f"could not enumerate: {e}")
@@ -230,11 +231,16 @@ def _check_gpu_routing() -> dict:
host = v.get("host_family", "cpu")
if status == "accelerated":
if reason: # driver/arch caveat — accelerated but at risk
if reason and KERNEL_RISK_MARKER in reason: # driver/arch caveat — at risk
return _check("gpu_routing", "GPU routing", WARN,
f"{engine} -> {dev}: {reason}",
"The GPU is selected but may fail at kernel launch — "
"update drivers / reinstall torch for this GPU arch.")
if reason: # low-VRAM caveat — not a driver/arch issue
return _check("gpu_routing", "GPU routing", WARN,
f"{engine} -> {dev}: {reason}",
"Unload other models before generating, keep the text "
"short, or pick a lighter engine.")
return _check("gpu_routing", "GPU routing", OK, f"{engine} -> {dev} (accelerated)")
if status == "cpu_fallback":
return _check("gpu_routing", "GPU routing", WARN,
@@ -374,7 +380,12 @@ def run_diagnostics(include_network: bool = True, deep: bool = False) -> dict:
try:
module = importlib.import_module(f"services.{family}_backend")
active = module.active_backend_id()
row = next((item for item in module.list_backends() if item.get("id") == active), None)
rows = (
module.list_backends(include_hidden=True)
if family == "tts"
else module.list_backends()
)
row = next((item for item in rows if item.get("id") == active), None)
if row is not None:
engine_execution.append({
"family": family,
+48
View File
@@ -97,6 +97,9 @@ _HINTS: dict[str, str] = {
"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.",
@@ -308,6 +311,23 @@ _CONTEXT_FREE_HINT_CLASSES = frozenset({
# a Windows virtual-memory setting rather than a connectivity problem, and
# the detailed hint we already had for it never reached them.
"WINDOWS_PAGING_FILE_TOO_SMALL",
# #1957: triggered by WinError 448 or the literal "untrusted mount
# point" — both unmistakable, and it reaches the user as a bare
# download failure with only the OS sentence attached.
"WINDOWS_UNTRUSTED_MOUNT",
# #1826: torch's own conv wording, which nothing else produces, and it
# reaches the user through the generic 500.
"INPUT_TOO_SHORT",
# #1879: matched on wording no other failure produces, and it reaches the
# user as a bare 400 carrying only the library sentence.
"CLONE_REFERENCE_MISSING",
# Its trigger is a VoiceStudio-authored sentence — "the TTS model cache
# for … is incomplete" plus "could not be auto-repaired" / "weights
# missing" — so it cannot be produced by an unrelated library. The 500
# handler is the surface a corrupt cache actually reaches, and dropping
# its hint there would leave the user with no way to know a redownload
# is the fix.
"MODEL_CACHE_CORRUPT",
})
@@ -569,6 +589,34 @@ def classify(reason: str) -> str:
or "application control policy" in low
):
return "WINDOWS_APP_CONTROL_BLOCKED"
# #1957: the path to a download or output file crosses a mount point
# Windows will not traverse (Dev Drive, mounted VHD/ReFS, a junction into
# another profile). Matched on the numeric code first because the OS
# translates the sentence, with the English phrase as a fallback.
if "[winerror 448]" in low or "untrusted mount point" in low:
return "WINDOWS_UNTRUSTED_MOUNT"
# #1826: a degenerate-length input reaches a conv layer whose kernel is
# wider than the tensor, and torch says so in its own terms — "Calculated
# padded input size per channel: (1). Kernel size: (2). Kernel size can't
# be greater than actual input size". That arrived doubly wrapped in
# "Underlying error:" and told the user nothing they could act on, when
# the fix is simply "type more than one character".
if "kernel size can't be greater than actual input size" in low or (
"calculated padded input size per channel" in low
):
return "INPUT_TOO_SHORT"
# #1879: mlx-audio (and the Chatterbox-family models under it) raise a
# bare ValueError naming their own parameters — "No conditionals
# available. Either provide audio_prompt/audio_prompt_sr ... or ensure
# conds.safetensors is in the model directory." The generate route passed
# that straight through as the 400 detail, so 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. What actually happened is "you asked to clone without a
# reference clip".
if "no conditionals available" in low or (
"audio_prompt" in low and "conds.safetensors" in low
):
return "CLONE_REFERENCE_MISSING"
# #1221: libsndfile failed an OS-level audio read/write. Its own wording is
# a bare "System error.", so match the library name — audio_io already
# prefixes the target path and free space onto the write-path failures.
+77 -3
View File
@@ -4,7 +4,14 @@ from __future__ import annotations
import os
import sys
import threading
from typing import BinaryIO, Callable
import time
from typing import Any, BinaryIO, Callable, Optional
# Poll cadence for the Windows pipe watcher. Exit latency after the desktop
# closes its end is bounded by this; the desktop's own kill-on-close Job is the
# hard backstop, so a quarter second is plenty and costs nothing measurable.
WINDOWS_PIPE_POLL_INTERVAL_S = 0.25
_FILE_TYPE_PIPE = 3 # winbase.h FILE_TYPE_PIPE
def _watch_parent_pipe(reader: BinaryIO, exit_process: Callable[[int], None]) -> None:
@@ -18,6 +25,64 @@ def _watch_parent_pipe(reader: BinaryIO, exit_process: Callable[[int], None]) ->
exit_process(0)
def _watch_parent_pipe_handle(
handle: int,
exit_process: Callable[[int], None],
*,
peek: Optional[Callable[[int], Any]] = None,
read_file: Optional[Callable[[int, int], Any]] = None,
sleep: Callable[[float], None] = time.sleep,
interval: float = WINDOWS_PIPE_POLL_INTERVAL_S,
) -> None:
"""Windows twin of :func:`_watch_parent_pipe` that never leaves a read
pending on the pipe.
A synchronous ``ReadFile`` parked on the stdin pipe whether issued through
the C runtime's ``read()`` or straight to the kernel — deadlocks the
OpenBLAS DLL initializer that ``import torch`` reaches (numpy's
``_multiarray_umath``) in the startup worker: every desktop-spawned backend
on Windows froze at "Loading ML runtime (PyTorch)" while the identical
command from a terminal, with no stdin pipe and no watchdog, started in
seconds. A thread that merely sleeps does not trigger it; only the pending
read on that pipe does. So instead of blocking in a read, poll with
``PeekNamedPipe``: it returns immediately, holds no I/O on the file object,
drains any keepalive bytes the desktop might write, and fails with
``ERROR_BROKEN_PIPE`` the moment the desktop closes its end which is the
same EOF signal the POSIX reader gets.
"""
if peek is None or read_file is None:
import _winapi # Windows-only stdlib module; the caller gates on the platform
peek = peek or _winapi.PeekNamedPipe
read_file = read_file or _winapi.ReadFile
try:
while True:
available, _ = peek(handle)
if available:
# Bytes are already buffered, so this read cannot block.
read_file(handle, available)
else:
sleep(interval)
except OSError:
# ERROR_BROKEN_PIPE (109) is how the closed parent end surfaces here.
pass
exit_process(0)
def _windows_pipe_handle(reader: Any) -> Optional[int]:
"""The OS handle behind ``reader`` when it is a pipe, else None."""
try:
import msvcrt
import _winapi
handle = msvcrt.get_osfhandle(reader.fileno())
if _winapi.GetFileType(handle) != _FILE_TYPE_PIPE:
return None
return handle
except (OSError, ValueError, AttributeError, ImportError):
return None
def arm_desktop_parent_watchdog() -> bool:
"""Use stdin EOF as an unforgeable parent-liveness signal for desktop runs."""
if os.environ.get("OMNIVOICE_DESKTOP_CONTAINED") != "1":
@@ -25,9 +90,18 @@ def arm_desktop_parent_watchdog() -> bool:
reader = getattr(sys.stdin, "buffer", None)
if reader is None:
return False
target: Callable[..., None] = _watch_parent_pipe
args: tuple = (reader, os._exit)
if os.name == "nt":
handle = _windows_pipe_handle(reader)
if handle is not None:
target = _watch_parent_pipe_handle
args = (handle, os._exit)
# A non-pipe stdin (file, NUL) cannot have a read pending against a
# pipe file object, so the blocking reader stays correct there.
threading.Thread(
target=_watch_parent_pipe,
args=(reader, os._exit),
target=target,
args=args,
name="desktop-parent-watchdog",
daemon=True,
).start()
+31 -2
View File
@@ -94,6 +94,16 @@ def stream_generation_failure(error: BaseException | object) -> dict[str, object
replace the failure being diagnosed.
"""
payload = stream_failure("generation_failed")
if isinstance(error, BaseException):
# The exception's TYPE NAME, never its message. Two failures that both
# render the floor message "Generation failed. Check the selected
# engine and try again." are indistinguishable in an auto-filed report,
# so every unclassified streaming failure arrives as the same issue and
# none of them can be triaged (#1800). A class name is VoiceStudio-safe
# by the same reasoning that already puts it on the wire as
# `error_class` in the dub routes and on the analytics allowlist: it is
# a Python type, not user text, and no substring of `error` is copied.
payload["error_class"] = type(error).__name__
try:
enriched = public_exception_response(error, fallback=str(payload["detail"]))
except Exception:
@@ -149,12 +159,31 @@ def public_exception_response(error: BaseException, *, fallback: str) -> dict[st
Classification may inspect the private diagnostic locally, but response
values come exclusively from VoiceStudio-owned constants. No substring of
``error`` is copied into the payload.
Every caller is a CONTEXT-FREE surface the global 500 handler, the
streaming generate error frame, the dub GPU-OOM 503 so the topic is
filtered through ``failure._CONTEXT_FREE_HINT_CLASSES`` before its hint is
attached. Without that filter a topic whose trigger is a generic phrase
stamps a confidently wrong remediation on an unrelated failure: #1943 is a
macOS mlx-audio TTS 500 that came back advising the user that "the
connection to the video server dropped mid-download", because
VIDEO_DOWNLOAD_NETWORK triggers on a bare "timed out" / "connection
reset". The allowlist already existed and already named that class as the
example of what must not appear here; only :func:`failure.append_hint`
honoured it, and this helper replaced ``append_hint`` on the 500 path
without carrying the rule across.
HF_MIRROR_UNREACHABLE is allowed alongside it: its hint is dynamic (it
names the configured mirror) and its trigger requires that a mirror is
configured at all, so it cannot fire on an unrelated failure (#874).
"""
from core.failure import classify, public_hint_for_topic
from core.failure import _CONTEXT_FREE_HINT_CLASSES, classify, public_hint_for_topic
try:
topic = classify(str(error))
hint = public_hint_for_topic(topic)
if topic and topic not in _CONTEXT_FREE_HINT_CLASSES and topic != "HF_MIRROR_UNREACHABLE":
topic = ""
hint = public_hint_for_topic(topic) if topic else ""
except Exception:
topic = ""
hint = ""
+43
View File
@@ -0,0 +1,43 @@
"""The PyTorch wheel index VoiceStudio installs CUDA builds from.
A local-version pin such as ``torch==2.9.1+cu128`` exists only on PyTorch's
own index, never on PyPI. The app's own ``pyproject.toml`` routes torch there
through ``[tool.uv.sources]``, but a sidecar engine is installed with
``uv pip install`` into its own venv, which knows nothing about that config
so every CUDA-pinned sidecar install has to name the index itself.
MOSS-TTS-v1.5's install did not, and its ``[torch-runtime]`` extra
(``torch==2.9.1+cu128``) could never resolve: ``uv pip compile`` reports it
unsatisfiable without this index and resolves it with it. One definition here,
imported by the one-click installer and by the engine's own bootstrap, so the
two cannot drift apart again. ``tests/test_sidecar_install.py`` pins the URL
to the ``pytorch-cuda`` index declared in the app's ``pyproject.toml``.
"""
PYTORCH_CU128_INDEX_URL = "https://download.pytorch.org/whl/cu128"
# `unsafe-best-match`: the PyTorch index also mirrors common dependencies
# (numpy, pillow, sympy, …) at a narrower range of versions than PyPI. uv's
# default first-index strategy would stop at whichever index lists a name first
# and could pin an old mirror copy or fail outright. The index is PyTorch's
# official one, so the dependency-confusion risk the name warns about does not
# apply to it.
UV_PIP_CU128_ARGS: tuple[str, ...] = (
"--extra-index-url",
PYTORCH_CU128_INDEX_URL,
"--index-strategy",
"unsafe-best-match",
)
PYTORCH_CPU_INDEX_URL = "https://download.pytorch.org/whl/cpu"
# For an engine that runs torch only on the CPU (PocketTTS). On Linux, PyPI's
# torch is the CUDA build and pulls ~15 NVIDIA packages the engine never uses;
# this index serves `+cpu` builds for Linux and Windows and the regular build
# for macOS.
UV_PIP_CPU_ARGS: tuple[str, ...] = (
"--extra-index-url",
PYTORCH_CPU_INDEX_URL,
"--index-strategy",
"unsafe-best-match",
)
+590
View File
@@ -0,0 +1,590 @@
"""audio.cpp TTS backend — Breeze-TTS-2 via a managed native server.
audio.cpp (0xShug0/audio.cpp) is a pure-C++ ggml runtime: prebuilt
``audiocpp_server`` binaries for Windows/macOS/Linux, no Python venv, no
``transformers`` pin so this engine needs neither the venv-isolation
(``engines.dots_tts``) nor the per-generate CLI-spawn (``engines
.omnivoice_gguf``) patterns. The parent instead:
1. resolves the binary + GGUF model (``bootstrap.py``),
2. spawns ONE long-lived ``audiocpp_server`` on 127.0.0.1 (lazy model load,
so model memory is only held after the first generate), and
3. speaks its OpenAI-style ``POST /v1/audio/speech`` per generate.
v1 serves the ``breeze_tts`` family only (Breeze-TTS-2, en+zh, voice clone
+ voice design + voice direction). The server is task-agnostic on the
speech route reference-audio presence selects clone/direction vs design
so a single ``task: tts`` model entry covers all three modes.
License honesty: Breeze-TTS-2 weights (``BreezeBlue/Breeze-TTS-2`` and the
audio.cpp GGUF repack) are RESEARCH AND NON-COMMERCIAL ONLY
(``BreezeBlue Research and Non-Commercial License``); only the audio.cpp
code is Apache-2.0. There is no in-tree acceptance dialog for this engine
yet (settings ``/license`` allow-list), so the restriction is surfaced in
the display name, the install hint, and ``docs/engines/audio-cpp.md``
not silently.
"""
from __future__ import annotations
import atexit
import base64
import io
import json
import logging
import os
import secrets
# Used only for stream constants; spawn_owned performs the process launch.
import subprocess # nosec B404
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import TYPE_CHECKING, Any
from core.contained_subprocess import spawn_owned
from services.tts_backend import TTSBackend, TTSInputError
if TYPE_CHECKING:
import torch
logger = logging.getLogger("omnivoice.audiocpp")
#: Engine id in the TTS registry.
ENGINE_ID = "audiocpp"
#: How long to wait for ``/health`` after spawning the server (first spawn
#: extracts nothing heavy — the model loads lazily on first generate).
_HEALTH_TIMEOUT_S = 120.0
#: Finish the inner HTTP request before the canonical generation guard can
#: abandon its worker thread. This leaves enough time to terminate the owned
#: native process and release its model memory synchronously.
_TERMINATE_GRACE_S = 5.0
_TERMINATE_KILL_S = 5.0
_GENERATE_TIMEOUT_MARGIN_S = (
_TERMINATE_GRACE_S + _TERMINATE_KILL_S + 5.0
)
# ── pure request/config builders (unit-tested, no I/O) ──────────────────────
def _cpu_thread_count() -> int:
"""Use up to 16 physical cores, with a stdlib fallback."""
try:
import psutil
cores = psutil.cpu_count(logical=False)
except (ImportError, OSError):
cores = None
return min(16, max(1, cores or os.cpu_count() or 1))
def _device_min_vram_gb(device) -> float:
"""Dedicated-memory comfort floor for one discovered native device."""
return 6.0 if (
device
and device.kind == "GPU"
and (
device.backend == "vulkan"
or device.hardware_family in {"cuda", "rocm"}
)
) else 0.0
def build_server_config(
*, model_id: str, family: str, model_path: str, port: int,
backend: str = "cpu", device: int = 0,
execution_target: str | None = None,
) -> dict:
"""``server.json`` dict for the managed ``audiocpp_server``.
``lazy_load`` defers the ~4.73 GiB GGUF load to the first generate;
``max_loaded_models: 1`` bounds residency to the one model we serve.
"""
return {
"host": "127.0.0.1",
"port": port,
"backend": backend,
"device": device,
# The pinned CPU runtime scales strongly through 16 workers while
# producing byte-identical audio.
"threads": _cpu_thread_count()
if (execution_target or backend) == "cpu" else 1,
"lazy_load": True,
"max_loaded_models": 1,
"models": [
{
"id": model_id,
"family": family,
"path": model_path,
"task": "tts",
"mode": "offline",
}
],
}
def build_speech_payload(
*, model_id: str, text: str, ref_audio: str | None = None,
ref_text: str | None = None, instructions: str | None = None,
guidance_scale: float | None = None, seed: int | None = None,
) -> dict:
"""``POST /v1/audio/speech`` JSON body.
Field spellings verified against ``app/server/runtime.cpp``
(``build_speech_request``): ``instructions`` (plural, OpenAI spelling)
feeds the ``instruction`` request option; ``reference_text`` and
``guidance_scale``/``seed`` pass through top-level; ``voice_ref`` takes
a ``{"type": "path", ...}`` object so the reference stays on disk
(the 5 MiB base64 cap never bites). ``response_format: json`` returns
the WAV base64-in-JSON one round trip, no binary framing.
"""
payload: dict[str, Any] = {
"model": model_id,
"input": text,
"response_format": "json",
}
if instructions:
payload["instructions"] = instructions
if ref_audio:
payload["voice_ref"] = {"type": "path", "path": str(ref_audio)}
if ref_text:
payload["reference_text"] = ref_text
if guidance_scale is not None:
payload["guidance_scale"] = float(guidance_scale)
if seed is not None:
payload["seed"] = int(seed)
return payload
def decode_speech_json(obj: dict) -> tuple[int, object]:
"""``(sample_rate, mono float32 numpy)`` from a ``response_format=json``
speech body. Raises ``ValueError`` on a server error payload."""
if not isinstance(obj, dict):
raise TypeError(f"audio.cpp speech reply is not JSON: {obj!r:.120}")
if "audio" not in obj:
raise ValueError(f"audio.cpp speech failed: {obj.get('error', obj)!r:.300}")
import numpy as np
import soundfile as sf
wav_bytes = base64.b64decode(obj["audio"])
wav, sr = sf.read(io.BytesIO(wav_bytes), dtype="float32", always_2d=False)
wav = np.asarray(wav, dtype=np.float32)
if wav.ndim > 1:
wav = wav.mean(axis=-1)
return int(sr), wav
# ── backend ─────────────────────────────────────────────────────────────────
class AudioCPPBackend(TTSBackend):
"""Breeze-TTS-2 through a parent-managed ``audiocpp_server``."""
id = ENGINE_ID
display_name = (
"audio.cpp · Breeze-TTS-2 (native GGUF, en+zh, clone+design; "
"weights research/non-commercial)"
)
supports_voice_design = True
applies_own_mastering = True # model-decoded 24 kHz studio output
gpu_compat = ("cpu",)
runs_out_of_process = True
# Same marker SubprocessBackend sets: this engine lives in another OS
# process. Consumers only branch the matrix label and the self-test
# route (spawn-and-ping instead of in-process synth) — both correct
# here; nothing assumes the stdio protocol from it.
_is_subprocess_isolated = True
_DEFAULT_SAMPLE_RATE = 24000 # Breeze-TTS-2 native rate
def __init__(self) -> None:
self._proc: Any | None = None
self._port: int | None = None
self._server_model_id: str | None = None
self._sr = self._DEFAULT_SAMPLE_RATE
self._lock = threading.RLock()
self._server_json: Path | None = None
self._selection = None
self._device = None
self._provider = None
# ── availability ────────────────────────────────────────────────────
@classmethod
def is_available(cls) -> tuple[bool, str]:
from engines.audiocpp import bootstrap
try:
bootstrap.resolve_server_binary()
bootstrap.resolve_model_file()
except RuntimeError as exc:
return False, str(exc)
return True, "ready"
@classmethod
def runtime_compute_profile(cls, caps) -> dict:
from dataclasses import replace
from engines.audiocpp import bootstrap
from services.engine_routing import low_vram_caveat
try:
selection = bootstrap.resolve_compute_selection(caps)
targets = bootstrap.runtime_targets()
except RuntimeError as exc:
return {
"gpu_compat": cls.gpu_compat,
"min_vram_gb": 0.0,
"effective_device": "cpu",
"routing_status": "unavailable",
"routing_reason": str(exc),
"runtime_backend": None,
"runtime_device_index": None,
"runtime_device_name": None,
"runtime_hardware_family": None,
"runtime_vram_gb": None,
"runtime_device_verified": False,
}
selected = selection.device
accelerated = selected.target != "cpu"
min_vram_gb = _device_min_vram_gb(selected)
dedicated = min_vram_gb > 0
reason = selection.fallback_reason
if accelerated and dedicated and reason is None:
selected_caps = replace(
caps,
device_name=selected.name,
vram_gb=selection.verified_vram_gb,
)
reason = low_vram_caveat(
selected_caps,
min_vram_gb,
family=selected.hardware_family,
vram_gb=selection.verified_vram_gb,
)
status = "accelerated" if accelerated else (
"cpu_fallback" if selection.fallback_reason else "cpu_only"
)
return {
"gpu_compat": targets,
"min_vram_gb": min_vram_gb,
"effective_device": selected.target,
"routing_status": status,
"routing_reason": reason,
"runtime_backend": selected.backend,
"runtime_device_index": selected.index,
"runtime_device_name": selected.name,
"runtime_hardware_family": selected.hardware_family,
"runtime_vram_gb": selection.verified_vram_gb,
"runtime_device_verified": selection.verified_vram_gb > 0,
}
# ── TTSBackend protocol ─────────────────────────────────────────────
@property
def sample_rate(self) -> int:
return self._sr
@property
def supported_languages(self) -> list[str]:
return ["en", "zh"]
def model_identity(self) -> str | None:
from engines.audiocpp import bootstrap
return f"{bootstrap.FAMILY}/{bootstrap.package_filename()}"
# ── server lifecycle ────────────────────────────────────────────────
def _base_url(self) -> str:
return f"http://127.0.0.1:{self._port}"
def _ensure_loaded(self) -> None:
"""Spawn the server (once) and wait for ``/health``. Idempotent."""
with self._lock:
if self._proc is not None and self._proc.poll() is None:
return
self._proc = None # stale handle — respawn below
from engines.audiocpp import bootstrap
binary = bootstrap.resolve_server_binary()
selection = bootstrap.resolve_compute_selection()
model_file = bootstrap.resolve_model_file()
self._port = bootstrap.server_port()
# The random model id is a per-launch challenge. Before sending
# speech text or a reference path, _verify_server_identity asks
# /v1/models to prove this is the child configured by this process,
# not an unrelated listener that pre-bound the loopback port.
self._server_model_id = f"{bootstrap.MODEL_ID}-{secrets.token_hex(16)}"
config = build_server_config(
model_id=self._server_model_id,
family=bootstrap.FAMILY,
model_path=str(model_file),
port=self._port,
backend=selection.device.backend,
device=selection.device.index,
execution_target=selection.device.target,
)
self._selection = selection
self._device = selection.device.target
self._provider = selection.device.backend
from core.config import DATA_DIR
workdir = Path(str(DATA_DIR)) / "audiocpp"
workdir.mkdir(parents=True, exist_ok=True)
self._server_json = workdir / "server.json"
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
config_fd = os.open(self._server_json, flags, 0o600)
try:
if os.name != "nt":
os.fchmod(config_fd, 0o600)
with os.fdopen(config_fd, "w", encoding="utf-8") as config_fh:
config_fd = -1
json.dump(config, config_fh, indent=2)
finally:
if config_fd >= 0:
os.close(config_fd)
log_path = workdir / "server.log"
logger.info(
"audio.cpp: starting %s (backend=%s, device=%d, port=%d, model=%s)",
binary.name, selection.device.backend, selection.device.index,
self._port, model_file.name,
)
with open(log_path, "ab") as log_fh:
self._proc = spawn_owned(
[str(binary), "--config", str(self._server_json)],
stdout=log_fh,
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
)
atexit.register(self._terminate_server)
self._wait_for_health()
def _wait_for_health(self) -> None:
if self._proc is None or self._port is None:
raise RuntimeError("managed audio.cpp server was not started")
deadline = time.monotonic() + _HEALTH_TIMEOUT_S
last_err = "unknown"
url = self._base_url() + "/health"
while time.monotonic() < deadline:
if self._proc.poll() is not None:
raise RuntimeError(
"audiocpp_server exited during startup "
f"(code {self._proc.returncode}). See the server log next "
"to server.json under the app data audiocpp/ directory — "
"the managed port may already be in use."
)
try:
# ``url`` is always the hard-coded loopback host plus a
# validated integer port; arbitrary schemes are impossible.
with urllib.request.urlopen(url, timeout=5) as resp: # nosec B310
if resp.status == 200:
self._verify_server_identity()
if self._proc.poll() is None:
logger.info(
"audio.cpp: managed server is healthy on loopback"
)
return
last_err = f"HTTP {resp.status}"
except Exception as exc: # noqa: BLE001 — still starting; retry
last_err = f"{type(exc).__name__}: {exc}"
time.sleep(1.0)
self._terminate_server()
raise RuntimeError(
f"audiocpp_server did not become healthy within "
f"{_HEALTH_TIMEOUT_S:.0f}s (last: {last_err})."
)
def _get_json(self, path: str, timeout: float = 5.0) -> dict:
"""GET one loopback JSON endpoint without sending request content."""
if self._port is None:
raise RuntimeError("managed audio.cpp server port is missing")
req = urllib.request.Request(self._base_url() + path, method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310
obj = json.loads(resp.read().decode("utf-8"))
if not isinstance(obj, dict):
raise TypeError("audio.cpp returned an invalid JSON response")
return obj
def _verify_server_identity(self) -> None:
"""Prove the loopback listener owns this launch's random model id."""
if self._proc is None or self._proc.poll() is not None:
raise RuntimeError("managed audio.cpp server is not running")
expected = self._server_model_id
if not expected:
raise RuntimeError("managed audio.cpp server identity is missing")
obj = self._get_json("/v1/models")
data = obj.get("data", [])
if not isinstance(data, list):
raise TypeError("managed audio.cpp server identity is invalid")
model_ids = {
item.get("id") for item in data
if isinstance(item, dict)
}
if expected not in model_ids or self._proc.poll() is not None:
raise RuntimeError(
"loopback listener did not prove managed audio.cpp ownership"
)
def _post_json(self, path: str, payload: dict, timeout: float) -> dict:
"""Verify child ownership, then POST JSON to the managed server."""
if self._port is None:
raise RuntimeError("managed audio.cpp server port is missing")
self._verify_server_identity()
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
self._base_url() + path,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
# ``req`` targets only ``_base_url()`` (127.0.0.1 + validated
# integer port), never a caller-provided URL.
with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:500]
raise RuntimeError(
f"audio.cpp {path} failed (HTTP {exc.code}): {detail}"
) from exc
except urllib.error.URLError as exc:
if isinstance(exc.reason, TimeoutError):
raise TimeoutError("audio.cpp request timed out") from exc
raise
def _terminate_server(self) -> None:
proc, self._proc = self._proc, None
self._server_model_id = None
if proc is None:
return
try:
proc.terminate()
proc.wait(timeout=_TERMINATE_GRACE_S)
except Exception: # noqa: BLE001 — kill as last resort, never raise
try:
proc.kill()
proc.wait(timeout=_TERMINATE_KILL_S)
except Exception as exc: # noqa: BLE001 — process is already failing
logger.debug("audio.cpp: final server kill failed: %s", exc)
# ── generate ────────────────────────────────────────────────────────
def generate(self, text: str, **kw) -> torch.Tensor:
import torch
from services.model_manager import (
GENERATE_PROGRESS_GRACE_S,
generate_timeout_s,
report_generate_progress,
)
if not text or not text.strip():
raise TTSInputError(
"audio.cpp: the input contains no speakable text — "
"send at least one word."
)
ref_audio = kw.get("ref_audio")
ref_text = kw.get("ref_text")
if ref_text and not ref_audio:
logger.info(
"audio.cpp: ref_text supplied without ref_audio; ignoring."
)
ref_text = None
# Voice design: our `description=` (no ref) and voice direction
# (`instruct=` + ref) both ride the server's `instructions` field —
# verified spelling against app/server/runtime.cpp.
instruct = kw.get("instruct") or kw.get("description") or None
language = kw.get("language")
if language and str(language).strip().lower() not in {
"auto", "en", "english", "zh", "chinese",
}:
logger.info(
"audio.cpp (Breeze-TTS-2) is en+zh only; ignoring "
"language=%r.", language,
)
if kw.get("speed", 1.0) != 1.0:
logger.info("audio.cpp: speed is not supported; ignoring.")
request_started = time.monotonic()
with self._lock:
self._ensure_loaded()
selected = self._selection.device if self._selection else None
min_vram_gb = _device_min_vram_gb(selected)
request_budget = generate_timeout_s(
text,
execution_device=selected.target if selected else "cpu",
min_vram_gb=min_vram_gb,
hardware_family=selected.hardware_family if selected else None,
vram_gb=self._selection.verified_vram_gb
if self._selection else 0.0,
)
if not self._server_model_id:
raise RuntimeError("managed audio.cpp server identity is missing")
payload = build_speech_payload(
model_id=self._server_model_id,
text=text,
ref_audio=str(ref_audio) if ref_audio else None,
ref_text=ref_text,
instructions=instruct,
guidance_scale=kw.get("guidance_scale", 1.0),
seed=kw.get("seed"),
)
# Device discovery and server startup can consume part of the soft
# budget. This fresh synthesis lease gives the lazy model load and
# request a bounded window. The inner request always expires early
# enough to reap the owned server before the outer guard abandons us.
report_generate_progress()
soft_remaining = request_budget - (time.monotonic() - request_started)
timeout = (
max(soft_remaining, GENERATE_PROGRESS_GRACE_S)
- _GENERATE_TIMEOUT_MARGIN_S
)
if timeout <= 0:
self._terminate_server()
raise TimeoutError(
"audio.cpp startup exhausted the generation time budget"
)
try:
obj = self._post_json(
"/v1/audio/speech", payload, timeout=timeout,
)
except TimeoutError:
self._terminate_server()
raise RuntimeError(
"audio.cpp generation timed out; its managed server was reset"
) from None
sr, wav_np = decode_speech_json(obj)
self._sr = sr
wav = torch.from_numpy(wav_np).float()
if wav.ndim == 0:
raise RuntimeError("audio.cpp produced empty audio")
return wav.unsqueeze(0)
# ── lifecycle ───────────────────────────────────────────────────────
def unload(self) -> None:
"""Free the model server-side, then stop it. Idempotent."""
with self._lock:
if self._port is not None and self._proc is not None \
and self._proc.poll() is None:
try:
self._post_json("/v1/tasks/unload_all_models", {}, timeout=30)
except Exception as exc: # noqa: BLE001 — best effort
logger.warning("audio.cpp: server unload failed: %s", exc)
self._port = None
self._terminate_server()
super().unload()
__all__ = [
"ENGINE_ID",
"AudioCPPBackend",
"build_server_config",
"build_speech_payload",
"decode_speech_json",
]
+738
View File
@@ -0,0 +1,738 @@
"""audio.cpp binary probe + model resolution.
audio.cpp (0xShug0/audio.cpp) is a pure-C++ ggml inference engine with
prebuilt release binaries no Python venv, no ``transformers`` pin, so
none of the dependency-isolation machinery in ``engines._venv_probe`` or
``services.subprocess_backend`` applies. The parent instead:
1. locates a user-installed ``audiocpp_server`` (env var, user dir, or this
package's ``bin/``), and
2. resolves an explicitly installed GGUF model file from a direct path or
the shared Hugging Face cache.
Probe order for the server binary (existing installs win, zero migration):
1. ``${OMNIVOICE_AUDIOCPP_BIN}`` absolute path to the binary itself.
2. ``${OMNIVOICE_AUDIOCPP_DIR}/audiocpp_server[.exe]`` a user-managed
install dir (e.g. an extracted release zip, or a self-built tree).
3. ``backend/engines/audiocpp/bin/audiocpp_server[.exe]`` an explicitly
installed local copy.
``is_installed()`` is a cheap file-existence check no spawn, no network.
VoiceStudio never downloads executable code for this engine.
"""
from __future__ import annotations
import errno
import functools
import logging
import os
import platform
import subprocess # nosec B404 -- fixed argv probes a user-selected executable
import sys
from dataclasses import dataclass
from pathlib import Path
logger = logging.getLogger("omnivoice.audiocpp.bootstrap")
#: Pinned audio.cpp release. BreezeTTS-2 support landed in 0.7.2 — older
#: binaries have no ``breeze_tts`` family, so the floor is also the pin.
VERSION = "v0.7.2"
#: GitHub repo serving the prebuilt binaries.
GH_REPO = "0xShug0/audio.cpp"
#: HuggingFace repo serving the GGUF model packages (not gated).
HF_MODEL_REPO = "audio-cpp/audio.cpp-gguf"
# Immutable repository revision used for the v0.7.2 Breeze-TTS-2 package.
# Pinning prevents a later upstream file replacement from silently changing
# the model exercised by this backend.
HF_MODEL_REVISION = "dc6fecccc2b0c6bdda0a8b2f38fa61394fee0b9c"
#: Model id used in the generated ``server.json`` and in speech requests.
MODEL_ID = "breeze-tts-2"
#: audio.cpp family name for BreezeTTS 2 (``--family`` / server ``family``).
FAMILY = "breeze_tts"
#: GGUF package directory inside :data:`HF_MODEL_REPO`.
PACKAGE_DIR = "Breeze-TTS-2-GGUF"
#: Default package (Q8_0, the upstream-recommended GGUF). ``bf16`` is
#: available via ``OMNIVOICE_AUDIOCPP_PACKAGE``.
DEFAULT_PACKAGE = "breeze-tts-2-q8_0.gguf"
#: Env var pointing directly at the ``audiocpp_server`` binary.
BIN_ENV = "OMNIVOICE_AUDIOCPP_BIN"
#: Env var pointing at a directory containing ``audiocpp_server``.
DIR_ENV = "OMNIVOICE_AUDIOCPP_DIR"
#: Env var overriding the GGUF package filename (e.g. the bf16 package).
PACKAGE_ENV = "OMNIVOICE_AUDIOCPP_PACKAGE"
#: Optional advanced overrides for a binary that exposes several runtimes or
#: devices. Device indices are local to the selected backend registry.
BACKEND_ENV = "OMNIVOICE_AUDIOCPP_BACKEND"
DEVICE_ENV = "OMNIVOICE_AUDIOCPP_DEVICE"
#: Env var overriding the loopback port the managed server binds.
PORT_ENV = "OMNIVOICE_AUDIOCPP_PORT"
#: Default loopback port. High and engine-specific to avoid clashing with
#: the app itself or a user-run ``audiocpp_server`` (default 8080).
DEFAULT_PORT = 17860
#: This package's owned binary dir (probe 3).
_PKG_BIN_DIR: Path = Path(__file__).parent / "bin"
# Recommended (asset filename, sha256) per platform slug, from the v0.7.2
# release. Windows and Linux use the vendor-neutral Vulkan build, which also
# exposes the native CPU backend. Upstream publishes the macOS builds under
# the Metal package name. No linux-aarch64 prebuilt exists in v0.7.2.
_ASSETS: dict[str, tuple[str, str]] = {
"windows-x64": (
"audio-v0.7.2-bin-windows-x64-vulkan.zip",
"15b8232eae740e21e507d87f827a89966de9451b085a45932d9e214e032962c1",
),
"linux-x64": (
"audio-v0.7.2-bin-ubuntu-x64-vulkan.tar.gz",
"fee1f978cee76453cf17f00196554bc2ee294645739538af0726a143b6a69a23",
),
"darwin-arm64": (
"audio-v0.7.2-bin-macos-arm64-metal.tar.gz",
"c01e4f82971bedbe341697e63a9cebd5a5d1f72d5a9bcb51a3191f95ddab7a95",
),
"darwin-x64": (
"audio-v0.7.2-bin-macos-x64-metal.tar.gz",
"3862270f33439077225324169313f727064f727305b54d8ce920244d75ddcc24",
),
}
#: Binary filename per platform.
_BINARY_NAMES = {"windows-x64": "audiocpp_server.exe"}
_REGISTRY_BACKENDS = {
"CPU": "cpu",
"CUDA": "cuda",
"MUSA": "cuda",
"HIP": "hip",
"ROCm": "hip",
"Vulkan": "vulkan",
"Metal": "metal",
"MTL": "metal",
}
_BACKEND_ALIASES = {
"cpu": "cpu",
"cuda": "cuda",
"hip": "hip",
"rocm": "hip",
"vulkan": "vulkan",
"metal": "metal",
}
@dataclass(frozen=True)
class AudioCPPDevice:
"""One immutable device from audio.cpp's backend-local registry."""
registry: str
backend: str
index: int
name: str
kind: str
target: str
hardware_family: str
@dataclass(frozen=True)
class AudioCPPSelection:
"""The runtime/device chosen for the next managed server."""
device: AudioCPPDevice
fallback_reason: str | None = None
verified_vram_gb: float = 0.0
@dataclass(frozen=True)
class _ProbeOutcome:
devices: tuple[AudioCPPDevice, ...] = ()
error: str | None = None
def _cpu_probe_fallback(error: RuntimeError) -> AudioCPPSelection:
"""A usable automatic fallback when native device discovery fails."""
return AudioCPPSelection(
AudioCPPDevice(
registry="CPU",
backend="cpu",
index=0,
name="Host CPU",
kind="CPU",
target="cpu",
hardware_family="cpu",
),
f"{error}; running on CPU",
)
def _vulkan_hardware_family(name: str) -> str:
low = name.casefold()
if any(token in low for token in ("nvidia", "geforce", "quadro", "tesla")):
return "cuda"
if any(token in low for token in ("amd", "radeon")):
return "rocm"
if any(token in low for token in ("intel", "arc ")):
return "xpu"
return "vulkan"
def _device_families(registry: str, name: str, kind: str) -> tuple[str, str]:
# Software adapters such as Vulkan llvmpipe may be listed by a GPU
# registry but still execute on the CPU. Keep their runtime backend for
# explicit overrides while reporting and routing them as CPU work.
if kind == "CPU":
return "cpu", "cpu"
if registry in {"CUDA", "MUSA"}:
return "cuda", "cuda"
if registry in {"HIP", "ROCm"}:
return "rocm", "rocm"
if registry in {"Metal", "MTL"}:
return "mps", "mps"
if registry == "Vulkan":
return "vulkan", _vulkan_hardware_family(name)
return "cpu", "cpu"
def parse_device_list(output: str) -> tuple[AudioCPPDevice, ...]:
"""Parse the stable stdout contract of ``--list-devices``.
Backend diagnostics are emitted on stderr and deliberately never enter
this parser. Unknown future registries are ignored; malformed entries for
a registry we understand fail closed instead of selecting the wrong GPU.
"""
devices: list[AudioCPPDevice] = []
seen: set[tuple[str, int]] = set()
for raw in str(output or "").splitlines():
line = raw.strip()
registry, colon, detail = line.partition(":")
if not colon or registry not in _REGISTRY_BACKENDS:
continue
index_text, space, remainder = detail.strip().partition(" ")
if not space or not index_text.isascii() or not index_text.isdecimal():
raise RuntimeError(
f"malformed audio.cpp {registry} device entry"
)
index = int(index_text)
remainder = remainder.strip()
kind_start = remainder.rfind("[")
if kind_start < 0 or not remainder.endswith("]"):
raise RuntimeError(
f"malformed audio.cpp {registry} device entry"
)
name_field = remainder[:kind_start].strip()
if name_field:
if len(name_field) < 2 or name_field[0] != '"' or name_field[-1] != '"':
raise RuntimeError(
f"malformed audio.cpp {registry} device entry"
)
name = name_field[1:-1]
else:
name = ""
kind = remainder[kind_start + 1:-1].strip().upper()
if kind not in {"CPU", "GPU", "IGPU", "ACCEL", "META"}:
raise RuntimeError("unknown audio.cpp device kind")
# Registry aliases such as HIP/ROCm share one backend-local index
# namespace and therefore cannot safely describe different devices.
key = (_REGISTRY_BACKENDS[registry], index)
if key in seen:
raise RuntimeError(
f"duplicate audio.cpp device entry: {registry}:{index}"
)
seen.add(key)
target, hardware_family = _device_families(registry, name, kind)
devices.append(AudioCPPDevice(
registry=registry,
backend=_REGISTRY_BACKENDS[registry],
index=index,
name=name,
kind=kind,
target=target,
hardware_family=hardware_family,
))
if not devices:
raise RuntimeError("audio.cpp reported no recognized compute devices")
return tuple(devices)
def _platform_slug() -> str:
system = sys.platform
machine = platform.machine().lower()
if system == "win32":
return "windows-x64"
if system == "darwin":
return "darwin-arm64" if machine in ("arm64", "aarch64") else "darwin-x64"
if machine in ("x86_64", "amd64"):
return "linux-x64"
return f"linux-{machine}"
def binary_name(slug: str | None = None) -> str:
"""``audiocpp_server`` filename for ``slug`` (``.exe`` on Windows)."""
return _BINARY_NAMES.get(slug or _platform_slug(), "audiocpp_server")
def _probe_paths() -> list[Path]:
out: list[Path] = []
direct = os.environ.get(BIN_ENV, "").strip()
if direct:
out.append(Path(direct))
user_dir = os.environ.get(DIR_ENV, "").strip()
if user_dir:
out.append(Path(user_dir) / binary_name())
out.append(_PKG_BIN_DIR / binary_name())
return out
def is_installed() -> bool:
"""Cheap precedence-aware check for a usable server binary."""
try:
resolve_server_binary()
except RuntimeError:
return False
return True
def resolve_server_binary() -> Path:
"""Resolve the ``audiocpp_server`` binary. Raises ``RuntimeError`` with
install instructions when none is found."""
for cand in _probe_paths():
if cand.is_file():
if os.name == "nt" or os.access(cand, os.X_OK):
return cand
raise RuntimeError(
"audiocpp_server is not executable. Run `chmod +x "
"audiocpp_server` on the configured binary, then restart "
"VoiceStudio. See docs/engines/audio-cpp.md."
)
slug = _platform_slug()
asset = _ASSETS.get(slug)
if asset is None:
raise RuntimeError(
f"audio.cpp ships no prebuilt binary for this platform ({slug}). "
"Build from https://github.com/0xShug0/audio.cpp and set "
f"{BIN_ENV} to your audiocpp_server binary. See "
"docs/engines/audio-cpp.md."
)
raise RuntimeError(
"audiocpp_server not found. Download "
f"https://github.com/{GH_REPO}/releases/download/{VERSION}/{asset[0]} "
f"(SHA-256 {asset[1]}), verify and extract it, and set {BIN_ENV} to the "
"audiocpp_server binary (or "
f"{DIR_ENV} to its directory). See docs/engines/audio-cpp.md."
)
@functools.lru_cache(maxsize=4)
def _probe_device_outcome(binary: str) -> _ProbeOutcome:
try:
proc = subprocess.run( # nosec B603 -- executable is the resolved engine binary
[binary, "--list-devices"],
capture_output=True,
text=True,
timeout=10,
check=False,
)
except subprocess.TimeoutExpired:
return _ProbeOutcome(
error="audiocpp_server device discovery timed out after 10 seconds"
)
except OSError as exc:
return _ProbeOutcome(
error=(
"audiocpp_server device discovery could not start: "
f"{type(exc).__name__}"
)
)
if proc.returncode != 0:
return _ProbeOutcome(
error=(
"audiocpp_server device discovery failed "
f"(code {proc.returncode}). Check the audio.cpp server log "
"for details."
)
)
try:
return _ProbeOutcome(devices=parse_device_list(proc.stdout))
except RuntimeError as exc:
return _ProbeOutcome(error=str(exc))
def _probe_devices(binary: str) -> tuple[AudioCPPDevice, ...]:
outcome = _probe_device_outcome(binary)
if outcome.error:
raise RuntimeError(outcome.error)
return outcome.devices
def probe_devices() -> tuple[AudioCPPDevice, ...]:
"""Return the installed binary's devices without loading a model."""
return _probe_devices(str(resolve_server_binary()))
def _priority(device: AudioCPPDevice) -> tuple[int, int]:
if device.kind == "META":
# Tensor-parallel meta devices are valid explicit targets, but their
# resource footprint is not safe to choose implicitly over CPU.
rank = 8
elif device.backend != "cpu" and device.kind == "CPU":
# Native CPU is the predictable fallback. Software adapters remain
# available to an explicit backend override but never win auto mode.
rank = 7
elif device.backend == "cuda":
rank = 0
elif device.backend == "hip":
rank = 1
elif device.backend == "metal":
rank = 2
elif device.backend == "vulkan" and device.kind == "GPU":
rank = 3
elif device.backend == "vulkan" and device.kind in {"IGPU", "ACCEL"}:
rank = 4
elif device.backend == "cpu":
rank = 6
else:
rank = 5
return rank, device.index
def select_device(
devices: tuple[AudioCPPDevice, ...],
*,
requested_family: str = "auto",
backend_override: str | None = None,
device_override: int | None = None,
preferred_name: str = "",
) -> AudioCPPSelection:
"""Resolve one device with explicit overrides and discrete-GPU priority."""
if backend_override:
normalized = _BACKEND_ALIASES.get(backend_override.strip().lower())
if normalized is None:
valid = ", ".join(_BACKEND_ALIASES)
raise RuntimeError(
f"unknown audio.cpp backend '{backend_override}' (valid: {valid})"
)
candidates = [device for device in devices if device.backend == normalized]
if device_override is not None:
candidates = [
device for device in candidates if device.index == device_override
]
if not candidates:
suffix = "" if device_override is None else f" device {device_override}"
available = ", ".join(
f"{device.backend}:{device.index}" for device in devices
)
raise RuntimeError(
f"audio.cpp backend '{backend_override}'{suffix} is unavailable "
f"(available: {available})"
)
# An explicit runtime request should still prefer a compute device to
# a software adapter when no backend-local index was supplied. META is
# valid here because the user explicitly chose this registry.
return AudioCPPSelection(min(
candidates,
key=lambda device: (device.kind == "CPU", _priority(device)),
))
if device_override is not None:
raise RuntimeError(
f"{DEVICE_ENV} requires {BACKEND_ENV} because device indices are "
"backend-local"
)
family = (requested_family or "auto").strip().lower()
if family != "auto":
candidates = [
device for device in devices if device.hardware_family == family
]
if candidates:
preferred = preferred_name.casefold().strip()
if preferred:
named = [
device for device in candidates
if device.name
and (
preferred in device.name.casefold()
or device.name.casefold() in preferred
)
]
if named:
candidates = named
return AudioCPPSelection(min(candidates, key=_priority))
cpu = [device for device in devices if device.backend == "cpu"]
if cpu:
return AudioCPPSelection(
min(cpu, key=_priority),
f"requested {family.upper()} device is not exposed by the "
"installed audio.cpp binary; running on CPU",
)
raise RuntimeError(
f"requested {family.upper()} device is not exposed by the "
"installed audio.cpp binary"
)
return AudioCPPSelection(min(devices, key=_priority))
def resolve_compute_selection(caps=None) -> AudioCPPSelection:
"""Select the runtime from engine env overrides, Settings, then auto."""
backend_override = os.environ.get(BACKEND_ENV, "").strip() or None
raw_device = os.environ.get(DEVICE_ENV, "").strip()
device_override: int | None = None
if raw_device:
try:
device_override = int(raw_device)
except ValueError as exc:
raise RuntimeError(
f"{DEVICE_ENV} must be a non-negative integer"
) from exc
if device_override < 0:
raise RuntimeError(f"{DEVICE_ENV} must be a non-negative integer")
if caps is None:
from core.device_caps import detect_host_caps
caps = detect_host_caps()
requested = getattr(caps, "requested_family", "auto") or "auto"
try:
devices = probe_devices()
except RuntimeError as exc:
if backend_override or raw_device or requested != "auto":
raise
return _cpu_probe_fallback(exc)
selection = select_device(
devices,
requested_family=requested,
backend_override=backend_override,
device_override=device_override,
preferred_name=getattr(caps, "device_name", "") or "",
)
# HostCaps measures the preferred accelerator's device 0. Reuse that VRAM
# only when the selected native registry has exactly one device with the
# same normalized name. Multi-GPU peers with identical names stay unknown.
selected_name = " ".join(selection.device.name.casefold().split())
host_name = " ".join(
str(getattr(caps, "device_name", "") or "").casefold().split()
)
peers = [
device for device in devices
if device.backend == selection.device.backend
and " ".join(device.name.casefold().split()) == host_name
]
if (
selected_name
and selected_name == host_name
and len(peers) == 1
and float(getattr(caps, "vram_gb", 0.0) or 0.0) > 0
):
return AudioCPPSelection(
selection.device,
selection.fallback_reason,
float(caps.vram_gb),
)
return selection
def runtime_targets(devices: tuple[AudioCPPDevice, ...] | None = None) -> tuple[str, ...]:
"""Actual compute backends compiled into the selected binary."""
if devices is not None:
found = devices
else:
try:
found = probe_devices()
except RuntimeError:
if (
os.environ.get(BACKEND_ENV, "").strip()
or os.environ.get(DEVICE_ENV, "").strip()
):
raise
return ("cpu",)
ordered: list[str] = []
for device in sorted(found, key=_priority):
if device.target not in ordered:
ordered.append(device.target)
return tuple(ordered)
def invalidate() -> None:
"""Forget cached binary capability discovery after an install change."""
_probe_device_outcome.cache_clear()
def default_asset() -> tuple[str, str] | None:
"""``(filename, sha256)`` of the release asset for this host, or None
when upstream ships no prebuilt for it."""
return _ASSETS.get(_platform_slug())
def server_port() -> int:
"""Loopback port for the managed server (env override or default)."""
raw = os.environ.get(PORT_ENV, "").strip()
if raw:
try:
port = int(raw)
if 1 <= port <= 65535:
return port
logger.warning("Ignoring %s=%r: out of range.", PORT_ENV, raw)
except ValueError:
logger.warning("Ignoring %s=%r: not a number.", PORT_ENV, raw)
return DEFAULT_PORT
def package_filename() -> str:
"""GGUF package filename (env override or the Q8_0 default)."""
return os.environ.get(PACKAGE_ENV, "").strip() or DEFAULT_PACKAGE
def _materialize_gguf_cache_path(model_file: Path) -> Path:
"""Return a real ``.gguf`` path when the HF snapshot is a symlink.
audio.cpp canonicalizes model paths before inspecting the suffix. The
Hugging Face cache points the friendly ``.gguf`` snapshot name at an
extensionless content-addressed blob, so passing that symlink makes the
server reject a valid model. A hard link beside the snapshot keeps the
required suffix without copying a multi-gigabyte model or escaping the
snapshot's cleanup lifecycle.
"""
resolved = model_file.resolve()
if resolved.suffix.lower() == ".gguf":
return model_file
if model_file.suffix.lower() != ".gguf":
raise RuntimeError(f"audio.cpp model must be a .gguf file: {model_file}")
def _link(alias: Path) -> Path:
for attempt in range(2):
try:
os.link(resolved, alias)
except FileExistsError:
if (
not alias.is_symlink()
and alias.is_file()
and os.path.samefile(resolved, alias)
):
return alias
if attempt == 0 and alias.is_symlink():
alias.unlink()
continue
raise RuntimeError(
f"audio.cpp model alias points at a different file: {alias}"
) from None
return alias
raise RuntimeError(f"audio.cpp model alias could not be created: {alias}")
alias = model_file.with_name(
f".{model_file.stem}-{HF_MODEL_REVISION[:12]}.audiocpp.gguf"
)
try:
return _link(alias)
except OSError as exc:
if exc.errno == errno.EXDEV:
# An explicit symlink may live on a different filesystem from its
# target. Put the suffix-preserving hard link beside the resolved
# file so no multi-gigabyte copy is needed.
target_alias = resolved.with_name(
f".{resolved.name}-{HF_MODEL_REVISION[:12]}.audiocpp.gguf"
)
try:
return _link(target_alias)
except OSError as target_exc:
exc = target_exc
raise RuntimeError(
"audio.cpp cannot materialize the Hugging Face cache symlink as "
f"a .gguf hard link: {exc}"
) from exc
def resolve_model_file() -> Path:
"""Resolve an explicitly installed Breeze-TTS-2 GGUF file.
An explicit ``OMNIVOICE_AUDIOCPP_MODEL`` path wins (file or directory
containing the package file). Otherwise only the local Hugging Face cache
is inspected. Downloads must be started explicitly from Model Catalogue
Models, so generation can never silently transfer the 4.73 GiB package.
"""
override = os.environ.get("OMNIVOICE_AUDIOCPP_MODEL", "").strip()
if override:
cand = Path(override)
if cand.is_file():
return _materialize_gguf_cache_path(cand)
if cand.is_dir():
inner = cand / package_filename()
if inner.is_file():
return _materialize_gguf_cache_path(inner)
raise RuntimeError(
f"OMNIVOICE_AUDIOCPP_MODEL={override} is not a GGUF file or a "
"directory containing one."
)
from huggingface_hub import snapshot_download
from huggingface_hub.utils import LocalEntryNotFoundError
try:
cached = Path(
snapshot_download(
repo_id=HF_MODEL_REPO,
# Full immutable commit SHA declared above; Bandit cannot follow
# the module constant through this call.
revision=HF_MODEL_REVISION, # nosec B615
allow_patterns=[f"{PACKAGE_DIR}/{package_filename()}"],
local_files_only=True,
)
)
except (LocalEntryNotFoundError, OSError) as exc:
raise RuntimeError(
"Breeze-TTS-2 is not installed. Install the audio.cpp Breeze-TTS-2 "
"model from Model Catalogue → Models, or set "
"OMNIVOICE_AUDIOCPP_MODEL to an existing GGUF file."
) from exc
model_file = cached / PACKAGE_DIR / package_filename()
if not model_file.is_file():
raise RuntimeError(
f"Breeze-TTS-2 package {package_filename()} is not completely "
"installed. Reinstall it from Model Catalogue → Models."
)
return _materialize_gguf_cache_path(model_file)
__all__ = [
"AudioCPPDevice",
"AudioCPPSelection",
"BACKEND_ENV",
"BIN_ENV",
"DEFAULT_PACKAGE",
"DEFAULT_PORT",
"DEVICE_ENV",
"DIR_ENV",
"FAMILY",
"HF_MODEL_REPO",
"HF_MODEL_REVISION",
"MODEL_ID",
"PACKAGE_DIR",
"PACKAGE_ENV",
"PORT_ENV",
"VERSION",
"_materialize_gguf_cache_path",
"binary_name",
"default_asset",
"invalidate",
"is_installed",
"package_filename",
"parse_device_list",
"probe_devices",
"resolve_compute_selection",
"resolve_model_file",
"resolve_server_binary",
"runtime_targets",
"server_port",
]
+11 -5
View File
@@ -222,8 +222,7 @@ def _bootstrap_engines_venv(clone_dir: Path) -> Path:
Runs ``uv venv <engines_venv>`` then ``uv pip install --python
<engines_venv>/bin/python -e "<clone>[torch-runtime]"``. Verifies the
result by re-probing the import a successful uv invocation that still
can't import the stack indicates a deeper environment problem (e.g. the
``+cu128`` torch-runtime extra can't resolve on a non-CUDA host) and we
can't import the stack indicates a deeper environment problem, and we
raise with whatever stderr we captured plus a docs pointer.
"""
uv = _locate_uv()
@@ -254,6 +253,8 @@ def _bootstrap_engines_venv(clone_dir: Path) -> Path:
f"{exc.stderr.decode('utf-8', errors='replace') if exc.stderr else exc}"
) from exc
from core.torch_indexes import UV_PIP_CU128_ARGS
python_path = _venv_python_path(_ENGINES_VENV_DIR)
try:
subprocess.run(
@@ -261,6 +262,10 @@ def _bootstrap_engines_venv(clone_dir: Path) -> Path:
uv, "pip", "install",
"--python", str(python_path),
"-e", f"{clone_dir}[torch-runtime]",
# The extra pins torch==2.9.1+cu128, which exists only on
# PyTorch's index — without it this could never resolve, on
# any host (core.torch_indexes).
*UV_PIP_CU128_ARGS,
],
check=True,
timeout=_UV_PIP_INSTALL_TIMEOUT_S,
@@ -270,9 +275,10 @@ def _bootstrap_engines_venv(clone_dir: Path) -> Path:
except subprocess.CalledProcessError as exc:
raise RuntimeError(
"uv pip install -e failed during MOSS-TTS-v1.5 bootstrap "
f"({clone_dir}). On a non-CUDA host the upstream '[torch-runtime]' "
"extra (cu128) cannot resolve — set up the venv manually per "
"docs/engines/moss-tts-v15.md. Error: "
# uv's own error names what failed; the PyTorch index is always
# supplied now, so a guess about the host would only mislead.
f"({clone_dir}). See docs/engines/moss-tts-v15.md for the manual "
"install. Error: "
f"{exc.stderr.decode('utf-8', errors='replace') if exc.stderr else exc}"
) from exc
+24 -14
View File
@@ -48,6 +48,15 @@ from services.subprocess_backend import SubprocessBackend
logger = logging.getLogger("omnivoice.engines.pockettts")
_VENV_ENV_VAR = "OMNIVOICE_POCKETTTS_DIR"
def _own_venv_python() -> "Path | None":
"""The venv the one-click installer made for this engine, if any."""
from services.sidecar_install import engine_venv_python
return engine_venv_python(_VENV_ENV_VAR)
if TYPE_CHECKING:
import torch # noqa: F401
@@ -121,16 +130,17 @@ class PocketTTSBackend(SubprocessBackend):
def is_available(cls) -> tuple[bool, str]:
if platform_error := cls._platform_error():
return False, platform_error
# Optional-dep gate: the pocket-tts wheel is installed only when the user
# opted in. The interpreter is the parent's own (sys.executable), so
# there is no separate venv to validate.
try:
import pocket_tts # type: ignore[import-not-found] # noqa: F401
except Exception as e:
return False, (
f"pocket_tts package not installed or failed to import ({e}). "
f"Enable in Settings -> Engines (uv sync --extra pockettts)."
)
# Installed either into its own venv by the one-click installer, which
# verified `import pocket_tts` there before saving the path, or into the
# app's environment by `uv sync --extra pockettts`.
if _own_venv_python() is None:
try:
import pocket_tts # type: ignore[import-not-found] # noqa: F401
except Exception as e:
return False, (
f"pocket_tts package not installed or failed to import ({e}). "
"Install it from Model Catalogue → Engines."
)
# The model repository has an additional gated-access agreement and
# prohibited-use conditions beyond its CC-BY-4.0 license. Keep first
@@ -145,10 +155,10 @@ class PocketTTSBackend(SubprocessBackend):
@classmethod
def venv_python(cls) -> Path:
# Parent interpreter: pocket-tts deps (torch>=2.5, scipy, beartype) sit
# happily at the parent's pins, so this isolates for crash recovery, not
# dependency pins (same rationale as omnivoice-subprocess).
return Path(sys.executable)
# Its own venv when the one-click installer made one. Otherwise the
# parent interpreter, where `uv sync --extra pockettts` installs it
# (its deps sit happily at the parent's pins).
return _own_venv_python() or Path(sys.executable)
@classmethod
def sidecar_script(cls) -> Path:
+23 -12
View File
@@ -48,6 +48,15 @@ if TYPE_CHECKING:
logger = logging.getLogger("omnivoice.supertonic3")
_VENV_ENV_VAR = "OMNIVOICE_SUPERTONIC3_DIR"
def _own_venv_python() -> "Path | None":
"""The venv the one-click installer made for this engine, if any."""
from services.sidecar_install import engine_venv_python
return engine_venv_python(_VENV_ENV_VAR)
# Absolute path to the sidecar script ‑‑ same pattern as IndexTTS's
# ``INDEXTTS_SIDECAR_SCRIPT``. SubprocessBackend spawns it with the
@@ -80,11 +89,11 @@ class Supertonic3Backend(SubprocessBackend):
@classmethod
def venv_python(cls) -> Path:
"""Supertonic-3 lives in the main OmniVoice venv ‑‑ no dedicated
venv. ``sys.executable`` is the parent interpreter, which is the
same Python that ``uv sync --extra supertonic`` populated.
"""Its own venv when the one-click installer made one. Otherwise the
parent interpreter, the same Python ``uv sync --extra supertonic``
populated.
"""
return Path(sys.executable)
return _own_venv_python() or Path(sys.executable)
@classmethod
def sidecar_script(cls) -> Path:
@@ -96,14 +105,16 @@ class Supertonic3Backend(SubprocessBackend):
def is_available(cls) -> tuple[bool, str]:
# 1. Optional-dep gate (TTS-02). The ``supertonic`` wheel is only
# installed when the user opted in via ``--extra supertonic``.
try:
import supertonic # type: ignore[import-not-found] # noqa: F401
except ImportError:
return False, (
"supertonic package not installed. Enable in "
"Model Catalogue → Engines (installs `supertonic` via `uv add --optional "
"supertonic supertonic==1.3.1`)."
)
# Its own venv (made by the one-click installer, which verified the
# import there) or the app's environment (`uv sync --extra`).
if _own_venv_python() is None:
try:
import supertonic # type: ignore[import-not-found] # noqa: F401
except ImportError:
return False, (
"supertonic package not installed. Install it from "
"Model Catalogue → Engines."
)
# 2. License acceptance gate (TTS-05). Defence in depth: the
# settings_store helper handles the read; we just refuse
+11 -3
View File
@@ -137,9 +137,17 @@ def _resolve_pinned_sha() -> str:
# Final fallback ‑‑ relative import for when the file is invoked
# via ``python backend/engines/supertonic3/sidecar.py`` rather
# than via ``python -m backend.engines.supertonic3.sidecar``.
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from engines.supertonic3.constants import PINNED_REVISION_SHA # type: ignore[import-not-found]
return PINNED_REVISION_SHA
# Load constants.py by path. Importing it as `engines.supertonic3…`
# runs the package __init__, which imports the app's backend, and that
# is absent from the engine's own venv (one-click install).
import importlib.util
spec = importlib.util.spec_from_file_location(
"_supertonic3_constants", Path(__file__).resolve().with_name("constants.py"),
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # type: ignore[union-attr]
return module.PINNED_REVISION_SHA
# ── model loading (lazy, on first synthesize) ─────────────────────────────
+43 -8
View File
@@ -624,7 +624,19 @@ def _phase_a_build_inner() -> None:
_startup_progress.begin_step("ml_imports")
import torchaudio
warnings.filterwarnings("ignore", category=UserWarning)
torchaudio.set_audio_backend("soundfile")
# torchaudio 2.9 REMOVED set_audio_backend(); soundfile has been the only
# backend since 2.0, so the call was already a no-op there and is simply
# absent now. Unguarded it raises AttributeError inside `ml_imports`, and a
# failure in that phase takes the whole backend down — the desktop app sits
# on "starting backend" forever and /health stays 503.
#
# That is not a hypothetical version: RTX 50-series (Blackwell, sm_120)
# users have no choice but to move off the pinned torch 2.8.0, which has no
# sm_120 kernels, and the torch 2.9.x they land on brings torchaudio 2.9
# with it. So the one group forced to upgrade hit a hard startup crash for
# a line that does nothing (#1931).
if hasattr(torchaudio, "set_audio_backend"):
torchaudio.set_audio_backend("soundfile")
from utils import hf_progress
# HF tqdm patch before any library import that can trigger
# hf_hub_download (transformers, mlx_whisper, …).
@@ -1080,6 +1092,33 @@ async def lifespan(app: FastAPI):
app.state.startup_task = asyncio.create_task(_deferred_startup(app))
yield
# ── Graceful shutdown (SIGTERM from Tauri, Ctrl+C, etc.) ────────────
# Retire the run sentinel FIRST, before any bounded wait below (#1895):
# once uvicorn has begun graceful shutdown the exit is deliberate by
# definition, so the sentinel has already done its job. This is one
# os.remove, against a ~50s worst-case tail of bounded waits plus model
# unload / free_vram() / gc.collect() below. Measured on macOS: a normal
# shutdown takes 5.25s end to end, while the desktop shell allows 2s
# (bootstrap.rs terminate_process_tree) before SIGKILL — so the old
# placement at the very end was killed every time on any run that had
# reached a working state. Doing the deadline-sensitive step first makes
# correctness independent of how much of that tail runs, instead of
# depending on the shell-side deadline being long enough to cover it.
#
# SCOPE, explicitly: this only helps platforms where lifespan teardown
# actually BEGINS. On Windows it does not — tools.rs terminates the job
# object with no graceful phase at all, so this line is never reached and
# a deliberate quit is still misreported as a crash there. That needs the
# shell to signal deliberate intent before the hard kill, which is a
# separate Rust-side change and is tracked separately; nothing here
# should be read as fixing Windows.
#
# sentinel_cleared feeds the truthful "Shutdown: done."/degraded log at
# the end of this function; nothing below re-clears the sentinel, so a
# later failure can't mask this result.
try:
sentinel_cleared = run_sentinel.clear_sentinel()
except Exception:
sentinel_cleared = False
# May run after a startup that never finished (SIGTERM mid-Phase-A/B), so
# every handle is read from app.state with a None default and every
# deferred-phase name is guarded.
@@ -1206,13 +1245,9 @@ async def lifespan(app: FastAPI):
await close_http_client()
except Exception:
pass
# Last thing on a clean shutdown: retire the run sentinel so the next
# startup doesn't misread this exit as a crash (#1164). If clearing fails,
# retain the sentinel and report a degraded shutdown truthfully.
try:
sentinel_cleared = run_sentinel.clear_sentinel()
except Exception:
sentinel_cleared = False
# Sentinel was already retired at the TOP of this block (#1895) — report
# truthfully using that result rather than clearing (or re-checking) it
# again here, so a failure in the steps above can't mask it as "done."
if sentinel_cleared:
logger.info("Shutdown: done.")
else:
+27 -6
View File
@@ -1801,9 +1801,7 @@ class SherpaDictationBackend(ASRBackend):
def __init__(self, model_id: str | None = None):
from services import sherpa_dictation as _sd
mid = model_id or os.environ.get(
"OMNIVOICE_SHERPA_ASR_MODEL", _sd.DEFAULT_MODEL_ID
)
mid = model_id or sherpa_engine_model_id()
spec = _sd.get_spec(mid)
if spec is None:
raise ValueError(
@@ -3047,6 +3045,31 @@ def get_sherpa_dictation_backend(model_id: str) -> "SherpaDictationBackend":
return backend
def sherpa_engine_model_id() -> str:
"""The sherpa model the ``sherpa-onnx-asr`` engine loads when nothing pins
one explicitly: env var (power-user pin) the dictation model the user
picked in Settings / the Engines menu the catalogue default.
Unlike :func:`dictation_model_id` this ignores ``dictation.enabled`` a
user who turned the hotkey off but chose the Sherpa engine for dub/batch
transcription still means *this* model and never returns None: the
engine needs *some* model to construct. A demoted model (decoded nothing
on this host) falls through to the default rather than being re-picked.
"""
from services import sherpa_dictation as _sd
explicit = os.environ.get("OMNIVOICE_SHERPA_ASR_MODEL")
if explicit:
return explicit
try:
from core import prefs
mid = prefs.get("dictation.model_id")
except Exception: # noqa: BLE001 — prefs store unavailable → default
return _sd.DEFAULT_MODEL_ID
if _sd.is_sherpa_model(mid) and not _sd.is_demoted(mid):
return _sd.get_spec(mid).id
return _sd.DEFAULT_MODEL_ID
def dictation_model_id() -> str | None:
"""The selected sherpa dictation model id, or None when dictation is off /
no sherpa model is chosen. Env var wins (power-user pin), then prefs."""
@@ -3321,9 +3344,7 @@ def _offline_asr_repo(backend_id: str | None = None) -> str | None:
# Unknown/none → fail open.
try:
from services import sherpa_dictation as _sd
spec = _sd.get_spec(
os.environ.get("OMNIVOICE_SHERPA_ASR_MODEL", _sd.DEFAULT_MODEL_ID)
)
spec = _sd.get_spec(sherpa_engine_model_id())
return spec.repo_id if spec is not None else None
except Exception: # noqa: BLE001 — preflight must stay best-effort
return None
+1 -1
View File
@@ -65,7 +65,7 @@ _MODE_PREF = "hf_endpoint_mode" # "auto" | "manual"; absent → default
_DECISION_PREF = "hf_endpoint_auto" # cached decision dict (see race())
DECISION_MAX_AGE_S = 7 * 24 * 3600.0 # re-race a decision older than 7 days
PROBE_TIMEOUT_S = 3.0 # short: a probe is not a download
PROBE_TIMEOUT_S = 8.0 # high-latency / China paths often need >3s
MIRROR_SPEEDUP_FACTOR = 3.0 # mirror must be ≥3× faster to win
# Small, stable, long-lived public file for the optional ranged-GET
+10
View File
@@ -33,10 +33,20 @@ _ESTIMATES: dict[str, dict] = {
"destination": "hf_model_cache",
"deduplication": None,
},
"audiocpp": {
"package_download_bytes": None,
"unique_installed_bytes": None,
"potentially_shared_bytes": None,
"temporary_free_bytes": None,
"confidence": "estimated",
"destination": "hf_model_cache",
"deduplication": None,
},
}
_MODEL_REPOS = {
"omnivoice": "k2-fsa/OmniVoice",
"kittentts": "KittenML/kitten-tts-mini-0.8",
"audiocpp": "audio-cpp/audio.cpp-gguf",
}
+23 -3
View File
@@ -88,14 +88,34 @@ def snapshot(
evidence_state = "loaded"
if isolated and provider is None and actual_device is None:
evidence_state = "subprocess_loaded_provider_unreported"
from core.scrub import scrub_text
runtime_device_name = routing.get("runtime_device_name")
device_name = (
getattr(caps, "device_name", "")
if runtime_device_name is None
else runtime_device_name
)
public_device_name = scrub_text(device_name)[:256]
return {
"implementation_variant": f"{engine_cls.__module__}.{engine_cls.__name__}",
"declared_device_families": list(getattr(engine_cls, "gpu_compat", ("cpu",))),
"declared_device_families": list(
routing.get("gpu_compat", getattr(engine_cls, "gpu_compat", ("cpu",)))
),
"evidence_state": evidence_state,
"actual_execution_provider": provider,
"actual_execution_device": actual_device,
"gpu_name": getattr(caps, "device_name", "") or None,
"gpu_architecture": _gpu_architecture(getattr(caps, "family", "cpu")),
"gpu_name": public_device_name or None,
"gpu_architecture": None
if (
routing.get("runtime_hardware_family")
and not routing.get("runtime_device_verified")
)
else _gpu_architecture(
routing.get("runtime_hardware_family")
or getattr(caps, "family", "cpu")
),
"runtime_vram_gb": routing.get("runtime_vram_gb"),
"precision_or_quantization": precision,
"cpu_fallback_reason": runtime_fallback_reason or (routing.get("routing_reason") if fallback else None),
"cpu_fallback_stage": runtime_fallback_stage or ("routing_preflight" if fallback else None),
+73 -15
View File
@@ -14,6 +14,7 @@ carry a home path.
"""
from __future__ import annotations
import asyncio
from typing import Literal, TypedDict
from core.device_caps import (
@@ -31,7 +32,44 @@ class RoutingResult(TypedDict):
routing_reason: str | None # raw, pre-scrub
def under_provisioned_vram(caps: HostCaps, min_vram_gb: float = 0.0) -> bool:
def runtime_compute_profile(engine_or_cls, caps: HostCaps) -> dict:
"""Return one engine's runtime-aware compute contract.
Native executables may discover providers independently of PyTorch. They
override ``runtime_compute_profile``; all existing engines retain the
exact static routing contract.
"""
hook = getattr(engine_or_cls, "runtime_compute_profile", None)
if callable(hook):
return hook(caps)
cls = engine_or_cls if isinstance(engine_or_cls, type) else type(engine_or_cls)
compat = tuple(getattr(cls, "gpu_compat", ("cpu",)))
floor = float(getattr(cls, "min_vram_gb", 0.0) or 0.0)
return {
"gpu_compat": compat,
"min_vram_gb": floor,
**resolve_routing(compat, caps, floor),
"runtime_backend": None,
"runtime_device_index": None,
"runtime_device_name": None,
"runtime_hardware_family": None,
"runtime_vram_gb": None,
"runtime_device_verified": None,
}
async def runtime_compute_profile_async(engine_or_cls, caps: HostCaps) -> dict:
"""Resolve runtime compute metadata without blocking the event loop."""
return await asyncio.to_thread(runtime_compute_profile, engine_or_cls, caps)
def under_provisioned_vram(
caps: HostCaps,
min_vram_gb: float = 0.0,
*,
family: str | None = None,
vram_gb: float | None = None,
) -> bool:
"""Is this host's DEDICATED VRAM below the engine's declared floor?
The one definition of "under-provisioned", shared by everything that acts
@@ -40,7 +78,8 @@ def under_provisioned_vram(caps: HostCaps, min_vram_gb: float = 0.0) -> bool:
.generate_timeout_s``). It was written out inline in each of them, which is
how the budget came to disagree with the warning printed next to it.
Dedicated-VRAM families ONLY. On MPS, ``HostCaps.vram_gb`` is a heuristic
Dedicated-VRAM families ONLY. CUDA, ROCm, XPU, and a native Vulkan device
report dedicated memory. On MPS, ``HostCaps.vram_gb`` is a heuristic
(system RAM / 2, see device_caps) for a UNIFIED memory pool; comparing it
against a floor measured on discrete CUDA hardware would tell every 8 GB Mac
its 4 GB "VRAM" is too small for an engine that runs fine there. A VRAM
@@ -49,10 +88,35 @@ def under_provisioned_vram(caps: HostCaps, min_vram_gb: float = 0.0) -> bool:
"""
if not min_vram_gb or min_vram_gb <= 0:
return False
if getattr(caps, "family", None) not in ("cuda", "rocm"):
if (family or getattr(caps, "family", None)) not in (
"cuda", "rocm", "xpu", "vulkan",
):
return False
vram_gb = float(getattr(caps, "vram_gb", 0.0) or 0.0)
return 0 < vram_gb < float(min_vram_gb)
raw_vram_gb = getattr(caps, "vram_gb", 0.0) if vram_gb is None else vram_gb
available_vram_gb = float(raw_vram_gb or 0.0)
return 0 < available_vram_gb < float(min_vram_gb)
def low_vram_caveat(
caps: HostCaps,
min_vram_gb: float = 0.0,
*,
family: str | None = None,
vram_gb: float | None = None,
) -> str | None:
"""User-facing advisory for a known under-provisioned dedicated GPU."""
if not under_provisioned_vram(
caps, min_vram_gb, family=family, vram_gb=vram_gb,
):
return None
device = caps.device_name or (family or caps.family).upper()
available_vram_gb = caps.vram_gb if vram_gb is None else vram_gb
return (
f"{device} has {available_vram_gb:.1f} GB VRAM; this engine wants about "
f"{min_vram_gb:.0f} GB. It will run, but expect slow generations "
f"that may time out. Unload other models before generating, keep "
f"the text short, or pick a lighter engine."
)
def _caveat(caps: HostCaps, min_vram_gb: float = 0.0) -> str | None:
@@ -75,15 +139,7 @@ def _caveat(caps: HostCaps, min_vram_gb: float = 0.0) -> str | None:
for note in caps.notes:
if KERNEL_RISK_MARKER in note:
return f"{caps.family.upper()} selected, but: {note}"
if under_provisioned_vram(caps, min_vram_gb):
device = caps.device_name or caps.family.upper()
return (
f"{device} has {caps.vram_gb:.1f} GB VRAM; this engine wants about "
f"{min_vram_gb:.0f} GB. It will run, but expect slow generations "
f"that may time out. Unload other models before generating, keep "
f"the text short, or pick a lighter engine."
)
return None
return low_vram_caveat(caps, min_vram_gb)
def resolve_routing(
@@ -223,5 +279,7 @@ def routing_fields(
__all__ = [
"RoutingStatus", "RoutingResult", "resolve_routing", "routing_fields",
"routing_notice", "header_safe_reason", "under_provisioned_vram",
"routing_notice", "header_safe_reason", "low_vram_caveat",
"runtime_compute_profile", "runtime_compute_profile_async",
"under_provisioned_vram",
]
+1
View File
@@ -15,6 +15,7 @@ _SHA = re.compile(r"[0-9a-f]{40}\Z")
CURATED_REVISIONS: dict[str, str] = {
"facebook/nllb-200-distilled-600M": "f8d333a098d19b4fd9a8b18f94170487ad3f821d",
"k2-fsa/OmniVoice": "c5fdb5ccb189668d56333f77ba2629f4cd7535f4",
"audio-cpp/audio.cpp-gguf": "dc6fecccc2b0c6bdda0a8b2f38fa61394fee0b9c",
"Systran/faster-whisper-large-v3": "edaa852ec7e145841d8ffdb056a99866b5f0a478",
"mlx-community/whisper-large-v3-mlx": "49e6aa286ad60c14352c404340ded53710378a11",
"mlx-community/whisper-large-v3-turbo": "a4aaeec0636e6fef84abdcbe3544cb2bf7e9f6fb",
+30 -15
View File
@@ -525,7 +525,8 @@ class GpuPoolBusyError(TimeoutError):
def generate_timeout_s(
text: "str | None", *, engine: object = None, execution_device: "str | None" = None,
min_vram_gb: float = 0.0,
min_vram_gb: float = 0.0, hardware_family: "str | None" = None,
vram_gb: "float | None" = None,
) -> float:
"""THE wall-clock execution budget for one synthesis job, scaled to input.
@@ -555,7 +556,10 @@ def generate_timeout_s(
the card. Only the budget ignored it. So an under-provisioned accelerator
now floors at the CPU budget the class of hardware it actually performs
like. ``min_vram_gb`` is the engine's declared floor; callers that pass
``engine`` get it read off the engine automatically.
``engine`` get it read off the engine automatically. Native runtimes pass
an explicit ``vram_gb=0`` when their dedicated-memory probe failed; that
unknown capacity gets the same conservative CPU-class budget without
claiming the card is under-provisioned in user-facing diagnostics.
"""
base = GPU_JOB_TIMEOUT_S
try:
@@ -565,14 +569,12 @@ def generate_timeout_s(
if not min_vram_gb and engine is not None:
min_vram_gb = float(getattr(engine, "min_vram_gb", 0.0) or 0.0)
if execution_device is None and engine is not None:
from services.engine_routing import resolve_routing
compat = getattr(engine, "gpu_compat", None)
if compat is None:
compat = getattr(type(engine), "gpu_compat", (family, "cpu"))
if tuple(compat) == ("cpu",):
family = "cpu"
else:
family = resolve_routing(compat, caps, min_vram_gb)["effective_device"]
from services.engine_routing import runtime_compute_profile
profile = runtime_compute_profile(engine, caps)
family = profile["effective_device"]
min_vram_gb = profile["min_vram_gb"]
hardware_family = profile.get("runtime_hardware_family")
vram_gb = profile.get("runtime_vram_gb")
universal_override = (
_GENERATE_TIMEOUT_EXPLICIT
or GPU_JOB_TIMEOUT_S != _CONFIGURED_GPU_JOB_TIMEOUT_S
@@ -586,10 +588,21 @@ def generate_timeout_s(
)
if family == "cpu" and (cpu_explicit or not universal_override):
base = CPU_JOB_TIMEOUT_S
elif not universal_override and family in ("cuda", "rocm"):
elif not universal_override and family in (
"cuda", "rocm", "vulkan", "xpu",
):
from services.engine_routing import under_provisioned_vram
if under_provisioned_vram(caps, min_vram_gb):
runtime_family = hardware_family or family
unknown_dedicated_vram = (
min_vram_gb > 0
and runtime_family in ("cuda", "rocm", "xpu", "vulkan")
and vram_gb is not None
and float(vram_gb or 0.0) <= 0
)
if unknown_dedicated_vram or under_provisioned_vram(
caps, min_vram_gb, family=hardware_family, vram_gb=vram_gb,
):
# `max`, never a plain assignment: an operator who raised the
# accelerated budget above the CPU one must not have it cut.
base = max(base, CPU_JOB_TIMEOUT_S)
@@ -1132,7 +1145,7 @@ def _timeout_guidance(
"compute-bound. For a durable fix try shorter text or a lighter "
"engine (OmniVoice GGUF and Supertonic-3 are CPU-tuned). If you "
"expect very long single generations, raise "
"OMNIVOICE_GENERATE_TIMEOUT_S."
"the compute-time budget in Settings → Performance & Device."
)
# #1226/#1222: two users on 4 GB cards were told, generically, that the GPU
# "is VRAM-starved" — true, but it read as a transient contention problem
@@ -1155,7 +1168,8 @@ def _timeout_guidance(
f"Supertonic-3 are tuned for small/no GPU) or shorter text; "
f"Flush caches / Unload the resident model (top toolbar or "
f"Model Catalogue → Models) frees what little headroom there is. (Raise "
f"OMNIVOICE_GENERATE_TIMEOUT_S if you'd rather let long "
f"the compute-time budget in Settings → Performance & Device if "
f"you'd rather let long "
f"generations run.)"
)
return common + (
@@ -1163,7 +1177,8 @@ def _timeout_guidance(
"contend for memory). For a durable fix, Flush caches / Unload the "
"resident model (top toolbar or Model Catalogue → Models) before retrying, "
"try shorter text, a lighter engine, or set the engine to CPU in "
"Model Catalogue → Models. (Raise OMNIVOICE_GENERATE_TIMEOUT_S for very "
"Model Catalogue → Models. (Raise the compute-time budget in "
"Settings → Performance & Device for very "
"long single generations.)"
)
+40
View File
@@ -222,6 +222,46 @@ def entries_for_language(entries, language: Optional[str]) -> dict[str, str]:
return merged
def inert_entries_for_language(entries, language: str | None) -> list[dict]:
"""Enabled entries that MATCH the language but cannot be applied yet.
Settings offers three notations Respelling, IPA, CMU and only
respelling substitutes text today. IPA and CMU rows save cleanly, are
validated, get a badge and can be toggled on, and are then dropped before
term matching. Nothing downstream reads them.
That is Phase 1 behaving as designed; the gap is that it is INVISIBLE.
"Test a sentence" reported "No entries match; spoken as written" for a
term that does match, which is not a degraded answer but a wrong one, and
it sent the user off to re-type an entry that was already correct (#1949).
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)". This is that flag: the
caller can now say WHY nothing happened instead of implying nothing
matched.
"""
req_prefix = _lang_prefix(language)
out: list[dict] = []
for e in entries or []:
try:
if not int(e["enabled"]):
continue
except (KeyError, IndexError, TypeError, ValueError):
continue
term = (e["term"] or "").strip()
if not term:
continue
etype = (e["type"] or "respelling").strip().lower()
if etype == "respelling":
continue
scope = (e["language"] or _ALL_LANG).strip() or _ALL_LANG
if scope != _ALL_LANG and (req_prefix is None or scope[:2].lower() != req_prefix):
continue
out.append({"term": term, "type": etype})
return out
# ── Inline one-off override: [[term|replacement]] / [[replacement]] ─────────
#
# Double brackets are unambiguous against the single-bracket grammar
+25 -1
View File
@@ -29,6 +29,14 @@ import httpx
_HF_AUTH_HOSTS = ("huggingface.co", "hf.co")
_DEFAULT_CONNECTIONS = 8
_MIN_SEGMENT_BYTES = 4 * 1024 * 1024 # don't split below this — overhead > gain
# Cap on a single segment. Progress is committed to the manifest only when a
# whole segment lands, so the segment size is also the MOST bytes a dropped
# connection can throw away. Sizing segments as size/num_connections made that
# ~100 MB on an 800 MB blob: on a link that drops every ~50 MB no segment ever
# completed, the manifest was never written, and every retry restarted from
# zero (#1224 follow-up). Bounded segments turn the same flaky link into steady
# forward progress.
_MAX_SEGMENT_BYTES = 16 * 1024 * 1024
_READ_CHUNK = 1024 * 1024
@@ -67,8 +75,16 @@ async def _resolve(client: httpx.AsyncClient, url: str, token: Optional[str], ma
def _plan_segments(size: int, num_connections: int) -> list[tuple[int, int]]:
"""Byte ranges to fetch, each at most ``_MAX_SEGMENT_BYTES``.
``num_connections`` controls how many run at once (see the semaphore in
:func:`segmented_download`), NOT how many segments exist a large file is
split into many bounded segments so each one commits to the manifest
quickly and a dropped connection costs at most one segment.
"""
n = max(1, min(num_connections, max(1, size // _MIN_SEGMENT_BYTES)))
step = -(-size // n) # ceil
step = max(_MIN_SEGMENT_BYTES, min(step, _MAX_SEGMENT_BYTES))
segs = []
start = 0
while start < size:
@@ -143,6 +159,10 @@ async def segmented_download(
_preallocate(part, size)
segments = [s for s in _plan_segments(size, num_connections) if s not in done]
lock = asyncio.Lock()
# Segments are bounded, so a big file yields many more of them than
# there are connections. The semaphore — not the segment count — is
# what keeps concurrency at num_connections.
sem = asyncio.Semaphore(max(1, num_connections))
async def _fetch(seg: tuple[int, int]):
start, end = seg
@@ -169,8 +189,12 @@ async def segmented_download(
done.add(seg)
_save_done(part, size, done)
async def _fetch_limited(seg: tuple[int, int]):
async with sem:
await _fetch(seg)
if segments:
await asyncio.gather(*(_fetch(s) for s in segments))
await asyncio.gather(*(_fetch_limited(s) for s in segments))
# ── verify ──────────────────────────────────────────────────────
actual = os.path.getsize(part)
+338 -21
View File
@@ -126,6 +126,33 @@ class SidecarSpec:
invalidate: Callable[[], None] = field(default=lambda: None)
# Cheap "is a healthy install already present?" probe (file existence only).
installed_probe: Callable[[], bool] = field(default=lambda: False)
# Extra `uv venv` arguments — an interpreter pin for an upstream that
# declares one, e.g. ("--python", "3.10").
venv_args: tuple[str, ...] = ()
# `uv pip install` target, "{checkout}" substituted. Each upstream installs
# differently (editable, editable with an extra, a requirements file, a
# constraints file); the default is the editable install IndexTTS uses.
install_args: tuple[str, ...] = ("-e", "{checkout}")
# Add PyTorch's CUDA index on a CUDA host. Plain PyPI torch is CPU-only on
# Windows, and `+cuNNN` local-version pins exist nowhere else.
uses_cuda_index: bool = False
# Python that proves the venv works; "{checkout}" / "{checkout_repr}"
# substituted. None means `import <probe_module>`.
probe_code: Optional[str] = None
# The file whose presence proves a fetched checkout is the whole
# repository. Most upstreams ship a pyproject.toml; Confucius4 ships
# only requirements.txt and setup.py.
source_manifest: str = "pyproject.toml"
# False for an engine that is a PyPI package, not a repository: nothing
# is fetched, and the managed root holds only the engine's own venv.
has_source: bool = True
# Add PyTorch's CPU index on every host, for an engine that only ever
# runs torch on the CPU (see core.torch_indexes).
cpu_torch_index: bool = False
# Can the one-click install work on THIS machine? (ok, reason). Consulted
# before an Install button is offered and again when an install starts, so
# a host the upstream does not support never gets a job that can only fail.
host_supported: Callable[[], tuple[bool, str]] = field(default=lambda: (True, ""))
def _indextts_invalidate() -> None:
@@ -138,6 +165,86 @@ def _indextts_installed() -> bool:
return is_indextts_installed()
def _moss_invalidate() -> None:
from engines.moss_tts_v15 import bootstrap
bootstrap.invalidate()
def _moss_installed() -> bool:
from engines.moss_tts_v15.bootstrap import is_moss_tts_v15_installed
return is_moss_tts_v15_installed()
def _confucius4_invalidate() -> None:
from engines.confucius4 import bootstrap
bootstrap.invalidate()
def _confucius4_installed() -> bool:
from engines.confucius4.bootstrap import is_confucius4_installed
return is_confucius4_installed()
def _dots_invalidate() -> None:
from engines.dots_tts import bootstrap
bootstrap.invalidate()
def _dots_installed() -> bool:
from engines.dots_tts.bootstrap import is_dots_tts_installed
return is_dots_tts_installed()
def _host_family() -> str:
"""The accelerator family this host runs, or "cpu" when it cannot tell."""
try:
from core.device_caps import detect_host_caps
return str(detect_host_caps().family)
except Exception: # noqa: BLE001 — a probe failure must not break installs
return "cpu"
def _moss_host() -> tuple[bool, str]:
if _host_family() == "cuda":
return True, ""
return False, (
"MOSS-TTS-v1.5's one-click install uses its CUDA build of PyTorch, and "
"this machine has no NVIDIA GPU available. Its guide covers a manual "
"CPU install."
)
def _dots_host() -> tuple[bool, str]:
if sys.platform != "win32":
return True, ""
return False, (
"dots.tts publishes no Windows install. Run VoiceStudio on Linux or "
"macOS, or under WSL2, to use it."
)
def _pockettts_host() -> tuple[bool, str]:
import platform
if sys.platform == "darwin" and platform.machine().lower() == "x86_64":
return False, (
"PocketTTS needs a PyTorch version that has no Intel Mac build."
)
return True, ""
def _in_app_env(module: str) -> Callable[[], bool]:
"""An install made with ``uv sync --extra`` lives in the app's own
environment. It counts as installed, so the installer never provisions a
second copy over one that works."""
def probe() -> bool:
import importlib.util
try:
return importlib.util.find_spec(module) is not None
except (ImportError, ValueError):
return False
return probe
SPECS: dict[str, SidecarSpec] = {
"indextts2": SidecarSpec(
engine_id="indextts2",
@@ -170,9 +277,165 @@ SPECS: dict[str, SidecarSpec] = {
invalidate=_indextts_invalidate,
installed_probe=_indextts_installed,
),
# Pinned to the upstream commits current on 2026-09-10. Weights are not
# fetched here: each engine downloads them into the shared HF cache on its
# first synthesis, as its manual install always has.
"moss-tts-v15": SidecarSpec(
engine_id="moss-tts-v15",
display_name="MOSS-TTS-v1.5",
repo_url="https://github.com/OpenMOSS/MOSS-TTS.git",
tarball_url=(
"https://github.com/OpenMOSS/MOSS-TTS/archive/"
"934d6826b084c46a0d033402174d5f8ac4ed2519.tar.gz"
),
checkout_dirname="MOSS-TTS",
env_var="OMNIVOICE_MOSS_TTS_V15_DIR",
probe_module="transformers",
probe_code="import transformers, torch",
source_revision="934d6826b084c46a0d033402174d5f8ac4ed2519",
source_required_path="pyproject.toml",
venv_args=("--python", "3.11"),
install_args=("-e", "{checkout}[torch-runtime]"),
uses_cuda_index=True,
host_supported=_moss_host,
docs_path="docs/engines/moss-tts-v15.md",
# ~7 GB CUDA torch venv now, ~16 GB of weights on first synthesis.
required_bytes=24 * _GIB,
dependency_bytes=8 * _GIB,
temporary_free_bytes=8 * _GIB,
disk_confidence="estimated",
invalidate=_moss_invalidate,
installed_probe=_moss_installed,
),
"confucius4-tts": SidecarSpec(
engine_id="confucius4-tts",
display_name="Confucius4-TTS",
repo_url="https://github.com/netease-youdao/Confucius4-TTS.git",
tarball_url=(
"https://github.com/netease-youdao/Confucius4-TTS/archive/"
"4fb32c481302d8858c3aec6a1c2a8b4cea8894c0.tar.gz"
),
checkout_dirname="Confucius4-TTS",
env_var="OMNIVOICE_CONFUCIUS4_TTS_DIR",
probe_module="confuciustts",
# Upstream is not pip-installable; the package resolves from the
# checkout on sys.path, exactly as the engine's sidecar imports it.
probe_code="import sys; sys.path.insert(0, {checkout_repr}); import confuciustts",
source_revision="4fb32c481302d8858c3aec6a1c2a8b4cea8894c0",
# No pyproject.toml upstream: requirements.txt is its manifest.
source_manifest="requirements.txt",
source_required_path="setup.py",
venv_args=("--python", "3.10"),
install_args=("-r", "{checkout}/requirements.txt"),
# torch==2.7.0: CPU-only from PyPI on Windows; the CUDA index supplies
# 2.7.0+cu128, which satisfies the same pin.
uses_cuda_index=True,
docs_path="docs/engines/confucius4-tts.md",
# ~7 GB venv now, ~5 GB of weights on first synthesis.
required_bytes=14 * _GIB,
dependency_bytes=8 * _GIB,
temporary_free_bytes=8 * _GIB,
disk_confidence="estimated",
invalidate=_confucius4_invalidate,
installed_probe=_confucius4_installed,
),
"dots-tts": SidecarSpec(
engine_id="dots-tts",
display_name="dots.tts",
repo_url="https://github.com/rednote-hilab/dots.tts.git",
tarball_url=(
"https://github.com/rednote-hilab/dots.tts/archive/"
"32407a55228630475c48ecdb2c4e2c0f9c09e030.tar.gz"
),
checkout_dirname="dots.tts",
env_var="OMNIVOICE_DOTS_TTS_DIR",
probe_module="dots_tts.runtime",
source_revision="32407a55228630475c48ecdb2c4e2c0f9c09e030",
source_required_path="constraints/recommended.txt",
# Upstream requires-python is >=3.10,<3.13.
venv_args=("--python", "3.11"),
install_args=("-e", "{checkout}", "-c", "{checkout}/constraints/recommended.txt"),
host_supported=_dots_host,
docs_path="docs/engines/dots-tts.md",
# ~7 GB venv now, ~9 GB checkpoint on first synthesis.
required_bytes=18 * _GIB,
dependency_bytes=8 * _GIB,
temporary_free_bytes=8 * _GIB,
disk_confidence="estimated",
invalidate=_dots_invalidate,
installed_probe=_dots_installed,
),
# PyPI packages rather than repositories: nothing to clone, and the managed
# root holds only the engine's own venv. The pins are the app's own
# optional extras (a test ties the two together), so the engine runs the
# same wheel whichever way it was installed.
"supertonic3": SidecarSpec(
engine_id="supertonic3",
display_name="Supertonic-3",
repo_url="",
tarball_url="",
checkout_dirname="supertonic3",
env_var="OMNIVOICE_SUPERTONIC3_DIR",
probe_module="supertonic",
has_source=False,
venv_args=("--python", "3.11"),
install_args=("supertonic==1.3.1",),
docs_path="docs/engines/supertonic3.md",
# onnxruntime + numpy + huggingface_hub, no torch. The ~400 MB of
# weights download on first synthesis into the shared HF cache.
required_bytes=1 * _GIB,
installed_probe=_in_app_env("supertonic"),
),
"pockettts": SidecarSpec(
engine_id="pockettts",
display_name="PocketTTS",
repo_url="",
tarball_url="",
checkout_dirname="pockettts",
env_var="OMNIVOICE_POCKETTTS_DIR",
probe_module="pocket_tts",
has_source=False,
venv_args=("--python", "3.11"),
install_args=("pocket-tts==2.1.0",),
cpu_torch_index=True,
docs_path="docs/engines/pockettts.md",
# CPU torch + scipy. The gated weights download on first use.
required_bytes=3 * _GIB,
installed_probe=_in_app_env("pocket_tts"),
host_supported=_pockettts_host,
),
}
class HostUnsupported(RuntimeError):
"""The one-click install cannot work on this machine. The message is a
VoiceStudio-owned sentence from the spec, safe to show the user."""
def host_support(spec: SidecarSpec) -> tuple[bool, str]:
"""Whether *spec*'s install can work here. A probe that raises counts as
unsupported: offering a button that fails is worse than not offering it."""
try:
ok, why = spec.host_supported()
except Exception: # noqa: BLE001
return False, (
f"Could not check whether {spec.display_name} can be installed on "
f"this machine. Its guide ({spec.docs_path}) has the manual steps."
)
return bool(ok), (why or "")
def installable_engine_ids() -> frozenset[str]:
"""Engines that get an Install button on THIS host."""
return frozenset(eid for eid, spec in SPECS.items() if host_support(spec)[0])
def _expand(value: str, checkout: Path) -> str:
return value.replace("{checkout_repr}", repr(str(checkout))).replace(
"{checkout}", str(checkout)
)
def get_spec(engine_id: str) -> Optional[SidecarSpec]:
return SPECS.get(engine_id)
@@ -196,6 +459,20 @@ def managed_checkout(spec: SidecarSpec) -> Path:
return managed_root(spec) / spec.checkout_dirname
def engine_venv_python(env_var: str) -> Optional[Path]:
"""The interpreter of the install *env_var* points at, if it has one.
For engines that can live in the app's environment or in a venv of their
own (PocketTTS, Supertonic-3): they prefer their own, and fall back to the
app's interpreter for an install made with ``uv sync --extra``.
"""
env_dir = os.environ.get(env_var)
if not env_dir:
return None
py = _venv_python(Path(env_dir) / ".venv")
return py if py.is_file() else None
def _legacy_managed_checkouts(spec: SidecarSpec) -> tuple[Path, ...]:
"""App-owned predecessor checkouts retained during in-place upgrades."""
if spec.engine_id == "indextts2":
@@ -272,13 +549,20 @@ def _default_uv_cache_root() -> Path:
return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "uv"
def uv_subprocess_env(cache_parent: Path) -> "dict[str, str] | None":
def uv_subprocess_env(cache_parent: Path) -> "dict[str, str]":
"""Environment for ``uv`` subprocesses that install into *cache_parent*'s volume.
Returns ``None`` (inherit the parent environment untouched) when uv's
default cache already shares a volume with *cache_parent* or the user
pinned both variables themselves. Otherwise returns a copy of
``os.environ`` with the *unset* one(s) of ``UV_CACHE_DIR`` /
Always a copy of ``os.environ`` with ``UV_NO_CONFIG=1``: an engine's
install resolves its own requirements, never VoiceStudio's. The backend
runs inside the app's tree, so uv would otherwise discover the app's
``pyproject.toml`` and apply its ``[tool.uv] constraint-dependencies``
(``torch==2.8.0``) to the engine's venv. An engine pinning another torch
(MOSS-TTS-v1.5, Confucius4) could then never resolve, and one that pins
none got the app's torch instead of its own. Mirrors still apply: they
arrive as ``UV_INDEX_URL``, an environment variable, not a config file.
When uv's default cache is on another volume than *cache_parent*, the
copy also places the *unset* one(s) of ``UV_CACHE_DIR`` /
``UV_PYTHON_INSTALL_DIR`` placed inside *cache_parent*, so downloads, the
unpacked wheel cache, managed Pythons, and the venv all stay on the
target volume and same-volume hardlink installs work again. The two
@@ -291,17 +575,15 @@ def uv_subprocess_env(cache_parent: Path) -> "dict[str, str] | None":
pass the directory that should hold the shared ``.uv-cache`` typically
the common parent of the engine venvs on that volume.
"""
if _same_volume(cache_parent, _default_uv_cache_root()):
return None
env = dict(os.environ)
overrode = False
env["UV_NO_CONFIG"] = "1"
if _same_volume(cache_parent, _default_uv_cache_root()):
return env
if not env.get("UV_CACHE_DIR"): # explicit user choice always wins
env["UV_CACHE_DIR"] = str(Path(cache_parent) / ".uv-cache")
overrode = True
if not env.get("UV_PYTHON_INSTALL_DIR"):
env["UV_PYTHON_INSTALL_DIR"] = str(Path(cache_parent) / ".uv-python")
overrode = True
return env if overrode else None
return env
# ── Disk preflight ─────────────────────────────────────────────────────────
@@ -520,9 +802,13 @@ def _healthy(spec: SidecarSpec) -> bool:
return False
if not _venv_python(checkout / ".venv").is_file():
return False
if spec.weights_repo_id and not _weights_present(spec):
return False
return True
if spec.weights_repo_id:
return _weights_present(spec)
# Nothing downloaded after the dependencies proves they finished; only
# the marker the import probe writes does. IndexTTS (weights) predates
# the marker and keeps its own check, so no existing install is asked
# to reinstall.
return (checkout / _INSTALL_COMPLETE_MARKER).is_file()
def _persist(spec: SidecarSpec) -> None:
@@ -548,6 +834,9 @@ def start_install(engine_id: str) -> dict:
spec = get_spec(engine_id)
if spec is None:
raise KeyError(engine_id)
ok, why = host_support(spec)
if not ok:
raise HostUnsupported(why)
with _jobs_lock:
existing = _jobs.get(engine_id)
if existing and existing["state"] == "running":
@@ -679,6 +968,11 @@ def _step_preflight(spec: SidecarSpec, job: dict) -> None:
def _step_fetch_source(spec: SidecarSpec, job: dict) -> None:
step = _job_step(job, "fetch_source")
checkout = managed_checkout(spec)
if not spec.has_source:
checkout.mkdir(parents=True, exist_ok=True)
step["state"] = "done"
step["detail"] = "PyPI package, no source to fetch"
return
if _source_present(spec, checkout):
step["state"] = "done"
step["detail"] = "source already present"
@@ -717,7 +1011,7 @@ def _step_fetch_source(spec: SidecarSpec, job: dict) -> None:
_fetch_tarball(spec, job, checkout)
if not _source_layout_ok(spec, checkout):
raise _StepError(
f"Fetched source at {checkout} has no pyproject.toml — the download "
f"Fetched source at {checkout} has no {spec.source_manifest} — the download "
"appears incomplete or the upstream layout changed.",
"Re-run the install; if it keeps failing, clone the repository "
f"manually and set {spec.env_var} to the clone (see the engine docs).",
@@ -727,10 +1021,14 @@ def _step_fetch_source(spec: SidecarSpec, job: dict) -> None:
_SOURCE_REVISION_MARKER = ".voicestudio_source_revision"
# Written once the import probe passes. For an engine with no weights
# download, the venv interpreter existing proves nothing: a dependency
# install that died halfway leaves one behind.
_INSTALL_COMPLETE_MARKER = ".voicestudio_install_complete"
def _source_layout_ok(spec: SidecarSpec, checkout: Path) -> bool:
if not (checkout / "pyproject.toml").is_file():
if not (checkout / spec.source_manifest).is_file():
return False
return not spec.source_required_path or (checkout / spec.source_required_path).is_file()
@@ -743,6 +1041,8 @@ def _write_source_marker(spec: SidecarSpec, checkout: Path) -> None:
def _source_present(spec: SidecarSpec, checkout: Path) -> bool:
if not spec.has_source:
return checkout.is_dir()
if not _source_layout_ok(spec, checkout):
return False
if not spec.source_revision:
@@ -836,8 +1136,8 @@ def _step_create_venv(spec: SidecarSpec, job: dict) -> None:
# uv_subprocess_env. The cache parent is the shared engines root, so
# every sidecar engine reuses one cache.
uv_env = uv_subprocess_env(Path(DATA_DIR) / "engines")
rc = _run_logged(job, [uv, "venv", str(venv_dir)], timeout=_UV_VENV_TIMEOUT_S,
env=uv_env)
rc = _run_logged(job, [uv, "venv", str(venv_dir), *spec.venv_args],
timeout=_UV_VENV_TIMEOUT_S, env=uv_env)
if rc != 0 or not py.is_file():
raise _StepError(
f"uv venv failed (exit {rc}) at {venv_dir}.",
@@ -857,17 +1157,28 @@ def _step_install_deps(spec: SidecarSpec, job: dict) -> None:
"""
checkout = managed_checkout(spec)
py = _venv_python(checkout / ".venv")
# A reinstall that fails must not leave the previous run's marker.
(checkout / _INSTALL_COMPLETE_MARKER).unlink(missing_ok=True)
uv = _locate_uv()
_log(job, f"Installing {spec.display_name} into its venv (this can take several minutes) …")
target = [_expand(arg, checkout) for arg in spec.install_args]
if spec.cpu_torch_index:
from core.torch_indexes import UV_PIP_CPU_ARGS
target += list(UV_PIP_CPU_ARGS)
elif spec.uses_cuda_index and _host_family() == "cuda":
from core.torch_indexes import UV_PIP_CU128_ARGS
target += list(UV_PIP_CU128_ARGS)
# Always `--python <this engine's venv>`: the install can only ever land in
# the venv this engine owns, never the app's interpreter.
rc = _run_logged(
job,
[uv, "pip", "install", "--python", str(py), "-e", str(checkout)],
[uv, "pip", "install", "--python", str(py), *target],
timeout=_UV_PIP_INSTALL_TIMEOUT_S,
env=uv_subprocess_env(Path(DATA_DIR) / "engines"),
)
if rc != 0:
raise _StepError(
f"uv pip install -e failed (exit {rc}).",
f"uv pip install failed (exit {rc}).",
"Usually a network hiccup — re-run the install to resume. Behind a "
"proxy, set HTTPS_PROXY in Settings → Environment first.",
)
@@ -879,8 +1190,13 @@ def _step_verify(spec: SidecarSpec, job: dict) -> None:
py = _venv_python(checkout / ".venv")
_log(job, f"Verifying `import {spec.probe_module}` inside the venv …")
try:
probe = (
_expand(spec.probe_code, checkout)
if spec.probe_code
else f"import {spec.probe_module}"
)
proc = subprocess.run(
[str(py), "-c", f"import {spec.probe_module}"],
[str(py), "-c", probe],
capture_output=True, timeout=_IMPORT_PROBE_TIMEOUT_S,
)
except (subprocess.TimeoutExpired, OSError) as exc:
@@ -898,6 +1214,7 @@ def _step_verify(spec: SidecarSpec, job: dict) -> None:
"the engine docs.",
)
_job_step(job, "verify")["detail"] = f"import {spec.probe_module} OK"
(checkout / _INSTALL_COMPLETE_MARKER).write_text(f"{spec.probe_module}\n", encoding="utf-8")
_log(job, "Venv verified.")
+63
View File
@@ -17,6 +17,8 @@ from __future__ import annotations
import asyncio
import importlib
import logging
import functools
import re
import os
import shutil
import subprocess
@@ -91,6 +93,8 @@ REGISTRY: dict[str, dict] = {
"probe_module": "openai",
"category": "llm",
"needs_key": True,
# A core dependency: Settings → LLM Providers uses it too.
"builtin": True,
"notes": (
"Uses the LLM provider you configure in Settings → LLM Providers "
"(route it via the 'Dub translation' skill in Settings → LLM Skills): "
@@ -181,6 +185,65 @@ def list_engines() -> list[dict]:
return out
def _normalize(name: str) -> str:
"""A distribution name in PEP 503 form (deep_translator == deep-translator)."""
return re.sub(r"[-_.]+", "-", name).lower()
@functools.lru_cache(maxsize=1)
def _app_dependency_names() -> frozenset[str]:
"""Distribution names VoiceStudio itself requires, normalized.
Read from the installed package metadata, so it follows the lockfile with
no second list to keep in step. Without metadata this guards nothing
rather than failing.
"""
try:
from importlib.metadata import requires
reqs = requires("omnivoice") or []
except Exception: # noqa: BLE001
return frozenset()
names = set()
for req in reqs:
if "extra ==" in req:
continue
names.add(_normalize(re.split(r"[\s;<>=!~\[@(]", req, maxsplit=1)[0]))
return frozenset(names)
def uninstall_blocker(engine_id: str) -> "tuple[int, str] | None":
"""Why removing this engine's package would break something, or None.
`pip uninstall` acts on the app's own environment. A package VoiceStudio
depends on (openai, argostranslate) would break the app, and a package
other translation engines share (deep_translator backs four) would break
those engines too.
"""
entry = REGISTRY.get(engine_id)
pkg = entry.get("pip_package") if entry else None
if not pkg:
return None
if _normalize(pkg) in _app_dependency_names():
return 400, (
f"{entry['display_name']} uses {pkg}, which VoiceStudio itself "
"depends on. Uninstalling it would break the app."
)
sharing = [
other["display_name"]
for other_id, other in REGISTRY.items()
if other_id != engine_id
and other.get("pip_package")
and _normalize(other["pip_package"]) == _normalize(pkg)
]
if sharing:
return 409, (
f"{entry['display_name']} shares {pkg} with {', '.join(sharing)}. "
"Uninstalling it would stop those working too."
)
return None
def get_engine(engine_id: str) -> dict | None:
return REGISTRY.get(engine_id)
+142 -15
View File
@@ -300,6 +300,31 @@ class TTSBackend(ABC):
#: 0 means "no meaningful floor" (CPU-class engines) and never warns.
min_vram_gb: float = 0.0
@classmethod
def runtime_compute_profile(cls, caps) -> dict:
"""Resolved compute metadata for this engine on the current host.
Most engines have one implementation whose static declarations are
sufficient. Native adapters may override this single hook when the
installed executable determines both the available runtimes and the
device actually selected.
"""
from services.engine_routing import resolve_routing
gpu_compat = tuple(getattr(cls, "gpu_compat", ("cpu",)))
min_vram_gb = float(getattr(cls, "min_vram_gb", 0.0) or 0.0)
return {
"gpu_compat": gpu_compat,
"min_vram_gb": min_vram_gb,
**resolve_routing(gpu_compat, caps, min_vram_gb),
"runtime_backend": None,
"runtime_device_index": None,
"runtime_device_name": None,
"runtime_hardware_family": None,
"runtime_vram_gb": None,
"runtime_device_verified": None,
}
#: True when generation allocates in ANOTHER process — a dedicated-venv
#: sidecar (SubprocessBackend) or a spawned binary (omnivoice-gguf).
#: Parent-process accelerator counters cannot see those allocations, so
@@ -2235,6 +2260,13 @@ _LAZY_REGISTRY: dict[str, tuple[str, str]] = {
# 2026-07-02 (CPU, Apple Silicon; 22.05 kHz output). Gated behind
# OMNIVOICE_CONFUCIUS4_TTS_DIR so it's inert until enabled.
"confucius4-tts": ("engines.confucius4", "Confucius4Backend"),
# audio.cpp (0xShug0/audio.cpp) — pure-C++ ggml runtime, no Python venv.
# v1 serves Breeze-TTS-2 (en+zh, clone+design) through a parent-managed
# audiocpp_server over loopback HTTP. Gated behind a server binary
# (OMNIVOICE_AUDIOCPP_BIN) so it's inert until enabled. Lazy for the
# same import-cycle reason as the entries above (engines.audiocpp
# imports services.tts_backend for TTSBackend).
"audiocpp": ("engines.audiocpp", "AudioCPPBackend"),
}
@@ -2339,6 +2371,7 @@ _INSTALL_HINTS: dict[str, str] = {
"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)",
"confucius4-tts":"git clone netease-youdao/Confucius4-TTS + set OMNIVOICE_CONFUCIUS4_TTS_DIR (own Python 3.10 venv; 14-lang cross-lingual zero-shot clone; ~5 GB weights auto-download; CUDA/ROCm/XPU/NPU/CPU, no MPS; Apache-2.0)",
"audiocpp": "download the matching audio.cpp v0.7.2 prebuilt + set OMNIVOICE_AUDIOCPP_BIN, then explicitly install Breeze-TTS-2 in Model Catalogue → Models (native CPU/Vulkan/CUDA/Metal GGUF server, no Python; en+zh clone+design; ~4.73 GiB; weights research/non-commercial only)",
}
@@ -2359,6 +2392,51 @@ _SETUP_SNIPPETS: dict[str, str] = {
}
# Per-engine documentation page, as a repo-relative path (#1866). Every one of
# these docs already exists and several are CI-guarded against the code they
# describe (e.g. tests/test_cosyvoice_install_docs.py), but nothing in the app
# linked to them, so the point of failure — an unavailable engine row — was a
# dead end. Paths rather than URLs so tests/test_engine_docs.py can assert the
# file is really there; the URL is built once, at read time, from core.links.
#
# Keyed on the engine id, so it stays correct when the doc filename does not
# match the id (indextts2 → indextts.md).
_ENGINE_DOCS: dict[str, str] = {
"omnivoice": "docs/engines/omnivoice.md",
"omnivoice-subprocess": "docs/engines/omnivoice-subprocess.md",
"omnivoice-gguf": "docs/engines/omnivoice-gguf.md",
"cosyvoice": "docs/engines/cosyvoice.md",
"kittentts": "docs/engines/kittentts.md",
"mlx-audio": "docs/engines/mlx-audio.md",
"voxcpm2": "docs/engines/voxcpm2.md",
"moss-tts-nano": "docs/engines/moss-tts-nano.md",
"moss-tts-v15": "docs/engines/moss-tts-v15.md",
"dots-tts": "docs/engines/dots-tts.md",
"confucius4-tts": "docs/engines/confucius4-tts.md",
"indextts2": "docs/engines/indextts.md",
"gpt-sovits": "docs/engines/gpt-sovits.md",
"sherpa-onnx": "docs/engines/sherpa-onnx.md",
"supertonic3": "docs/engines/supertonic3.md",
"pockettts": "docs/engines/pockettts.md",
"audiocpp": "docs/engines/audio-cpp.md",
}
def _engine_docs_url(bid: str) -> str | None:
"""Public URL of this engine's doc page, or None when it has none.
VoiceStudio-owned constant either way: the path comes from the registry
above and the base from :mod:`core.links`, so no part of it is derived
from an engine probe. That is what lets it cross the public boundary
intact (see api.public_engine_metadata).
"""
path = _ENGINE_DOCS.get(bid)
if not path:
return None
from core import links
return f"{links.PROJECT_REPO_BLOB_MAIN}/{path}"
# Short, readable labels for mlx-audio's curated models (#981) — surfaced in
# the Model Catalogue → Engines model picker so users see more than a bare key.
# Single-sourced here rather than on MLXAudioBackend.CURATED_MODELS itself so
@@ -2385,14 +2463,24 @@ def _sidecar_installable_ids() -> frozenset[str]:
button into their matrix rows.
"""
try:
from services.sidecar_install import SPECS
return frozenset(SPECS)
# Host-aware: an engine whose installer cannot work on THIS machine
# (dots.tts on Windows, a CUDA-only install on a CPU host) must not get
# an Install button that can only fail.
from services.sidecar_install import installable_engine_ids
return installable_engine_ids()
except Exception: # pragma: no cover — defensive only
return frozenset()
def list_backends() -> list[dict]:
"""Enumerate every registered backend with its availability state.
def list_backends(*, include_hidden: bool = False) -> list[dict]:
"""Enumerate the engine catalogue with each backend's availability state.
On MPS, the canonical ``omnivoice`` id already resolves to the killable
OmniVoice sidecar. The explicit ``omnivoice-subprocess`` compatibility id
is therefore omitted from the normal catalogue so the picker does not
advertise two choices with the same runtime behavior. Internal callers
that must validate or preserve a stored compatibility id can pass
``include_hidden=True``.
Per-entry shape (ENGINE-05 + ENGINE-06):
@@ -2406,10 +2494,11 @@ def list_backends() -> list[dict]:
# e.g. VoxCPM2's >=2.0.3 upgrade hint)
"install_hint": Optional[str],
"setup_snippet": Optional[str], # exact `export VAR=...` for path-gated opt-in engines
"docs_url": Optional[str], # this engine's doc page (registry-authored constant)
"one_click_install": bool, # services.sidecar_install can provision it in-app
"last_error": Optional[str], # cached most-recent failure
"isolation_mode": "in-process" | "subprocess",
"gpu_compat": list[str], # subset of {cuda, rocm, mps, xpu, npu, cpu}
"gpu_compat": list[str], # subset of {cuda, rocm, mps, vulkan, xpu, npu, cpu}
"supports_cloning": Optional[bool], # True/False from the class attr; None when
# model-dependent (property, e.g. mlx-audio)
"effective_device": str, # device this engine uses on THIS host
@@ -2439,12 +2528,17 @@ def list_backends() -> list[dict]:
from core.device_caps import detect_host_caps
from services.engine_disk_usage import disk_summary_for
from services.engine_evidence import snapshot as execution_snapshot
from services.engine_routing import routing_fields
caps = detect_host_caps()
installable = _sidecar_installable_ids()
out: list[dict] = []
for bid, cls in _REGISTRY.items():
if (
not include_hidden
and caps.family == "mps"
and bid == "omnivoice-subprocess"
):
continue
cls = _effective_backend_class(bid, cls, caps.family)
try:
ok, msg = cls.is_available()
@@ -2465,14 +2559,40 @@ def list_backends() -> list[dict]:
isolation = "subprocess"
else:
isolation = "in-process"
gpu_compat = getattr(cls, "gpu_compat", ("cpu",))
from services.engine_routing import resolve_routing, runtime_compute_profile
try:
profile = runtime_compute_profile(cls, caps)
except Exception:
# Runtime-aware native probes remain optional metadata. A broken
# provider probe must not take down the engine picker, especially
# when availability already explains a missing binary or model.
compat = tuple(getattr(cls, "gpu_compat", ("cpu",)))
floor = float(getattr(cls, "min_vram_gb", 0.0) or 0.0)
profile = {
"gpu_compat": compat,
"min_vram_gb": floor,
**resolve_routing(compat, caps, floor),
"runtime_backend": None,
"runtime_device_index": None,
"runtime_device_name": None,
"runtime_hardware_family": None,
"runtime_vram_gb": None,
"runtime_device_verified": None,
}
gpu_compat = profile["gpu_compat"]
# Cloning capability: same descriptor guard as
# cloning_capable_engine_ids() — a class-level getattr on a *property*
# (mlx-audio: capability depends on the picked model) returns the
# descriptor, not a bool, so report None (= model-dependent) there
# instead of an always-truthy false positive.
_clone = getattr(cls, "supports_cloning", True)
routing = routing_fields(gpu_compat, caps, getattr(cls, "min_vram_gb", 0.0))
from core.scrub import scrub_text
routing = {
"effective_device": profile["effective_device"],
"routing_status": profile["routing_status"],
"routing_reason": scrub_text(profile["routing_reason"])
if profile["routing_reason"] else None,
}
loaded_instance = None
if _active_instance_id == bid:
loaded_instance = _active_instance
@@ -2493,6 +2613,10 @@ def list_backends() -> list[dict]:
"install_hint": _INSTALL_HINTS.get(bid),
# Exact `export VAR=...` line for path-gated opt-in engines, or None.
"setup_snippet": _SETUP_SNIPPETS.get(bid),
# This engine's doc page (#1866). Registry-authored constant, so it
# survives api.public_engine_metadata and gives an unavailable row
# somewhere to send the user.
"docs_url": _engine_docs_url(bid),
# True when services.sidecar_install can provision this engine
# in-app (Settings renders an Install button instead of leading
# with the manual setup snippet).
@@ -2502,7 +2626,7 @@ def list_backends() -> list[dict]:
"isolation_mode": isolation,
"gpu_compat": list(gpu_compat),
# effective_device / routing_status / routing_reason (scrubbed):
"min_vram_gb": getattr(cls, "min_vram_gb", 0.0) or None,
"min_vram_gb": profile["min_vram_gb"] or None,
# effective_device / routing_status / routing_reason (scrubbed);
# the reason now also carries the under-provisioned-GPU caveat.
**routing,
@@ -2510,7 +2634,7 @@ def list_backends() -> list[dict]:
engine_id=bid,
engine_cls=cls,
instance=loaded_instance,
routing=routing,
routing={**profile, **routing},
caps=caps,
),
})
@@ -2592,7 +2716,9 @@ def active_routing() -> dict | None:
"""
try:
active = active_backend_id()
for b in list_backends():
# The MPS picker intentionally hides the redundant compatibility id,
# but routing must still describe a saved or environment-pinned id.
for b in list_backends(include_hidden=True):
if b.get("id") == active:
return {
"engine": active,
@@ -2932,10 +3058,9 @@ async def resolve_generation_backend(
raise ValueError(f"TTS engine '{engine_id}' is not available: {_mask_hf_tokens(msg)}")
from core.device_caps import detect_host_caps
from services.engine_routing import resolve_routing
routing = resolve_routing(
getattr(backend_cls, "gpu_compat", ("cpu",)), detect_host_caps(),
getattr(backend_cls, "min_vram_gb", 0.0),
from services.engine_routing import runtime_compute_profile_async
routing = await runtime_compute_profile_async(
backend_cls, detect_host_caps()
)
if routing["routing_status"] == "unavailable":
raise ValueError(routing["routing_reason"])
@@ -2973,4 +3098,6 @@ def __getattr__(name: str): # pragma: no cover - exercised via tests
return _REGISTRY[name if name in _REGISTRY else None]
if name == "IndexTTS2Backend":
return _REGISTRY["indextts2"]
if name == "AudioCPPBackend":
return _REGISTRY["audiocpp"]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -120,6 +120,7 @@ def test_drain_fd_is_explicitly_inherited_by_wrapper_but_not_operation(monkeypat
proc.wait(timeout=5)
@pytest.mark.skipif(os.name != "posix", reason="Unix drain pipe contract")
def test_invalid_or_missing_desktop_drain_fd_fails_safe(monkeypatch):
monkeypatch.setenv("OMNIVOICE_DESKTOP_CONTAINED", "1")
monkeypatch.setenv("OMNIVOICE_DESKTOP_DRAIN_FD", "not-an-fd")
@@ -15,6 +15,15 @@ import pytest
from core import contained_subprocess as owned
# This module simulates macOS by deleting os.waitid, then drives the fallback
# with os.waitpid/os.WNOHANG and start_new_session — POSIX-only APIs that
# Windows does not have at all (os.WNOHANG raises AttributeError before the
# first assertion). CI runs this suite on Linux, so nothing is lost by
# skipping; what is gained is a Windows contributor whose checkout runs green.
pytestmark = pytest.mark.skipif(
os.name != "posix", reason="simulates a POSIX platform without os.waitid"
)
def _make_owned(argv):
cr, cw = os.pipe()
+101 -1
View File
@@ -160,6 +160,96 @@ def test_engine_catalogue_reports_effective_mps_isolation(monkeypatch):
assert row["isolation_mode"] == "subprocess"
def test_mps_catalogue_hides_redundant_explicit_omnivoice_sidecar(monkeypatch):
"""The picker advertises the canonical id, while legacy callers retain both."""
from core.device_caps import HostCaps
from services import tts_backend
monkeypatch.setattr(
tts_backend,
"_REGISTRY",
{
"omnivoice": OmniVoiceBackend,
"omnivoice-subprocess": OmniVoiceSubprocessBackend,
},
)
monkeypatch.setattr(
"core.device_caps.detect_host_caps",
lambda: HostCaps(family="mps", available_families=("mps", "cpu")),
)
monkeypatch.setattr(
OmniVoiceSubprocessBackend,
"is_available",
classmethod(lambda cls: (True, "ready")),
)
picker_ids = {item["id"] for item in list_backends()}
assert picker_ids == {"omnivoice"}
assert get_backend_class("omnivoice") is OmniVoiceMPSSubprocessBackend
all_ids = {item["id"] for item in list_backends(include_hidden=True)}
assert all_ids == {"omnivoice", "omnivoice-subprocess"}
assert get_backend_class("omnivoice-subprocess") is OmniVoiceSubprocessBackend
def test_mps_active_routing_preserves_hidden_compatibility_id(monkeypatch):
from core.device_caps import HostCaps
from services import tts_backend
monkeypatch.setattr(
tts_backend,
"_REGISTRY",
{"omnivoice-subprocess": OmniVoiceSubprocessBackend},
)
monkeypatch.setattr(tts_backend, "active_backend_id", lambda: "omnivoice-subprocess")
monkeypatch.setattr(
"core.device_caps.detect_host_caps",
lambda: HostCaps(family="mps", available_families=("mps", "cpu")),
)
monkeypatch.setattr(
OmniVoiceSubprocessBackend,
"is_available",
classmethod(lambda cls: (True, "ready")),
)
assert tts_backend.active_routing() == {
"engine": "omnivoice-subprocess",
"available": True,
"effective_device": "mps",
"routing_status": "accelerated",
"routing_reason": None,
}
@pytest.mark.parametrize("family", ("cuda", "cpu"))
def test_non_mps_catalogue_keeps_explicit_omnivoice_sidecar(monkeypatch, family):
from core.device_caps import HostCaps
from services import tts_backend
monkeypatch.setattr(
tts_backend,
"_REGISTRY",
{
"omnivoice": OmniVoiceBackend,
"omnivoice-subprocess": OmniVoiceSubprocessBackend,
},
)
monkeypatch.setattr(
"core.device_caps.detect_host_caps",
lambda: HostCaps(family=family, available_families=(family, "cpu")),
)
monkeypatch.setattr(
OmniVoiceSubprocessBackend,
"is_available",
classmethod(lambda cls: (True, "ready")),
)
assert {item["id"] for item in list_backends()} == {
"omnivoice",
"omnivoice-subprocess",
}
def test_mps_startup_does_not_preload_native_model(monkeypatch):
from core.device_caps import HostCaps
from services import model_manager
@@ -382,7 +472,17 @@ def test_mps_proxy_survives_fatal_child_exit_and_recovers(stub_sidecar, monkeypa
try:
with pytest.raises(RuntimeError, match="backend is still running"):
b.generate("CRASH")
assert b._proc is not None and b._proc.poll() is not None
assert b._proc is not None
# The child called os._exit; the parent raised the moment its pipe hit
# EOF, which is BEFORE the OS has reaped the process. Asserting poll()
# on the next line is a race the test happened to win on Linux and lost
# every time on Windows. Wait for the death instead of assuming it has
# already been observed — the claim is that the child is gone, not that
# it is gone within one instruction.
deadline = time.monotonic() + 5
while b._proc.poll() is None and time.monotonic() < deadline:
time.sleep(0.02)
assert b._proc.poll() is not None, "the crashed sidecar never died"
assert b.generate("ok").shape[1] == 24000
finally:
b.shutdown()
+87
View File
@@ -113,6 +113,93 @@ def test_unclean_shutdown_yields_crash_record(sentinel_env, monkeypatch):
assert acked is False, "a fresh crash record must be unacknowledged"
def test_lifespan_clears_sentinel_even_if_later_shutdown_raises(monkeypatch, tmp_path):
"""THE #1895 regression: before this fix, ``clear_sentinel()`` was the
LAST statement of ``main.py``'s lifespan shutdown, behind ~50s of bounded
waits plus model unload / ``free_vram()`` / ``gc.collect()`` / httpx
close. The desktop shell's quit path grants only a 2s grace before
SIGKILL (``frontend/src-tauri/src/bootstrap.rs``
``terminate_process_tree``), and Windows grants no graceful phase at all
(``tools.rs``) nowhere near enough, so a deliberate, clean quit
routinely got killed before reaching that last line, leaving the
sentinel behind for the NEXT startup to misreport as "did not shut down
cleanly... likely crashed".
Simulates that class of interruption without an actual SIGKILL: a later
shutdown step (``model_loads_begin_shutdown()``, called unguarded well
after the sentinel clear) raises, so nothing past it in the shutdown
body ever runs for this purpose, the same effect as being killed
mid-teardown.
Fail-before/pass-after: with ``clear_sentinel()`` moved to the TOP of
the shutdown block (immediately after ``yield``), the sentinel is
already gone by the time this raise happens, so the next startup must
not fabricate a crash record.
"""
import asyncio
from fastapi import FastAPI
# Fresh `main`/`core`/`api`/`services` import, mirroring
# tests/test_model_load_shutdown.py's `_reimported_backend_modules`: a
# sibling suite may have purged these names from sys.modules, leaving a
# collection-time alias stale. Purging and re-importing here makes this
# test self-consistent in isolation, not dependent on suite order.
purge_names = ("main", "core", "api", "services")
purge_prefixes = ("core.", "api.", "services.")
saved = {
name: mod for name, mod in sys.modules.items()
if name in purge_names or name.startswith(purge_prefixes)
}
def _purge():
for name in [
n for n in sys.modules
if n in purge_names or n.startswith(purge_prefixes)
]:
sys.modules.pop(name, None)
_purge()
try:
import main as main_mod
from core import run_sentinel as fresh_run_sentinel
monkeypatch.setattr(
fresh_run_sentinel, "SENTINEL_PATH", str(tmp_path / "run_sentinel.json")
)
monkeypatch.setattr(
fresh_run_sentinel, "CRASH_RECORD_PATH", str(tmp_path / "last_run_crash.json")
)
monkeypatch.setattr(
fresh_run_sentinel, "LOG_PATH", str(tmp_path / "omnivoice.log")
)
fresh_run_sentinel._reset_for_tests()
def _boom():
raise RuntimeError("simulated kill: interrupted after the early clear")
monkeypatch.setattr(main_mod, "model_loads_begin_shutdown", _boom)
async def scenario():
app = FastAPI()
async with main_mod.lifespan(app):
pass
with pytest.raises(RuntimeError, match="simulated kill"):
asyncio.run(scenario())
assert not os.path.exists(fresh_run_sentinel.SENTINEL_PATH), (
"sentinel must already be cleared even though a later shutdown "
"step raised before ever reaching the old clear-sentinel line"
)
assert fresh_run_sentinel.detect_unclean_shutdown() is None, (
"a deliberate quit interrupted after the early clear must never "
"be reported as a crash on the next startup"
)
finally:
_purge()
sys.modules.update(saved)
def test_live_pid_means_second_instance_not_a_crash(sentinel_env):
"""A sentinel owned by a LIVE process is a concurrent second instance
sharing DATA_DIR never a crash, and we must not take over or delete
+31 -4
View File
@@ -23,6 +23,8 @@ from __future__ import annotations
import logging
from typing import Optional
from worker.capacity import derive_concurrency
logger = logging.getLogger("omnivoice.worker")
# gpu_compat families that mean "this would run on the CPU here", which is
@@ -74,6 +76,12 @@ def discover(*, include_unavailable: bool = False) -> list[dict]:
gpu_compat = set(entry.get("gpu_compat") or [])
repo_ids = repo_ids_for(entry)
downloaded = _downloaded(repo_ids)
runtime_vram_gb = (entry.get("execution_evidence") or {}).get(
"runtime_vram_gb"
)
engine_free_bytes = free_bytes if runtime_vram_gb is None else int(
float(runtime_vram_gb or 0.0) * 1024**3
)
discovered.append(
{
"engine": engine_id,
@@ -97,7 +105,17 @@ def discover(*, include_unavailable: bool = False) -> list[dict]:
"min_memory_bytes": int(float(entry.get("min_vram_gb") or 0) * 1024**3),
"precision": "",
"backend": entry.get("effective_device") or family,
"free_memory_bytes": free_bytes,
"free_memory_bytes": engine_free_bytes,
# A native provider that works independently of torch may not
# expose memory telemetry. Unknown capacity still gets one
# serial slot; zero must not turn a working Vulkan engine into
# an unschedulable capability.
"derived_concurrency": 1
if (
runtime_vram_gb is not None
and float(runtime_vram_gb or 0.0) <= 0
and routing == "accelerated"
) else 0,
# Capability is not acceleration: an engine present but routed
# to the CPU here should not be preferred for GPU work.
"cpu_fallback": routing in ("cpu_fallback", "cpu_only")
@@ -291,9 +309,18 @@ def max_concurrent_tasks(capabilities: Optional[list[dict]] = None) -> int:
caps = capabilities if capabilities is not None else discover()
if not caps:
return 1
derived = [int(c.get("derived_concurrency") or 0) for c in caps]
positive = [d for d in derived if d > 0]
return min(positive) if positive else 1
derived: list[int] = []
for cap in caps:
concurrency = int(cap.get("derived_concurrency") or 0)
if concurrency <= 0:
concurrency = derive_concurrency(
backend=str(cap.get("backend") or ""),
free_memory_bytes=int(cap.get("free_memory_bytes") or 0),
min_model_bytes=int(cap.get("min_memory_bytes") or 0),
compiled=bool(cap.get("compiled")),
)
derived.append(concurrency)
return min(derived)
__all__ = [
+4 -6
View File
@@ -97,18 +97,16 @@ def derive_concurrency(
) -> int:
"""How many jobs of this model may run at once on this worker.
Returns 0 when the model cannot run here at all a capability mismatch,
which the scheduler must treat as "send it elsewhere", never as a worker
fault.
Under-provisioned accelerators remain usable with one serial slot and the
longer CPU-class deadline. Zero memory means telemetry is unknown, not
that the engine cannot run.
"""
family = (backend or "").strip().lower()
if min_model_bytes and free_memory_bytes < min_model_bytes:
return 0
if compiled:
# Thread-affinity pinning (#315). One job, always.
return 1
if family in _ALWAYS_SERIAL:
return 1 if (not min_model_bytes or free_memory_bytes >= min_model_bytes) else 0
return 1
budget = max(min_model_bytes, _VRAM_PER_JOB_BYTES)
if budget <= 0:
return 1
+3 -1
View File
@@ -143,7 +143,9 @@ def _base_execution_seconds(
control plane, and its hardware is not the worker's.
"""
target_device = str(execution_device or "cpu").lower()
if target_device not in {"cpu", "cuda", "mps", "mlx", "directml", "rocm", "xpu"}:
if target_device not in {
"cpu", "cuda", "mps", "mlx", "directml", "rocm", "vulkan", "xpu",
}:
target_device = "cpu"
if under_provisioned and target_device != "cpu":
return max(
+4 -2
View File
@@ -34,7 +34,7 @@ _HEARTBEAT_MISS_SECONDS = 90.0
# enough that one slow answer cannot move it.
_LATENCY_WINDOW = 5
_KNOWN_EXECUTION_DEVICES = frozenset(
{"cpu", "cuda", "mps", "mlx", "directml", "rocm", "xpu"}
{"cpu", "cuda", "mps", "mlx", "directml", "rocm", "vulkan", "xpu"}
)
@@ -150,7 +150,9 @@ class ConnectedWorker:
cap = self._capability_for(engine, model_id, operation)
if cap is None or cap.get("cpu_fallback"):
return False
if str(cap.get("backend") or "").lower() not in ("cuda", "rocm"):
if str(cap.get("backend") or "").lower() not in (
"cuda", "rocm", "vulkan",
):
return False
floor = int(cap.get("min_memory_bytes") or 0)
have = int(cap.get("free_memory_bytes") or 0)
File diff suppressed because one or more lines are too long
@@ -93,7 +93,7 @@ class HostInfo(_message.Message):
def __init__(self, hostname: _Optional[str] = ..., os: _Optional[str] = ..., arch: _Optional[str] = ..., worker_version: _Optional[str] = ..., cpu_count: _Optional[int] = ..., system_memory_bytes: _Optional[int] = ..., gpus: _Optional[_Iterable[_Union[GpuInfo, _Mapping]]] = ...) -> None: ...
class ModelCapability(_message.Message):
__slots__ = ("engine", "model_id", "operations", "supported", "installed", "downloaded", "resident", "min_memory_bytes", "precision", "derived_concurrency", "cpu_fallback", "repo_ids", "display_name")
__slots__ = ("engine", "model_id", "operations", "supported", "installed", "downloaded", "resident", "min_memory_bytes", "precision", "derived_concurrency", "cpu_fallback", "repo_ids", "display_name", "backend", "free_memory_bytes")
ENGINE_FIELD_NUMBER: _ClassVar[int]
MODEL_ID_FIELD_NUMBER: _ClassVar[int]
OPERATIONS_FIELD_NUMBER: _ClassVar[int]
@@ -107,6 +107,8 @@ class ModelCapability(_message.Message):
CPU_FALLBACK_FIELD_NUMBER: _ClassVar[int]
REPO_IDS_FIELD_NUMBER: _ClassVar[int]
DISPLAY_NAME_FIELD_NUMBER: _ClassVar[int]
BACKEND_FIELD_NUMBER: _ClassVar[int]
FREE_MEMORY_BYTES_FIELD_NUMBER: _ClassVar[int]
engine: str
model_id: str
operations: _containers.RepeatedScalarFieldContainer[str]
@@ -120,7 +122,9 @@ class ModelCapability(_message.Message):
cpu_fallback: bool
repo_ids: _containers.RepeatedScalarFieldContainer[str]
display_name: str
def __init__(self, engine: _Optional[str] = ..., model_id: _Optional[str] = ..., operations: _Optional[_Iterable[str]] = ..., supported: _Optional[bool] = ..., installed: _Optional[bool] = ..., downloaded: _Optional[bool] = ..., resident: _Optional[bool] = ..., min_memory_bytes: _Optional[int] = ..., precision: _Optional[str] = ..., derived_concurrency: _Optional[int] = ..., cpu_fallback: _Optional[bool] = ..., repo_ids: _Optional[_Iterable[str]] = ..., display_name: _Optional[str] = ...) -> None: ...
backend: str
free_memory_bytes: int
def __init__(self, engine: _Optional[str] = ..., model_id: _Optional[str] = ..., operations: _Optional[_Iterable[str]] = ..., supported: _Optional[bool] = ..., installed: _Optional[bool] = ..., downloaded: _Optional[bool] = ..., resident: _Optional[bool] = ..., min_memory_bytes: _Optional[int] = ..., precision: _Optional[str] = ..., derived_concurrency: _Optional[int] = ..., cpu_fallback: _Optional[bool] = ..., repo_ids: _Optional[_Iterable[str]] = ..., display_name: _Optional[str] = ..., backend: _Optional[str] = ..., free_memory_bytes: _Optional[int] = ...) -> None: ...
class RegisterRequest(_message.Message):
__slots__ = ("envelope", "protocol_version_min", "protocol_version_max", "enrollment_token", "worker_id", "public_key", "challenge_signature", "challenge", "host", "capabilities", "max_concurrent_tasks", "in_flight", "completed_unacked", "key_id", "nonce", "labels", "features")
+5
View File
@@ -168,6 +168,11 @@ message ModelCapability {
// Human-readable UI label. Never use this as a scheduling or residency key;
// unlike model_id it may change with ordinary copy edits.
string display_name = 13;
// Per-engine runtime routing. Native engines can select a provider that is
// independent of the worker's global torch device.
string backend = 14;
// Memory measured for that exact selected provider/device; zero is unknown.
uint64 free_memory_bytes = 15;
}
message RegisterRequest {
+31 -4
View File
@@ -134,6 +134,32 @@ INPUT_PARAM_KEYS: tuple[str, ...] = (
# task records what was staged for it. The record is what makes the purge
# exact: an input is deletable only when no surviving task still refers to it.
INPUTS_DIRNAME = "inputs"
def artifact_id_for(name: str) -> str:
"""The id a staged input is known by, everywhere.
This is a PROTOCOL identifier, not a local path: it is persisted in
``params_json``, handed to remote workers over gRPC, and matched against
what a later sweep finds on disk. ``os.path.join`` made it OS-specific, so
a Windows control plane stored and shipped ``inputs\\<sha>.wav`` which a
Linux worker cannot resolve, and which stops matching the moment the same
data directory is opened on another OS. Always ``/``; ``resolve_within``
already treats both separators as structural, so resolution is unaffected.
"""
return f"{INPUTS_DIRNAME}/{name}"
def normalize_artifact_id(artifact_id: str) -> str:
"""Compare ids written by any host on equal terms.
Rows staged by a Windows control plane before this was canonicalised carry
a backslash. The sweeper decides whether a file on disk is still
referenced by comparing ids, so without this an upgraded install would
read every legacy row as unreferenced and delete inputs that surviving
tasks still point at.
"""
return (artifact_id or "").replace("\\", "/")
INPUTS_PARAM_KEY = "inputs"
_HASH_CHUNK_BYTES = 1024 * 1024
@@ -292,7 +318,7 @@ def stage_input(
f"Could not read the task input {source!r}: {exc}"
) from exc
artifact_id = os.path.join(INPUTS_DIRNAME, f"{digest}{_extension(source)}")
artifact_id = artifact_id_for(f"{digest}{_extension(source)}")
try:
destination = resolve_within(base, artifact_id)
except UnsafePath as exc: # pragma: no cover — the id is ours, hex only
@@ -473,7 +499,7 @@ def _referenced_artifacts(conn) -> set[str]:
continue
for entry in entries:
if isinstance(entry, dict) and entry.get("artifact_id"):
referenced.add(str(entry["artifact_id"]))
referenced.add(normalize_artifact_id(str(entry["artifact_id"])))
return referenced
@@ -560,8 +586,7 @@ def purge_artifacts(
except OSError:
return removed
for name in names:
artifact_id = os.path.join(INPUTS_DIRNAME, name)
if artifact_id in referenced:
if artifact_id_for(name) in referenced:
continue
path = os.path.join(inputs_dir, name)
try:
@@ -893,6 +918,8 @@ def purge_finished(
__all__ = [
"INPUTS_DIRNAME",
"artifact_id_for",
"normalize_artifact_id",
"INPUTS_PARAM_KEY",
"INPUT_PARAM_KEYS",
"InputStagingError",
+20 -1
View File
@@ -229,10 +229,27 @@ def capability_to_pb(cap: dict) -> pb.ModelCapability:
cpu_fallback=bool(cap.get("cpu_fallback")),
repo_ids=list(cap.get("repo_ids") or []),
display_name=str(cap.get("display_name") or ""),
backend=str(cap.get("backend") or ""),
free_memory_bytes=int(cap.get("free_memory_bytes") or 0),
)
def capability_from_pb(message: pb.ModelCapability) -> dict:
def capability_from_pb(
message: pb.ModelCapability, *, fallback_backend: str = ""
) -> dict:
"""Decode a capability, including protocol-v2 peers from before backend.
``backend`` was added to the existing protocol-v2 message, so an older
peer legitimately sends its protobuf default (the empty string). The
host-level GPU backend is the only compatible execution-device signal in
that payload. A capability explicitly marked as a CPU fallback must stay
on CPU even when its host also has a GPU.
"""
backend = str(message.backend or "").strip().lower()
if message.cpu_fallback:
backend = "cpu"
elif not backend:
backend = str(fallback_backend or "").strip().lower()
return {
"engine": message.engine,
"model_id": message.model_id,
@@ -249,6 +266,8 @@ def capability_from_pb(message: pb.ModelCapability) -> dict:
"cpu_fallback": message.cpu_fallback,
"repo_ids": list(message.repo_ids),
"display_name": message.display_name,
"backend": backend,
"free_memory_bytes": message.free_memory_bytes,
}
+12 -3
View File
@@ -851,10 +851,12 @@ class WorkerServicer(pb_grpc.WorkerServiceServicer):
epoch: int,
) -> pb.RegisterResponse:
session = identity.issue_session(worker_id=worker.id, key_id=worker.key_id, epoch=epoch)
capabilities = [codec.capability_from_pb(c) for c in request.capabilities]
host = codec.host_from_pb(request.host)
backend = host["gpus"][0].get("backend", "") if host.get("gpus") else ""
capabilities = [
codec.capability_from_pb(c, fallback_backend=backend)
for c in request.capabilities
]
claimed_refs = {
ref.attempt_id: codec.task_ref(
ref.task_id, ref.attempt_id, ref.session_epoch
@@ -2044,7 +2046,14 @@ class WorkerServicer(pb_grpc.WorkerServiceServicer):
session.worker_id,
)
return
caps = [codec.capability_from_pb(c) for c in update.capabilities]
worker = self.pool.get(session.worker_id)
fallback_backend = (
worker.capacity.backend if worker is not None else ""
)
caps = [
codec.capability_from_pb(c, fallback_backend=fallback_backend)
for c in update.capabilities
]
self._queue_capability_update(session, caps)
return
+158 -142
View File
@@ -5,9 +5,9 @@
"": {
"name": "omnivoice-studio-monorepo",
"devDependencies": {
"concurrently": "^9.2.4",
"concurrently": "^10",
"playwright": "^1.63.0",
"taze": "^19.17.2",
"taze": "^21",
"turbo": "^2.10.12",
"typescript": "^6.0.3",
"wait-on": "^9.1.0",
@@ -30,29 +30,29 @@
"@radix-ui/react-toggle": "^1.1.18",
"@radix-ui/react-toggle-group": "^1.1.19",
"@radix-ui/react-tooltip": "^1.2.16",
"@scalar/api-reference-react": "^0.9.63",
"@scalar/api-reference-react": "^0.9.67",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-query": "^5.102.8",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.9",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-opener": "^2.5.4",
"@tanstack/react-virtual": "^3.14.11",
"@tauri-apps/plugin-dialog": "^2.7.3",
"@tauri-apps/plugin-opener": "^2.5.5",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-updater": "^2.11.0",
"@tauri-apps/plugin-window-state": "^2.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"country-flag-icons": "^1.6.20",
"i18next": "^26.3.6",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.31.0",
"posthog-js": "^1.417.0",
"lucide-react": "^1.43.0",
"posthog-js": "^1.428.11",
"qrcode": "^1.5.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-hot-toast": "^2.6.0",
"react-i18next": "^17.0.11",
"react-window": "^2.3.0",
"react-i18next": "^17.0.13",
"react-window": "^2.3.1",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
@@ -60,25 +60,25 @@
"zustand": "^5.0.15",
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@playwright/test": "1.63.0",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/cli": "^2.11.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
"eslint": "^10.8.1",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"eslint": "^10.10.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.11.0",
"jsdom": "^29.1.1",
"knip": "^6.32.2",
"globals": "^17.12.0",
"jsdom": "^30.0.1",
"knip": "^6.35.1",
"oxfmt": "^0.57.0",
"oxlint": "1.71.0",
"playwright-core": "1.62.1",
"playwright-core": "1.63.0",
"typescript": "^6.0.3",
"vite": "^8.2.1",
"vitest": "4.1.9",
"vite": "^8.2.2",
"vitest": "4.1.11",
},
},
},
@@ -95,13 +95,9 @@
"@antfu/ni": ["@antfu/ni@30.5.0", "", { "dependencies": { "fzf": "^0.5.2", "package-manager-detector": "^1.8.0", "tinyexec": "^1.3.0", "tinyglobby": "^0.2.17" }, "bin": { "na": "bin/na.mjs", "nd": "bin/nd.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-VwQoM9qF1dzDrye55b1qIBeLr4zQ1a5wZQMPCe496HTiquViBZqxtNBaq98WX3ze5kC9Yl7gKSU4W2vtLztxYw=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@6.0.7", "", { "dependencies": { "@csstools/css-calc": "^3.3.0", "@csstools/css-color-parser": "^4.1.10", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.5.2" } }, "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw=="],
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="],
"@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="],
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@8.3.2", "", { "dependencies": { "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.2" } }, "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q=="],
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
@@ -139,6 +135,10 @@
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
"@cacheable/memory": ["@cacheable/memory@2.2.0", "", { "dependencies": { "@cacheable/utils": "^2.5.0", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" } }, "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ=="],
"@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="],
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="],
"@codemirror/commands": ["@codemirror/commands@6.11.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA=="],
@@ -255,6 +255,10 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@keyv/bigmap": ["@keyv/bigmap@1.3.1", "", { "dependencies": { "hashery": "^1.4.0", "hookified": "^1.15.0" }, "peerDependencies": { "keyv": "^5.6.0" } }, "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ=="],
"@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="],
"@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="],
"@lezer/css": ["@lezer/css@1.3.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g=="],
@@ -279,45 +283,45 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.143.0", "", { "os": "android", "cpu": "arm" }, "sha512-n9uozULWflPqBtdmI8lAabLqGKNgLVNN0ZH8HfgCwpKGNtzRzauB76jTiW/3YLkcA7N1zskpi9GdVnZuu1SAvg=="],
"@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.148.0", "", { "os": "android", "cpu": "arm" }, "sha512-pHASv9g5pASxb7akHERZNSkrEqPhFaUix98o7d9hbTpolnnFWl7UiRrcMhCsV1+iVO4/cJwKsbKRJTFNs2tdBQ=="],
"@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.143.0", "", { "os": "android", "cpu": "arm64" }, "sha512-9BbdjHETk6O3zH/DDid9IgBtF0GlpLabNKN231uraXpRDSfY+iiZxTP5bk1Z63GBownVdhdINFIeddmMz4MzpQ=="],
"@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.148.0", "", { "os": "android", "cpu": "arm64" }, "sha512-sg/6Ez0KdAygsu0POELux9wN1Po2CP93WY8eNl4DBKIGprsd4QSHBXOb471Pu9i2OCD5sLkISSb2agZEhVn2Zw=="],
"@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.143.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gh+6ecoHUy4/sUcolBl/1qPXKBbYNxFY0Pk0ujgQvINTMSftJY7o4yb8gOkDJPeZeB8+a+u7xTe6umoP8N5HFA=="],
"@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.148.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yiSJmzGUvCUaJT8X3j40gVcX+ckuHQMuiOtF8DvzTs5+JtB/7XuHFPp4M+vv5u+HlBtDUd4Ks5pyHpWz8mfnkg=="],
"@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.143.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-qd1hl2d+lXgHv/VQ/M9qm8TrMC5T4RqDBwtOnl+1D0QMjwcz+8AaB4JSg8STgeag0GP6a6L74XEGAsrTSJWNzQ=="],
"@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.148.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-6ZeklaamrMy4H2JmhvcJg6iip59tYILtuLaILxyAHT3l5FDxnI5ihVievAft5ZmAbqtlWHErOi1OpJK8gy1wcA=="],
"@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.143.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-M5XXcNa7aOqLPKTR41msfghKu2yQ4xWvCm11/gwU0JzOzHNk5sgW//rVEjJ+LO48+VDAMzXTSzurUVxIDKwozw=="],
"@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.148.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vFsPx+a/qFECPnz/H8nC6x6MDvnWscLTCo/5muojEF54ERUq1kdgbvnWo95YnkhjF9sTIcG/uDxQBh1gffaufQ=="],
"@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.143.0", "", { "os": "linux", "cpu": "arm" }, "sha512-T/GXusuOkPNQhCQCSBbcU/N8j0rAypuDBl1IyFK+lyYT594XsVz80clPC/OtbSSpBGyJxj8uYEfctxVuxVYoww=="],
"@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.148.0", "", { "os": "linux", "cpu": "arm" }, "sha512-eOr3M+6iGbbxNL4PSS0VtsyQ2eOUxSBh00BqO22SbolDimPSYsBuLr/LCrZBkiqW2BoabhR6V4R8jrRAay7hjg=="],
"@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.143.0", "", { "os": "linux", "cpu": "arm" }, "sha512-oKu4RcBlXSqo3OC62dp6YTnQaZIurNDpCX3BnAM3+bJxt7s8J2TJKMnC0UYer1qhlRaDCg6wkTaTw+2IlsZ12w=="],
"@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.148.0", "", { "os": "linux", "cpu": "arm" }, "sha512-58ZKDw0mQRbCNfrd2IDyV4o8T7enzGERJn41BH2tjrZVGyiKiFzcfDicuB7Zcpb/1xIOrObovr8Dja6lZi8dLw=="],
"@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.143.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WJBbD186AZmMGaSIhlktC+rPl8L3peCTXAh88Ih9uEvK0en2mPojGyCGYiL6mHtV1RPV3JyfJW5t6n5hh0lXhA=="],
"@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.148.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Fnu95O4eZ5i++GPvIzBEZ8y4ddTLR+D9paYa8JRaRk6ZK7nHQiWP5xtrhcPQsXqgat1d7sU/d5rbbI0p1FTHSQ=="],
"@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.143.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-t1AcYOwEzgceadT4v5e+vaCCb0AncCA3v5AyzfBAz/tMq11qzVccXKzNHtkWdjBsgvTKwRkaUF3QvT4kot8vcQ=="],
"@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.148.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-3CQy/BMdx7N7H3qrcPxUL+a2CwUZodUcf6oq8iJuNZ9C6Ol1aq3mcWzsgySJ7CHFLvpX21ZDPp1r1X0QLbu/AQ=="],
"@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.143.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RsnO/NoD8376LMJq8JS8TwI0ieNaFRTuNe2GVJntQg6gwZNMENZsEbknHdVwjpOmxdGLGodcwaGSbAeRr5Bgjw=="],
"@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.148.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9LkaYvfiF8hMOw900csAvkf1oxE8XlmMeGowu5BcastSSwV8mKvKRMNU7HsV+ycyj1dQD8pX5qgOw8ja6SJacg=="],
"@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.143.0", "", { "os": "linux", "cpu": "none" }, "sha512-48fSVfR9TZi5CASZFyv0VC6z6BCoeihFsX031mAD/oSH7d9PYsPgIqza7d9mjP7Z2KTEpTFyH6SIu0Ui6R1vdg=="],
"@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.148.0", "", { "os": "linux", "cpu": "none" }, "sha512-2GBiM9h26dR4WJfhoMvnFMnFLf7m/kYs4UMqjvrOfQG4BV1nuTJDH22Zc2MQr3INZF7nSKYQ6xlhD3hQ7A6gug=="],
"@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.143.0", "", { "os": "linux", "cpu": "none" }, "sha512-T8CpdD+SfE01DnIOD4HpVxu0ZJOfMJ/VhCvikKfaXAxkZ+9veyLM/D2hpi7Y2hFUyPmVQO3FNZHmYzV/WlVR4g=="],
"@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.148.0", "", { "os": "linux", "cpu": "none" }, "sha512-uPqZexvKJmEgq4mAu36qe2xTfXZE7oyik1R7KtZ5tl8qKlq1U1fIqTFRUEBZqRGvforoTrGIpatRzcoPKO66RA=="],
"@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.143.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-QLdeMsCcacenPEFsfxnBUDF1y6opyz5+fmOz9bfD5Y7fiGCMupUCuB3KTPQhNwshIG1P9fPqar9MHxuBDd4bwQ=="],
"@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.148.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-9oUHvnTbp7ZraFsTC8PN6XhdhPSSxZumYvixWl7Smi353gEULvK6yV0sXNVrdFMHQeaDKFCi8TgDhNK7/A+Y+Q=="],
"@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.143.0", "", { "os": "linux", "cpu": "x64" }, "sha512-659ujfqLy6k7cuH3sbzhd8b+ztSq+i6E2E9pG78Q0BmHjAExfGIdgc8cGgMdwAozDXeZFHkJ+LXYJdWsaGdgyw=="],
"@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.148.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2qhDSJwKzbSZzF7lDqqk8sr/yXsmwr3PeUa4/nazIF+zFAYz1gVPEfC34GQtGxzJUUmklaYAL63368LEfrMeyw=="],
"@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.143.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/Mw/9j4TfZcnKphPrzOE6t4MMknXadcAAuVUlDRTF/ETWB5xOgQvOJV2Mh9We/bWxZdoxaGAdc+hy4GuYwQ2yQ=="],
"@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.148.0", "", { "os": "linux", "cpu": "x64" }, "sha512-qQoPDZUFV0bh9xA09XydmkjMBpgc1ukJuhMvzQ9QeVmFaHTS9W5TE5CoLmSl3QQyUP9OuHO3x/WPZTIIZPWR3Q=="],
"@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.143.0", "", { "os": "none", "cpu": "arm64" }, "sha512-8rIKWR2BFuifbIK/1XB9wTaSdtuJ25dlE7ZQYDnEwj/2xH2vHsxnvIjHT3ZjSVuLLwGGlSslIG/fbOJ8TV8rTw=="],
"@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.148.0", "", { "os": "none", "cpu": "arm64" }, "sha512-1UGbaQWEXUCLqAmaR5kwRDjx/R4S5LQKZkM9CHmaHkuKhriOF32aRLfS0jCRNE2yGQJLMEA1z9UucbBVqjXnDw=="],
"@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.143.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-5U9kQYMfRRI6Zq7KDxgbIP0RMnKrfn3gLepRMgJuRkPSUALTiRCk9d/uyhb4lGDjUdzwK7mBkKqhLgzBPCmLpQ=="],
"@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.148.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-pWKdzRDNG2+NK4h/V6U/CYERcfYD6u28h5IB/VJVsrZaD3muvE58tUj22lieL5vLZ+XFi1GPv9YXckZbJZ9BLA=="],
"@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.143.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-25P7AaHk4R88Yv2XH4gToDVmh0cOu+bEURQU10CRrmvgabfRArSGAP5osmwUKeSUHj0VS50upbpbRWWW/m7mHA=="],
"@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.148.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-i3p4x+mvwtjcE1J5HM6V7ggsbXiznExN/4MkNyOy3dfXrVV3bnkSfmZxvo6/84qCVX4ShkpNE1SKt9biIF31GQ=="],
"@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.143.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ORMh3JE1s6V7ySicdRK7vgaDQnn5o+UHg9ct989PlWHbel8O9ARrmWXM6kZjrBMtNucxNayQ8g69G0VfWzhANw=="],
"@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.148.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Ye6vB7VQulghWYkYkECOBYFRVEizz4XyRTUAv+t8BuyurhKU7uD0P9eowL+mKG5Mf8MSYx+DI3Cm8SKZvYG7bQ=="],
"@oxc-project/types": ["@oxc-project/types@0.143.0", "", {}, "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA=="],
"@oxc-project/types": ["@oxc-project/types@0.148.0", "", {}, "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A=="],
"@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA=="],
@@ -435,13 +439,13 @@
"@phosphor-icons/core": ["@phosphor-icons/core@2.1.1", "", {}, "sha512-v4ARvrip4qBCImOE5rmPUylOEK4iiED9ZyKjcvzuezqMaiRASCHKcRIuvvxL/twvLpkfnEODCOJp5dM4eZilxQ=="],
"@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="],
"@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="],
"@posthog/browser-common": ["@posthog/browser-common@0.5.2", "", { "dependencies": { "@posthog/core": "^1.48.11", "@posthog/types": "^1.405.3" } }, "sha512-8GvfEshFdeKIccuy3kpp6mDBxawQtRamMYRCwzy1r1ixLKQVLAGs3afhOf6r75yp59ZQBi5mtXQgUJ2Jz8eHuw=="],
"@posthog/browser-common": ["@posthog/browser-common@0.8.2", "", { "dependencies": { "@posthog/core": "^1.51.0", "@posthog/types": "^1.409.1" } }, "sha512-8g7+ijrx8bfWmpDjmP07e0ZmdRnJoFqZ6PCcMQvfBTUrr422GRXvQWpQn7yD028zIJHIIn/rUTipKgUC5UkWpw=="],
"@posthog/core": ["@posthog/core@1.50.5", "", { "dependencies": { "@posthog/types": "^1.409.0" } }, "sha512-afEchuShDaVIoxAIj76kDZQ1DhfesDmgfVp+mtTzsA3wlc8DF5uoz8YjuTjnxOicWkpP5HCDqYidK/1kT125Cg=="],
"@posthog/core": ["@posthog/core@1.52.0", "", { "dependencies": { "@posthog/types": "^1.409.4" } }, "sha512-pvbdeRRpizktmo8MXZQxjAI4aFfNajwkzxbm72wKV9n2wWcnQWa4m+AqTI2zmmCSsYk7F666rRUdvYqZLjjhWg=="],
"@posthog/types": ["@posthog/types@1.409.0", "", {}, "sha512-239umoaZVb2GBaXeEyJpwFvjhrrChJH8NHCwiao23EBSu3NA6EN0MTMoaHSmMEf4yjiXEFFoYJA6FjJAXF5HGA=="],
"@posthog/types": ["@posthog/types@1.409.4", "", {}, "sha512-X8egIUjMe1uItlxpbLfWypS2SWLOF+5Vzvne/44AsaHa/fYbtSy8itJNk5iQS1MZEwVYAEYUsQY1vuN61jpxTg=="],
"@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
@@ -553,55 +557,55 @@
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@scalar/agent-chat": ["@scalar/agent-chat@0.12.28", "", { "dependencies": { "@ai-sdk/vue": "3.0.33", "@scalar/api-client": "3.16.3", "@scalar/components": "0.28.1", "@scalar/helpers": "0.11.1", "@scalar/icons": "0.7.6", "@scalar/json-magic": "0.13.2", "@scalar/openapi-types": "0.9.5", "@scalar/schemas": "0.8.3", "@scalar/themes": "0.17.3", "@scalar/types": "0.18.2", "@scalar/use-toasts": "0.10.5", "@scalar/validation": "0.6.3", "@scalar/workspace-store": "0.58.1", "@vueuse/core": "13.9.0", "ai": "6.0.33", "js-base64": "^3.9.2", "neverpanic": "0.0.8", "truncate-json": "3.0.1", "vue": "^3.5.40" } }, "sha512-N1ZEIKHrOhbB6mSUuaeMbNEK8G0lhXu8aHj8RqPMjKAkm0IbZVGCEkFgRCzaqDMcdsXKqQKygzx2a5pV5gX6Ow=="],
"@scalar/agent-chat": ["@scalar/agent-chat@0.12.30", "", { "dependencies": { "@ai-sdk/vue": "3.0.33", "@scalar/api-client": "3.18.0", "@scalar/components": "0.29.1", "@scalar/helpers": "0.11.3", "@scalar/icons": "0.7.6", "@scalar/json-magic": "0.13.4", "@scalar/openapi-types": "0.9.5", "@scalar/schemas": "0.9.0", "@scalar/themes": "0.17.4", "@scalar/types": "0.19.0", "@scalar/use-toasts": "0.10.5", "@scalar/validation": "0.6.3", "@scalar/workspace-store": "0.60.0", "@vueuse/core": "13.9.0", "ai": "6.0.33", "js-base64": "^3.9.2", "neverpanic": "0.0.8", "truncate-json": "3.0.1", "vue": "^3.5.40" } }, "sha512-1+09CTY/eVLg6ygdrrKyNkhWXnbHcVGrr4Jq4HYr7XogrkIsTCuHCBcpPbYRWOFJ2zRpToPFu5M3R7eLE810zA=="],
"@scalar/api-client": ["@scalar/api-client@3.16.3", "", { "dependencies": { "@headlessui/tailwindcss": "^0.2.2", "@headlessui/vue": "1.7.23", "@scalar/blocks": "0.1.14", "@scalar/components": "0.28.1", "@scalar/helpers": "0.11.1", "@scalar/icons": "0.7.6", "@scalar/oas-utils": "0.19.14", "@scalar/openapi-types": "0.9.5", "@scalar/sidebar": "0.10.1", "@scalar/snippetz": "0.9.28", "@scalar/themes": "0.17.3", "@scalar/typebox": "^0.1.3", "@scalar/types": "0.18.2", "@scalar/use-codemirror": "0.14.15", "@scalar/use-hooks": "0.4.10", "@scalar/use-toasts": "0.10.5", "@scalar/workspace-store": "0.58.1", "@vueuse/core": "13.9.0", "@vueuse/integrations": "13.9.0", "focus-trap": "^7.8.0", "fuse.js": "^7.5.0", "js-base64": "^3.9.2", "jsonc-parser": "3.3.1", "nanoid": "^5.1.6", "pretty-ms": "^9.3.0", "radix-vue": "^1.9.17", "set-cookie-parser": "3.1.0", "vue": "^3.5.40", "yaml": "^2.9.0", "zod": "^4.3.5" } }, "sha512-4F0aZtdnCWZN5EvJQTAlXPzVetniu9pQqPeukck/AUNtvsk59CdNHjd8DjAB3+SS3wv3gfNj0C+obhzjUbCNZg=="],
"@scalar/api-client": ["@scalar/api-client@3.18.0", "", { "dependencies": { "@headlessui/tailwindcss": "^0.2.2", "@headlessui/vue": "1.7.23", "@scalar/blocks": "0.1.16", "@scalar/components": "0.29.1", "@scalar/helpers": "0.11.3", "@scalar/icons": "0.7.6", "@scalar/oas-utils": "0.19.16", "@scalar/openapi-types": "0.9.5", "@scalar/sidebar": "0.11.1", "@scalar/snippetz": "0.9.30", "@scalar/themes": "0.17.4", "@scalar/typebox": "^0.1.3", "@scalar/types": "0.19.0", "@scalar/use-codemirror": "0.14.15", "@scalar/use-hooks": "0.4.11", "@scalar/use-toasts": "0.10.5", "@scalar/workspace-store": "0.60.0", "@vueuse/core": "13.9.0", "@vueuse/integrations": "13.9.0", "focus-trap": "^7.8.0", "fuse.js": "^7.5.0", "js-base64": "^3.9.2", "jsonc-parser": "3.3.1", "nanoid": "^5.1.6", "pretty-ms": "^9.3.0", "radix-vue": "^1.9.17", "set-cookie-parser": "3.1.0", "vue": "^3.5.40", "yaml": "^2.9.0", "zod": "^4.4.3" } }, "sha512-FlseC6xWfx0ganh4s0IAOelJ5kF+aJ07BJfjUriwKDdK8Y4oAFkRlneKQNoT/S+4mrNCXdQ/T5VPEjYFlx4Zxw=="],
"@scalar/api-reference": ["@scalar/api-reference@1.66.1", "", { "dependencies": { "@headlessui/vue": "1.7.23", "@scalar/agent-chat": "0.12.28", "@scalar/api-client": "3.16.3", "@scalar/blocks": "0.1.14", "@scalar/code-highlight": "0.4.5", "@scalar/components": "0.28.1", "@scalar/helpers": "0.11.1", "@scalar/icons": "0.7.6", "@scalar/oas-utils": "0.19.14", "@scalar/schemas": "0.8.3", "@scalar/sidebar": "0.10.1", "@scalar/snippetz": "0.9.28", "@scalar/themes": "0.17.3", "@scalar/types": "0.18.2", "@scalar/use-hooks": "0.4.10", "@scalar/use-toasts": "0.10.5", "@scalar/validation": "0.6.3", "@scalar/workspace-store": "0.58.1", "@unhead/vue": "^2.1.4", "@vueuse/core": "13.9.0", "fuse.js": "^7.5.0", "microdiff": "^1.5.0", "nanoid": "^5.1.6", "vue": "^3.5.40", "yaml": "^2.9.0" } }, "sha512-+iHSJX8HPUyDGinNiLRV8qgFb+fbNwAjGDWzl8Wg/VNEcbmA40t1ZuL/WoWNqphI2QaPfjiCLtmsIhanWAed3w=="],
"@scalar/api-reference": ["@scalar/api-reference@1.68.0", "", { "dependencies": { "@headlessui/vue": "1.7.23", "@scalar/agent-chat": "0.12.30", "@scalar/api-client": "3.18.0", "@scalar/blocks": "0.1.16", "@scalar/code-highlight": "0.4.5", "@scalar/components": "0.29.1", "@scalar/helpers": "0.11.3", "@scalar/icons": "0.7.6", "@scalar/oas-utils": "0.19.16", "@scalar/schemas": "0.9.0", "@scalar/sidebar": "0.11.1", "@scalar/snippetz": "0.9.30", "@scalar/themes": "0.17.4", "@scalar/types": "0.19.0", "@scalar/use-hooks": "0.4.11", "@scalar/use-toasts": "0.10.5", "@scalar/validation": "0.6.3", "@scalar/workspace-store": "0.60.0", "@unhead/vue": "^2.1.4", "@vueuse/core": "13.9.0", "fuse.js": "^7.5.0", "microdiff": "^1.5.0", "nanoid": "^5.1.6", "vue": "^3.5.40", "yaml": "^2.9.0" } }, "sha512-rY43w3REwCxp+rDDx/0CncZxmlzISnGTK9zZ8moq0Ij2vRHhLQCJ0/BXut9pBAupVrOZF7MoqKXcG+gISgTu5g=="],
"@scalar/api-reference-react": ["@scalar/api-reference-react@0.9.65", "", { "dependencies": { "@scalar/api-reference": "1.66.1", "@scalar/types": "0.18.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-0kBuLtyZcvLcMIhuLGr3WHalytf5eqGPvT5soMSP0tdHZkIoTkzeOHCQNiDCiuq7Jct3a2IqsRrDrokukf9N2A=="],
"@scalar/api-reference-react": ["@scalar/api-reference-react@0.9.67", "", { "dependencies": { "@scalar/api-reference": "1.68.0", "@scalar/types": "0.19.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-0L+/XvPwbNbwhMaSJEpGamDcqUaYyqO7LyhQdjOSA/VnsykrljPN+G4mr23Ocr9aZFl5FhzWv88XCdy9WTsHHA=="],
"@scalar/asyncapi-upgrader": ["@scalar/asyncapi-upgrader@0.1.7", "", { "dependencies": { "@scalar/helpers": "0.11.1" } }, "sha512-RU3CrNV77hWiZ9Ik0GJHDf/bEg8C0iF9Rg5b6GDJz3F9Y7ZyBJcgqq++do9GUzPTKzftfOORfQ9180s09XQp7Q=="],
"@scalar/asyncapi-upgrader": ["@scalar/asyncapi-upgrader@0.1.9", "", { "dependencies": { "@scalar/helpers": "0.11.3" } }, "sha512-+kK4dp1J8GvOcTU2OCMFwj62VMP3Y0lppjQ2mdfYsczhTTP9saf12vkRzuHQ+ILkLJJXo98km5zj7pEVMdOSPA=="],
"@scalar/blocks": ["@scalar/blocks@0.1.14", "", { "dependencies": { "@scalar/components": "0.28.1", "@scalar/helpers": "0.11.1", "@scalar/icons": "0.7.6", "@scalar/snippetz": "0.9.28", "@scalar/themes": "0.17.3", "@scalar/types": "0.18.2", "@scalar/workspace-store": "0.58.1", "@types/har-format": "^1.2.16", "js-base64": "^3.9.2", "vue": "^3.5.40" } }, "sha512-4goVCRnz8QWCzQIuMV58GUSoj+WJNZBSGS5L6n3kc8TAKY6qiTOf+Unc5oy2DgwHCJJ6AO531hnGDAsMx7sNaQ=="],
"@scalar/blocks": ["@scalar/blocks@0.1.16", "", { "dependencies": { "@scalar/components": "0.29.1", "@scalar/helpers": "0.11.3", "@scalar/icons": "0.7.6", "@scalar/snippetz": "0.9.30", "@scalar/themes": "0.17.4", "@scalar/types": "0.19.0", "@scalar/workspace-store": "0.60.0", "@types/har-format": "^1.2.16", "js-base64": "^3.9.2", "vue": "^3.5.40" } }, "sha512-k72Dxwj2Bh9jhJkIyTlrpja6QRnvnT5Cb+LH++BGMV70lSu22cyzJBmhtKORTfQVb671s2I7WLUBU6zirxkhsA=="],
"@scalar/code-highlight": ["@scalar/code-highlight@0.4.5", "", { "dependencies": { "hast-util-to-text": "^4.0.2", "highlight.js": "^11.11.1", "lowlight": "^3.3.0", "rehype-external-links": "^3.0.0", "rehype-format": "^5.0.1", "rehype-parse": "^9.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-util-visit": "^5.1.0" } }, "sha512-OCZvBYwodCR1cWemO3MXMwXumI18kFOy8k2nwP+Ic8zFLK1ObyXECRoM+UH+GTw+O4TJsrjNIdLjsnXPO1MvyA=="],
"@scalar/components": ["@scalar/components@0.28.1", "", { "dependencies": { "@floating-ui/utils": "0.2.10", "@floating-ui/vue": "1.1.9", "@headlessui/tailwindcss": "^0.2.2", "@headlessui/vue": "1.7.23", "@scalar/code-highlight": "0.4.5", "@scalar/helpers": "0.11.1", "@scalar/icons": "0.7.6", "@scalar/themes": "0.17.3", "@scalar/use-hooks": "0.4.10", "@vueuse/core": "13.9.0", "cva": "1.0.0-beta.4", "radix-vue": "^1.9.17", "vue": "^3.5.40", "vue-component-type-helpers": "^3.2.6" } }, "sha512-mYI2WwvVM6a9E/o6vrft9FKoLP7Chcit5kOc+Iz+MV6xQ63Cjx2e/dACagEXE9O5i3b4+8+8C00iy4p1TS6P0A=="],
"@scalar/components": ["@scalar/components@0.29.1", "", { "dependencies": { "@floating-ui/utils": "0.2.10", "@floating-ui/vue": "1.1.9", "@headlessui/tailwindcss": "^0.2.2", "@headlessui/vue": "1.7.23", "@scalar/code-highlight": "0.4.5", "@scalar/helpers": "0.11.3", "@scalar/icons": "0.7.6", "@scalar/themes": "0.17.4", "@scalar/use-hooks": "0.4.11", "@vueuse/core": "13.9.0", "cva": "1.0.0-beta.4", "radix-vue": "^1.9.17", "vue": "^3.5.40", "vue-component-type-helpers": "^3.2.6" } }, "sha512-mxlJ/3Pv1YqqaXSBU2SLAiTZSGdam3ZyoYW7CAITSoVlruDBIEfSm6a8eABpM9mWSsoNQhmscLEq2QSC0szUPA=="],
"@scalar/helpers": ["@scalar/helpers@0.11.1", "", {}, "sha512-Knwbe0IYqFk0PPDoOKLasqglBHfyf9/zwWWqFsSNi/AtdjM29wSZXN6p8DFid6iB5B9epYH9YiSgJ6tpD00TEw=="],
"@scalar/helpers": ["@scalar/helpers@0.11.3", "", {}, "sha512-4zPzuNTXObDUtZS93xzAoK83ddHDgBGifv/vKFe6bHqEl5i+IMLTTtEHOaaOZK4dusPEXcXsU5TBSaY/I6Mi+A=="],
"@scalar/icons": ["@scalar/icons@0.7.6", "", { "dependencies": { "@phosphor-icons/core": "^2.1.1", "@types/node": "^24.1.0", "chalk": "^5.6.2", "vue": "^3.5.40" } }, "sha512-UIQ7K/xNDo7Mgez/Z+ArG7JZnS8QRZNOl+s9frdS1T8uEJYuTqT1lnS7R0ECwIw3y6dn6EIfjaZKPihjYC7+qw=="],
"@scalar/json-magic": ["@scalar/json-magic@0.13.2", "", { "dependencies": { "@scalar/helpers": "0.11.1", "pathe": "^2.0.3", "yaml": "^2.9.0" } }, "sha512-T8rQw5u7+MSTDpUcd5ShX1taOUxpZMv2b/P6xsahdlv/u68VX/Bq/+uzuAf2xW8IIOy7BEP4MBggle/vMDgAXw=="],
"@scalar/json-magic": ["@scalar/json-magic@0.13.4", "", { "dependencies": { "@scalar/helpers": "0.11.3", "pathe": "^2.0.3", "yaml": "^2.9.0" } }, "sha512-pOZdlzkgLB+/4OlIlzMToV/cr4vsvWy/MtbtJoRcNHIzDnT8sfNtv/cH8MR2pNDg/29sPpTV1HaE4SZ8b9jUOQ=="],
"@scalar/oas-utils": ["@scalar/oas-utils@0.19.14", "", { "dependencies": { "@scalar/helpers": "0.11.1", "@scalar/themes": "0.17.3", "@scalar/types": "0.18.2", "@scalar/workspace-store": "0.58.1", "flatted": "^3.4.0", "vue": "^3.5.40", "yaml": "^2.9.0" } }, "sha512-rnVIOK6+oHTc4SeXQBkEj+Kb2xB/VUJxckKGNdHnHHlsLjpN4VXhwUBldYAvV9dA/AENfeMj5ys6GubP0RyNwQ=="],
"@scalar/oas-utils": ["@scalar/oas-utils@0.19.16", "", { "dependencies": { "@scalar/helpers": "0.11.3", "@scalar/themes": "0.17.4", "@scalar/types": "0.19.0", "@scalar/workspace-store": "0.60.0", "flatted": "^3.4.0", "vue": "^3.5.40", "yaml": "^2.9.0" } }, "sha512-0u0/vd62lEektF9u6d7ywAYwamkrG1xTfxMf5gOkRGTVZJ7jV+J9LoSfUv+NCR3mmQpeGyKSaiD/3/Psa4OwRA=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.9.5", "", {}, "sha512-czrz/zkVm1oPzrpYo3hI/iymfiw1s4dgJiQtwNi2U77Sqf3EQOGKsif4VNRa5suWMYRuPaFx5EiFM1FNEV4Whg=="],
"@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.2.15", "", { "dependencies": { "@scalar/openapi-types": "0.9.5" } }, "sha512-yqROK9U96ElasEL4Wl/+PIjQZlqrQXVUUpXk9PA6xBnrp8KdEv7at8pR5gsZkvddJfYVwLILPlctyqUg4u4YZA=="],
"@scalar/schemas": ["@scalar/schemas@0.8.3", "", { "dependencies": { "@scalar/helpers": "0.11.1", "@scalar/validation": "0.6.3" } }, "sha512-cTjgiJxXFXMqXlFZafXOyTOKm1lUfbDTbe9La+dPcQPe6q0zXAiVtys0IKILQWNrjXPidY0eaB9Va/rd16bfxw=="],
"@scalar/schemas": ["@scalar/schemas@0.9.0", "", { "dependencies": { "@scalar/helpers": "0.11.3", "@scalar/validation": "0.6.3" } }, "sha512-yYRlIWzw+7HuIX4z7rk7tg4y1nERwvvWKbolZOm7LveSTrppllGKyjtnIqS5uXsmJddERxuurSgDW224gGJlFQ=="],
"@scalar/sidebar": ["@scalar/sidebar@0.10.1", "", { "dependencies": { "@scalar/components": "0.28.1", "@scalar/helpers": "0.11.1", "@scalar/icons": "0.7.6", "@scalar/themes": "0.17.3", "@scalar/use-hooks": "0.4.10", "@scalar/workspace-store": "0.58.1", "vue": "^3.5.40" } }, "sha512-RbDJD22tAMGquDh7ItUeVujSvQxLoc7Eo95gPhHaokEYJprBs/B30Es3J1qhNZQ6IVZ0kcxCjuD4haI1h/UmrQ=="],
"@scalar/sidebar": ["@scalar/sidebar@0.11.1", "", { "dependencies": { "@scalar/components": "0.29.1", "@scalar/helpers": "0.11.3", "@scalar/icons": "0.7.6", "@scalar/themes": "0.17.4", "@scalar/use-hooks": "0.4.11", "@scalar/workspace-store": "0.60.0", "vue": "^3.5.40" } }, "sha512-xXgB0WYWG4WJTFL93WkvoAVdD1H4k+A2n2jgwa/PrUM6RO8TTh+7BLcekpwvPLvXd5muWxzWDTcR413saocphw=="],
"@scalar/snippetz": ["@scalar/snippetz@0.9.28", "", { "dependencies": { "@scalar/helpers": "0.11.1", "@scalar/types": "0.18.2", "js-base64": "^3.9.2", "stringify-object": "^6.0.0" } }, "sha512-xpzQ5NgJDfV5Y5Xmpo2lDZclbXuIRolIm6qAaShNVBvO3q2GYtxJ9RSsrMFTpuk1NIqcQ4WKVAVRllRhYuzlWg=="],
"@scalar/snippetz": ["@scalar/snippetz@0.9.30", "", { "dependencies": { "@scalar/helpers": "0.11.3", "@scalar/types": "0.19.0", "js-base64": "^3.9.2", "stringify-object": "^6.0.0" } }, "sha512-mDluVSGZet1Go8NgJK9s9Z8zNKqePG7zNn8PMCphAzwXNomvMy6j8WRuLDln+Dz33jILfiKlUtv4cnLkmzB+7g=="],
"@scalar/themes": ["@scalar/themes@0.17.3", "", { "dependencies": { "nanoid": "^5.1.6" } }, "sha512-QJPHeGCg0hF30IGPjX2nMcMOvBYyd62d5vey1mIC17CLhOe5tLVTn9D6G175D+jPVb0WhVPcSbaax6KxSZTUEQ=="],
"@scalar/themes": ["@scalar/themes@0.17.4", "", { "dependencies": { "nanoid": "^5.1.6" } }, "sha512-tSCtGLb0noijR8GzgH6H/tlbSuZoLupe66ta6I9FzZxdvgj4SdPM+2Q7E8k1uwPRfrE5NvMyp61s1BsA3DTFsg=="],
"@scalar/typebox": ["@scalar/typebox@0.1.3", "", {}, "sha512-lU055AUccECZMIfGA0z/C1StYmboAYIPJLDFBzOO81yXBi35Pxdq+I4fWX6iUZ8qcoHneiLGk9jAUM1rA93iEg=="],
"@scalar/types": ["@scalar/types@0.18.2", "", { "dependencies": { "@scalar/helpers": "0.11.1", "nanoid": "^5.1.6", "type-fest": "^5.8.0", "zod": "^4.3.5" } }, "sha512-q7fGMn0IygdLbYk9W4quM0w1caHDfL9FIxsYXNsgIep6uuO0T/t4BOStyf0qyzQ3pjt0B7rWFxO//EM9I7F/Tw=="],
"@scalar/types": ["@scalar/types@0.19.0", "", { "dependencies": { "@scalar/helpers": "0.11.3", "nanoid": "^5.1.6", "type-fest": "^5.8.0", "zod": "^4.4.3" } }, "sha512-EKeoWgUlP+uepbM/zEHKbsdpBNyOmSrw6DdU/WC0p53gz5uRzI7d/IEecIP9SQMUkResPR9DmWeWfFQftG7vqg=="],
"@scalar/use-codemirror": ["@scalar/use-codemirror@0.14.15", "", { "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.8", "@codemirror/lang-json": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.10.7", "@codemirror/lint": "^6.8.4", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", "@lezer/common": "^1.2.3", "@lezer/highlight": "^1.2.1", "@replit/codemirror-css-color-picker": "^6.3.0", "vue": "^3.5.40" } }, "sha512-Uvjx0qvOsWPs+I32FL+3v//+VKwtp8ROXgbIAMauNypuw52VeF7R4RM7YoN2Xg1EAEiMtM8ZhWVzCoYTFr6AKQ=="],
"@scalar/use-hooks": ["@scalar/use-hooks@0.4.10", "", { "dependencies": { "@scalar/use-toasts": "0.10.5", "@scalar/validation": "0.6.3", "@vueuse/core": "13.9.0", "cva": "1.0.0-beta.4", "tailwind-merge": "3.5.0", "vue": "^3.5.40" } }, "sha512-YDIohEujqRmPCLpRlE+NTzjKPjeMJZtI4oJcZ5uH2vA1gR8wiaEStK8djBsldOFIn+LmhHagl+HHn6NX054f7Q=="],
"@scalar/use-hooks": ["@scalar/use-hooks@0.4.11", "", { "dependencies": { "@scalar/helpers": "0.11.3", "@scalar/use-toasts": "0.10.5", "@scalar/validation": "0.6.3", "@vueuse/core": "13.9.0", "cva": "1.0.0-beta.4", "tailwind-merge": "3.5.0", "vue": "^3.5.40" } }, "sha512-wCUn9WWKv4abiFZOcjBp8nIcyZ30t7QNPgHbcYu5MXgLgPclYwKb/A+TZiyp63TORWJOK8sp1RzrNGw2CT+m/Q=="],
"@scalar/use-toasts": ["@scalar/use-toasts@0.10.5", "", { "dependencies": { "vue": "^3.5.40", "vue-sonner": "^1.3.2" } }, "sha512-5a8qTVd9eXvMDeD0ifqHvnzg0i2v15clhyFToXDHYmInQD51oBRwByCFqFO+m9ymxSLOHZ7/FiEcU6F90/QTlg=="],
"@scalar/validation": ["@scalar/validation@0.6.3", "", {}, "sha512-j3s9XPv8Wo1EG5/naBi4XGF0uXYQ2A3QZNI0NKWgCMxRHVrDJGlZEYymcHDHY7fquRm73P8U3c8xqJqB5yad6w=="],
"@scalar/workspace-store": ["@scalar/workspace-store@0.58.1", "", { "dependencies": { "@scalar/asyncapi-upgrader": "0.1.7", "@scalar/helpers": "0.11.1", "@scalar/json-magic": "0.13.2", "@scalar/openapi-upgrader": "0.2.15", "@scalar/schemas": "0.8.3", "@scalar/snippetz": "0.9.28", "@scalar/typebox": "0.1.3", "@scalar/types": "0.18.2", "@scalar/validation": "0.6.3", "js-base64": "^3.9.2", "type-fest": "^5.8.0", "vue": "^3.5.40", "yaml": "^2.9.0" } }, "sha512-aKBwM7Tp+VzdxqVC971c8uxM8w9cw+RS7JY5V/VHj29HyNwKQTSJX6+qf8gma46bgUK0W15Ra/T8rN1mAR+EIw=="],
"@scalar/workspace-store": ["@scalar/workspace-store@0.60.0", "", { "dependencies": { "@scalar/asyncapi-upgrader": "0.1.9", "@scalar/helpers": "0.11.3", "@scalar/json-magic": "0.13.4", "@scalar/openapi-upgrader": "0.2.15", "@scalar/schemas": "0.9.0", "@scalar/snippetz": "0.9.30", "@scalar/typebox": "0.1.3", "@scalar/types": "0.19.0", "@scalar/validation": "0.6.3", "js-base64": "^3.9.2", "type-fest": "^5.8.0", "vue": "^3.5.40", "yaml": "^2.9.0" } }, "sha512-O3Zp6Olq7+L2Yp6xd7Z9sIQ4VG5SwR2oHkiGTagZBSrqEuvuce5Z93q3NVPux6j3cID/geW0vC0sPJ5LxNTnBA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
@@ -637,17 +641,17 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.101.4", "", {}, "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.102.8", "", {}, "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.101.4", "", { "dependencies": { "@tanstack/query-core": "5.101.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA=="],
"@tanstack/react-query": ["@tanstack/react-query@5.102.8", "", { "dependencies": { "@tanstack/query-core": "5.102.8" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A=="],
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.10", "", { "dependencies": { "@tanstack/virtual-core": "3.17.8" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-SRyoUbdFMRHuYXMijV5H4ZarQWpXkj3iANq8OFre+pybeVap8ZJjZ3Nz9bVjx4d8PfobVUQUdKyyyHYk3E+djw=="],
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.11", "", { "dependencies": { "@tanstack/virtual-core": "3.17.9" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-SStWf8bYdTgAquYqG4Pi2+d1XimQKoCIJ94H3jsm0D6UA0yqffvWuvUzrrQ4idewFWq8BSPpahnmLVosJIAs6g=="],
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.8", "", {}, "sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.9", "", {}, "sha512-M8Bzy7CCMvUjRiuoHVH9mRjyUVczRs8v8RcUEphLFGc445ZP4KQ15Y1sLqhQqJi/HgGJrxfCHnEnIX0x9mVrBg=="],
"@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.36", "", { "dependencies": { "@tanstack/virtual-core": "3.17.8" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-gKpExv4RbB9luVG+SucTXoqPZv/gzu/Yvz6BNO+8kpNxJ2x+I/ulryzl5W9BRciahZGp5Tls3Dp5XP1ztVGbMw=="],
@@ -677,21 +681,21 @@
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="],
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.2", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg=="],
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.3", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-CRgE+7TP4tvq9MjBU6f04NLTFIqVMLKHk3hAqlhil00ngK9ACTrXPH3oHpKMProxILodd3YjBoKbMwSI4IEcfA=="],
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.4", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="],
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.5", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-xvzGai5aQds8j8R8RsUK/lW6pGG50YgOYIPLzvkqmkwAj7dfySOD7sGtejRzvVdMmv1EQfKVEFh1MvmDp8QR0g=="],
"@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="],
"@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="],
"@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.11.0", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-AE36XkOoSna24G40jZMY15nzAnkXEPL/73tGoseGrtGOHuI/cZwWzHpZFLjKXDPgzYZ435z1gHu28LgrsBwIxQ=="],
"@tauri-apps/plugin-window-state": ["@tauri-apps/plugin-window-state@2.4.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw=="],
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@7.0.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" }, "peerDependencies": { "@testing-library/dom": ">=10 <11", "vitest": ">= 0.32" }, "optionalPeers": ["vitest"] }, "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw=="],
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
"@testing-library/react": ["@testing-library/react@16.3.3", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.10.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-9nKgKoF6ZOUsM+or0OtNf+TTJSfGvDNP7ZFv/ZGWVwOSCkumyctQiTeHwB4UNljHTnC41AqylgbunLDHoccNrA=="],
@@ -733,7 +737,7 @@
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
"@types/react-dom": ["@types/react-dom@19.2.4", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw=="],
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
@@ -747,21 +751,21 @@
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.0", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="],
"@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="],
"@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="],
"@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="],
"@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="],
"@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="],
"@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="],
"@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="],
"@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="],
"@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="],
"@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="],
"@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="],
"@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.42", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.42", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ=="],
@@ -801,7 +805,7 @@
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
@@ -827,6 +831,8 @@
"cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="],
"cacheable": ["cacheable@2.5.0", "", { "dependencies": { "@cacheable/memory": "^2.2.0", "@cacheable/utils": "^2.5.0", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
@@ -837,7 +843,7 @@
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
@@ -847,7 +853,7 @@
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -859,7 +865,7 @@
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"concurrently": ["concurrently@9.2.4", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.9.0", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA=="],
"concurrently": ["concurrently@10.0.5", "", { "dependencies": { "chalk": "5.6.2", "rxjs": "7.8.2", "shell-quote": "1.9.0", "supports-color": "10.2.2", "tree-kill": "1.2.2", "yargs": "18.0.0" }, "bin": { "conc": "dist/bin/index.js", "concurrently": "dist/bin/index.js" } }, "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg=="],
"convert-hrtime": ["convert-hrtime@5.0.0", "", {}, "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg=="],
@@ -917,7 +923,7 @@
"electron-to-chromium": ["electron-to-chromium@1.5.422", "", {}, "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
@@ -937,7 +943,7 @@
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@10.8.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ=="],
"eslint": ["eslint@10.10.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
@@ -975,11 +981,11 @@
"fflate": ["fflate@0.4.9", "", {}, "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"file-entry-cache": ["file-entry-cache@11.1.5", "", { "dependencies": { "flat-cache": "^6.1.23" } }, "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flat-cache": ["flat-cache@6.1.23", "", { "dependencies": { "cacheable": "^2.5.0", "flatted": "^3.4.2", "hookified": "^1.15.0" } }, "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA=="],
"flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="],
@@ -989,7 +995,7 @@
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
"formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="],
"formatly": ["formatly@0.7.0", "", { "dependencies": { "fd-package-json": "^2.0.0", "package-manager-detector": "^1.8.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-7CXJtIIA0zy/u12StsYk25qVKxvdLA2ep2sTNxK3ov0mGNIIDqIvAXDSgTnAfDJFsPfWjuz0WjfYSdpvnLA5Tg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@@ -1005,6 +1011,8 @@
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
@@ -1013,11 +1021,11 @@
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-tsconfig": ["get-tsconfig@4.14.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A=="],
"get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.11.0", "", {}, "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw=="],
"globals": ["globals@17.12.0", "", {}, "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA=="],
"goober": ["goober@2.1.19", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg=="],
@@ -1027,12 +1035,12 @@
"guess-json-indent": ["guess-json-indent@3.0.1", "", {}, "sha512-LWZ3Vr8BG7DHE3TzPYFqkhjNRw4vYgFSsv2nfMuHklAlOfiy54/EwiDQuQfFVLxENCVv20wpbjfTayooQHrEhQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hast-util-embedded": ["hast-util-embedded@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-is-element": "^3.0.0" } }, "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA=="],
@@ -1077,6 +1085,8 @@
"hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="],
"hookified": ["hookified@1.15.1", "", {}, "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"html-parse-stringify": ["html-parse-stringify@4.0.1", "", {}, "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw=="],
@@ -1087,7 +1097,7 @@
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"i18next": ["i18next@26.4.0", "", { "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["typescript"] }, "sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg=="],
"i18next": ["i18next@26.4.2", "", { "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["typescript"] }, "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug=="],
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="],
@@ -1127,12 +1137,10 @@
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="],
"jsdom": ["jsdom@30.0.1", "", { "dependencies": { "@asamuzakjp/css-color": "^6.0.5", "@asamuzakjp/dom-selector": "^8.3.0", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.7", "@exodus/bytes": "^1.15.1", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.2", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.2", "undici": "^8.9.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^17.1.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.2.3" }, "optionalPeers": ["canvas"] }, "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
@@ -1143,9 +1151,9 @@
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="],
"knip": ["knip@6.32.2", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.1", "jiti": "^2.7.0", "oxc-parser": "^0.143.0", "oxc-resolver": "11.24.2", "picomatch": "^4.0.5", "smol-toml": "^1.7.1", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^4.0.9", "yaml": "^2.9.0", "zod": "^4.4.3" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-WXTXbmocrw7gqm1A1TQvFN0OgJ7hUSU6E1g6SPRIzzHFogUBhXByc7cYeOFVtJ2uODg7DP4VbESYBYnfbtBYsg=="],
"knip": ["knip@6.35.1", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.7.0", "get-tsconfig": "4.14.3", "jiti": "^2.7.0", "oxc-parser": "^0.148.0", "oxc-resolver": "11.24.2", "picomatch": "^4.0.7", "smol-toml": "^1.8.0", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^4.0.11", "yaml": "^2.9.0", "zod": "^4.4.3" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-22wnEnv4do2fvoeJsxpFCG/MBxReNxoCVBccaCl7suUJsG+F0UvxI9ycxZ9YgtLHSSjrZl5tOas0JZ/R1vJ93g=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
@@ -1183,7 +1191,7 @@
"lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
"lucide-react": ["lucide-react@1.33.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MTRwMy0ZlL8Ur/vOAiJ9XGHE+kFPC7brq6MxAm0GiGXEBj0qy0jA/pG4N675oSzciO/UCdX8T+5yUQdmDeTLxg=="],
"lucide-react": ["lucide-react@1.43.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ubtnda1fVK5ky0PNEpOmB0wiwhpZUyJiol4K14KCm+QKvuCkfUu54Et/rIjyupJpwiJ5Hg+BLQE86/GEsS+IgQ=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
@@ -1313,7 +1321,7 @@
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"oxc-parser": ["oxc-parser@0.143.0", "", { "dependencies": { "@oxc-project/types": "^0.143.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.143.0", "@oxc-parser/binding-android-arm64": "0.143.0", "@oxc-parser/binding-darwin-arm64": "0.143.0", "@oxc-parser/binding-darwin-x64": "0.143.0", "@oxc-parser/binding-freebsd-x64": "0.143.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.143.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.143.0", "@oxc-parser/binding-linux-arm64-gnu": "0.143.0", "@oxc-parser/binding-linux-arm64-musl": "0.143.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.143.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.143.0", "@oxc-parser/binding-linux-riscv64-musl": "0.143.0", "@oxc-parser/binding-linux-s390x-gnu": "0.143.0", "@oxc-parser/binding-linux-x64-gnu": "0.143.0", "@oxc-parser/binding-linux-x64-musl": "0.143.0", "@oxc-parser/binding-openharmony-arm64": "0.143.0", "@oxc-parser/binding-win32-arm64-msvc": "0.143.0", "@oxc-parser/binding-win32-ia32-msvc": "0.143.0", "@oxc-parser/binding-win32-x64-msvc": "0.143.0" } }, "sha512-ov0NzaDCOInknS7mP1cwKdJERt3utPW8ldjtdUXQ8Ty0GEFD08wk422vCUN0d7pST6kqtV7dxoI9w1Zi0l/9TA=="],
"oxc-parser": ["oxc-parser@0.148.0", "", { "dependencies": { "@oxc-project/types": "^0.148.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.148.0", "@oxc-parser/binding-android-arm64": "0.148.0", "@oxc-parser/binding-darwin-arm64": "0.148.0", "@oxc-parser/binding-darwin-x64": "0.148.0", "@oxc-parser/binding-freebsd-x64": "0.148.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.148.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.148.0", "@oxc-parser/binding-linux-arm64-gnu": "0.148.0", "@oxc-parser/binding-linux-arm64-musl": "0.148.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.148.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.148.0", "@oxc-parser/binding-linux-riscv64-musl": "0.148.0", "@oxc-parser/binding-linux-s390x-gnu": "0.148.0", "@oxc-parser/binding-linux-x64-gnu": "0.148.0", "@oxc-parser/binding-linux-x64-musl": "0.148.0", "@oxc-parser/binding-openharmony-arm64": "0.148.0", "@oxc-parser/binding-win32-arm64-msvc": "0.148.0", "@oxc-parser/binding-win32-ia32-msvc": "0.148.0", "@oxc-parser/binding-win32-x64-msvc": "0.148.0" } }, "sha512-syxUKHeUll89RIABQADcI7sikYrwyssvA6gj4phSSIPezKVM8yMaLAiLLSc7fmzVvrwybfFGFbW5zme9sX87rg=="],
"oxc-resolver": ["oxc-resolver@11.24.2", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.24.2", "@oxc-resolver/binding-android-arm64": "11.24.2", "@oxc-resolver/binding-darwin-arm64": "11.24.2", "@oxc-resolver/binding-darwin-x64": "11.24.2", "@oxc-resolver/binding-freebsd-x64": "11.24.2", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-musl": "11.24.2", "@oxc-resolver/binding-openharmony-arm64": "11.24.2", "@oxc-resolver/binding-wasm32-wasi": "11.24.2", "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" } }, "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw=="],
@@ -1349,7 +1357,7 @@
"playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="],
"playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="],
"playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
@@ -1357,7 +1365,7 @@
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
"posthog-js": ["posthog-js@1.418.6", "", { "dependencies": { "@posthog/browser-common": "^0.5.0", "@posthog/core": "^1.48.6", "@posthog/types": "^1.405.0", "core-js": "^3.49.0", "dompurify": "^3.4.13", "fflate": "^0.4.8", "preact": "^10.29.3", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.3.0", "web-vitals-soft-navs": "npm:web-vitals@6.0.0" } }, "sha512-s1+5sPSOElZ8twPNRQytBhY3fOp+rOkf6tZzS5xSZlNZG7UE/jRWcI0zRqykhMaeJOVwSABMuaeaap7rmX82Pg=="],
"posthog-js": ["posthog-js@1.428.11", "", { "dependencies": { "@posthog/browser-common": "^0.8.2", "@posthog/core": "^1.52.0", "@posthog/types": "^1.409.4", "core-js": "^3.49.0", "dompurify": "^3.4.13", "fflate": "^0.4.8", "preact": "^10.29.3", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^6.2.1", "web-vitals-soft-navs": "npm:web-vitals@6.2.1" }, "peerDependencies": { "@types/react": ">=16.8.0", "react": ">=16.8.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-98hMmCYgEclXefLmDHSaZIFzGS2D/1ypytNG51WJsWSJkEznyx6JlizT6m+aVqCXh0SR4u8TEvdloFoxCbP3UQ=="],
"preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="],
@@ -1373,6 +1381,8 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qified": ["qified@0.10.1", "", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
@@ -1381,13 +1391,13 @@
"radix-vue": ["radix-vue@1.9.17", "", { "dependencies": { "@floating-ui/dom": "^1.6.7", "@floating-ui/vue": "^1.1.0", "@internationalized/date": "^3.5.4", "@internationalized/number": "^3.5.3", "@tanstack/vue-virtual": "^3.8.1", "@vueuse/core": "^10.11.0", "@vueuse/shared": "^10.11.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "fast-deep-equal": "^3.1.3", "nanoid": "^5.0.7" }, "peerDependencies": { "vue": ">= 3.2.0" } }, "sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ=="],
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
"react-hot-toast": ["react-hot-toast@2.6.0", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg=="],
"react-i18next": ["react-i18next@17.0.12", "", { "dependencies": { "@babel/runtime": "^7.29.7", "html-parse-stringify": "^4.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q=="],
"react-i18next": ["react-i18next@17.0.13", "", { "dependencies": { "@babel/runtime": "^7.29.7", "html-parse-stringify": "^4.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q=="],
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
@@ -1397,7 +1407,7 @@
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"react-window": ["react-window@2.3.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-FW6TIpaOH646k51X7yE+LSCWGkt5Pfsnc1fVyq/sCI9h0pTqmMiBXM04pzFKg3Bt7NGkeV6kqbU8d/QjmFS7Ug=="],
"react-window": ["react-window@2.3.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-x/+N6b7FNVtlKE7ZQlfOY2bYdA7VXvT2B1Emo/ndAsFsSQ98FBIO88MsatNELbSIlYJgkCd7unBps5XMfv0Eyg=="],
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
@@ -1439,7 +1449,7 @@
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -1471,13 +1481,13 @@
"string-byte-slice": ["string-byte-slice@3.0.1", "", {}, "sha512-GWv2K4lYyd2+AhmKH3BV+OVx62xDX+99rSLfKpaqFiQU7uOMaUY1tDjdrRD4gsrCr9lTyjMgjna7tZcCOw+Smg=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"stringify-object": ["stringify-object@6.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-identifier": "^1.0.1", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-6f94vIED6vmJJfh3lyVsVWxCYSfI5uM+16ntED/Ql37XIyV6kj0mRAAiTeMMc/QLYIaizC3bUprQ8pQnDDrKfA=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
@@ -1487,7 +1497,7 @@
"super-regex": ["super-regex@1.1.0", "", { "dependencies": { "function-timeout": "^1.0.1", "make-asynchronous": "^1.0.1", "time-span": "^5.1.0" } }, "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"swrv": ["swrv@1.2.0", "", { "peerDependencies": { "vue": ">=3.2.26 < 4" } }, "sha512-lH/g4UcNyj+7lzK4eRGT4C68Q4EhQ6JtM9otPRIASfhhzfLWtbZPHcMuhuba7S9YVYuxkMUGImwMyGpfbkH07A=="],
@@ -1503,7 +1513,7 @@
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"taze": ["taze@19.17.2", "", { "dependencies": { "@antfu/ni": "^30.3.0", "@henrygd/queue": "^1.2.0", "cac": "^7.0.0", "obug": "^2.1.4", "ofetch": "^1.5.1", "package-manager-detector": "^1.8.0", "pathe": "^2.0.3", "pnpm-workspace-yaml": "^1.7.0", "restore-cursor": "^5.1.0", "tinyexec": "^1.3.0", "tinyglobby": "^0.2.17", "unconfig": "^7.5.0", "verkit": "^0.3.1", "yaml": "^2.9.0" }, "bin": { "taze": "bin/taze.mjs" } }, "sha512-1WH+LUf5H0R07EF606fuP5TuHPVvtrMIPpn69EkqaOVJYg0foL8xnD+lEs0s19Zfmplul5nRd3TpbbF0twXCkw=="],
"taze": ["taze@21.1.0", "", { "dependencies": { "@antfu/ni": "^30.5.0", "@henrygd/queue": "^1.2.0", "cac": "^7.0.0", "obug": "^2.1.4", "ofetch": "^1.5.1", "package-manager-detector": "^1.8.0", "pathe": "^2.0.3", "pnpm-workspace-yaml": "^1.8.0", "restore-cursor": "^5.1.0", "tinyexec": "^1.3.0", "tinyglobby": "^0.2.17", "unconfig": "^7.5.0", "verkit": "^0.3.2", "yaml": "^2.9.0" }, "bin": { "taze": "bin/taze.mjs" } }, "sha512-NkFkadmqqpaVZ9x3bV4cul1xQnUknhs1HzE3Q/VXHKS7np2Kg7ZNL7nNsEsBXKsafmgepa+aZNtzoegDgsymPw=="],
"time-span": ["time-span@5.1.0", "", { "dependencies": { "convert-hrtime": "^5.0.0" } }, "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA=="],
@@ -1553,7 +1563,7 @@
"unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="],
"undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="],
"undici": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
@@ -1593,7 +1603,7 @@
"vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="],
"vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="],
"vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="],
"vue": ["vue@3.5.42", "", { "dependencies": { "@vue/compiler-dom": "3.5.42", "@vue/compiler-sfc": "3.5.42", "@vue/runtime-dom": "3.5.42", "@vue/server-renderer": "3.5.42", "@vue/shared": "3.5.42" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA=="],
@@ -1615,9 +1625,9 @@
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"web-vitals": ["web-vitals@5.3.0", "", {}, "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g=="],
"web-vitals": ["web-vitals@6.2.1", "", {}, "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw=="],
"web-vitals-soft-navs": ["web-vitals@6.0.0", "", {}, "sha512-Guaibvy/+uNtL6Bsu4jmMJGzuSl91oeRH5iO9pPRbYftnFUr3yqT1TUNX/OE4o9HexuEMU3Kb/Wg7iKhlffZUA=="],
"web-vitals-soft-navs": ["web-vitals@6.2.1", "", {}, "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw=="],
"web-worker": ["web-worker@1.5.0", "", {}, "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw=="],
@@ -1625,7 +1635,7 @@
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
"whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
"whatwg-url": ["whatwg-url@17.1.0", "", { "dependencies": { "@exodus/bytes": "^1.15.1", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -1635,7 +1645,7 @@
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
@@ -1647,9 +1657,9 @@
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
@@ -1671,14 +1681,10 @@
"@floating-ui/vue/@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
"@playwright/test/playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="],
"@scalar/api-client/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
"@scalar/api-reference/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
"@scalar/icons/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@scalar/themes/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
"@scalar/types/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
@@ -1699,6 +1705,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tanstack/vue-virtual/@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.8", "", {}, "sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA=="],
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
@@ -1709,7 +1717,7 @@
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"data-urls/whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
"hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
@@ -1721,9 +1729,7 @@
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"playwright/playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="],
"qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
@@ -1735,9 +1741,9 @@
"radix-vue/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
"rolldown/@oxc-project/types": ["@oxc-project/types@0.148.0", "", {}, "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A=="],
"strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
"@playwright/test/playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@@ -1769,6 +1775,8 @@
"qrcode/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"qrcode/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
"qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
@@ -1779,10 +1787,18 @@
"radix-vue/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@10.11.1", "", {}, "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw=="],
"qrcode/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"qrcode/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"qrcode/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"qrcode/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"qrcode/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"qrcode/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"qrcode/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
+12 -3
View File
@@ -14,12 +14,21 @@ cloning, and cinematic video dubbing — fully local, with no cloud API keys or
![VoiceStudio — the open-source ElevenLabs alternative](https://raw.githubusercontent.com/debpalash/VoiceStudio/main/.github/assets/social-preview.png)
VoiceStudio runs entirely on your own hardware (CUDA / MPS / ROCm / CPU
VoiceStudio runs entirely on your own hardware (CUDA / ROCm / CPU
auto-detect) — nothing is sent to the cloud. This image is the **headless
web-server build**: a FastAPI backend serving a pre-built React UI over HTTP, so
you can run it on a homelab box, a GPU server, or anywhere Docker runs and open
you can run it on an AMD64 homelab box or GPU server and open
the UI in a browser.
**Architecture:** published images are **`linux/amd64` only**; there is no
native ARM64 image. On Apple Silicon, use the
[native macOS app](https://github.com/debpalash/VoiceStudio/blob/main/docs/install/macos.md)
for Apple GPU acceleration; the Linux container cannot access the Mac's Apple
GPU through MPS or MLX. Other ARM64 hosts need an AMD64 server or CPU emulation,
which can be much slower. See the
[architecture requirements](https://github.com/debpalash/VoiceStudio/blob/main/docs/install/docker.md#architecture)
before pulling an image.
> The Tauri desktop app's auto-updater and update-channel toggle are
> **desktop-only** and do not apply to this image — to update, pull a newer tag
> and recreate the container.
@@ -136,7 +145,7 @@ are mirrored on GHCR at
- **📦 Batch Queue** — drop 50 videos and walk away; per-job progress.
- **🤖 MCP Server** — drive VoiceStudio from Claude, Cursor, or any MCP client.
- **🛡️ AI Watermark** — invisible AudioSeal (Meta) marking that survives compression.
- **⚡ GPU Auto-Detect** — CUDA · MPS · ROCm · CPU, with auto-offload on ≤8 GB cards.
- **⚡ GPU Auto-Detect** — CUDA · ROCm · CPU, with auto-offload on ≤8 GB cards.
- **🧩 Extensible** — subclass `TTSBackend` to add any engine in ~50 lines.
Multiple TTS engines ship out of the box (IndexTTS, CosyVoice, Supertonic-3, and
+109 -48
View File
@@ -7,39 +7,71 @@ Every folder has a single job. Every file at the root earns its place.
```
VoiceStudio/
├── README.md ⟵ user-facing overview
├── CHANGELOG.md ⟵ release history
├── LICENSE
├── README.md / README_CN.md ⟵ user-facing overview (English / Chinese)
├── CHANGELOG.md ⟵ release history; release.yml extracts the tag's section verbatim
├── CLAUDE.md / AGENTS.md ⟵ the working contract for AI agents — keep the two in sync
├── LICENSE, LICENSE-NOTICE.md, SPONSORS.md
├── pyproject.toml ⟵ Python project manifest
├── pyproject.toml ⟵ Python project manifest (+ pytest / lint config)
├── uv.lock ⟵ Python lockfile
├── package.json ⟵ monorepo manifest (Bun workspaces + Turborepo)
├── bun.lock ⟵ JS lockfile
├── bun.lock ⟵ JS lockfile — repo-root, covers frontend/ too
├── turbo.json ⟵ turborepo pipeline
├── .coderabbit.yaml ⟵ CodeRabbit PR review config (fed CLAUDE.md)
├── greptile.json ⟵ Greptile PR review config (fed CLAUDE.md)
├── skills-lock.json ⟵ pins the sources + hashes of .agents/skills/
├── .gitleaks.toml ⟵ secret-scan config
├── .gitmodules ⟵ omnivoice-gallery submodule
├── .python-version
├── .dockerignore ⟵ Docker build context filter
├── backend.spec ⟵ pyinstaller spec (stays at root by pyinstaller convention)
├── alembic.ini ⟵ DB migration config (stays at root by alembic convention)
├── .env user config; gitignored, .env.example is the template
├── .gitignore
├── .gitignorea repo-local .env stays ignored, but user config is NOT
│ kept here: the durable env file is ~/.config/omnivoice/env
│ (backend/core/user_env.py), written by the Settings panel
├── backend/ ⟵ FastAPI server
│ ├── main.py
├── api/routers/ HTTP endpoints (thin)
│ ├── core/ config, db, task queue, metrics
├── services/ business logic
── schemas/ pydantic request/response shapes
│ ├── main.py the one entry point; its boot order is load-bearing —
│ read the comments before reordering anything
│ ├── api/routers/ 39 routers, auto-included; thin HTTP/WS surface
│ └── setup/ first-run wizard, model download
── core/ config, db, job queue, event bus, auth/CSRF, path security,
│ │ opt-in analytics, version, diagnostics
│ ├── services/ 78 modules of business logic — TTS, dubbing pipeline,
│ │ audio DSP, GPU gateway, engine routing, model lifecycle
│ ├── engines/ per-engine adapters: indextts, supertonic3, confucius4,
│ │ dots_tts, moss_tts_v15, pockettts, audiocpp,
│ │ omnivoice_gguf, omnivoice_subprocess, _asr_sidecar, _echo
│ ├── worker/ remote / distributed workers — scheduler, pool, routing,
│ │ breaker, capacity, plus protocol/ and inbound/
│ ├── mcp_shim/ MCP server entry point (docs/mcp.md)
│ ├── speech_client/ speech sidecar client entry point
│ ├── schemas/ pydantic request/response shapes
│ ├── migrations/versions/ alembic revisions — every schema change goes through here
│ ├── plugins/ plugin drop-in point (see services/plugin_sdk.py)
│ ├── hooks/ pyinstaller runtime hooks
│ ├── config/models.yaml model catalogue
│ └── tests/ the isolated pytest session — see "Where tests live"
├── frontend/ ⟵ React 19 + Vite + Tauri desktop
│ ├── package.json THE app version — every other version file mirrors it
│ ├── src/
│ │ ├── pages/ one file per top-level view
│ │ ├── components/ reusable UI
│ │ ├── api/ typed API clients
│ │ ├── store/ Zustand slices
│ │ ├── components/ reusable UI (+ audiobook/ clone/ dub/ gallery/ settings/ …)
│ │ ├── ui/, lib/ shared primitives and helpers
│ │ ├── api/ typed API clients, one per router group
│ │ ├── store/ Zustand slices (+ persisted-state migrations)
│ │ ├── hooks/ custom React hooks
│ │ ── utils/
│ ├── src-tauri/ Rust desktop shell
│ │ ── i18n/locales/ the ONLY home for user-facing strings
│ ├── config/, data/, assets/, utils/
│ │ └── test/ vitest setup + visual-test helpers
│ ├── e2e/, e2e-perf/, e2e-prod/ Playwright suites: functional, perf, packaged bundle
│ ├── src-tauri/ Rust desktop shell — backend spawn/bootstrap, updater
│ │ │ channel, dictation shortcut, crash/reset/uninstall
│ │ ├── capabilities/, icons/, wix/, debian/, appimage/ packaging inputs
│ │ └── tests/
│ └── public/
├── omnivoice/ ⟵ the underlying TTS model package
@@ -51,46 +83,59 @@ VoiceStudio/
│ ├── training/
│ └── utils/
├── tests/ ⟵ all tests live here, no exceptions
│ ├── conftest.py
│ ├── test_api.py
│ ├── test_dub_*.py
│ ├── test_job_queue.py
│ ├── test_segmentation.py
│ └── frontend/ Node-based frontend tests
├── tests/ ⟵ the main pytest session (testpaths in pyproject.toml)
│ ├── conftest.py hermetic OMNIVOICE_DATA_DIR — never touches real app state
│ ├── backend/, scripts/ mirrors of the source trees they cover
│ ├── smoke/ fast end-to-end checks (own CI job, HF_HUB_OFFLINE=1)
│ ├── evals/ quality evals (evals.yml)
│ ├── probe/, fixtures/
│ └── frontend/ Node-based frontend tests (legacy; vitest is the default)
├── scripts/ ⟵ dev / build / release shell + python scripts
│ ├── install.sh universal installer (macOS/Linux/WSL)
│ ├── install.ps1 universal installer (Windows)
│ ├── run.sh universal launcher
├── scripts/ ⟵ dev / build / release scripts (shell, python, mjs)
│ ├── install.sh / install.ps1 universal installers
│ ├── desktop-*.mjs dev, prod and fresh desktop launchers
│ ├── smoke-test.sh end-to-end validation
── desktop-prod.sh production desktop build
── check-docs-drift.py the docs-drift.yml checker (docs/features.yaml is canonical)
│ └── build-omnivoice-tts.sh builds the bin/ sidecars
├── bin/ ⟵ prebuilt omnivoice-tts sidecars, one per platform
├── .agents/skills/ ⟵ canonical skill copies (vite, fastapi-python), pinned by
│ skills-lock.json — followed by path, never symlinked
├── skills/ ⟵ skills this repo publishes (omnivoice, oss-maintainer)
├── infra/ ⟵ edge/deploy workers (not the Docker deploy path)
│ └── install-redirect/ voicestudio.sh/install — UA-sniffing installer worker
├── deploy/ ⟵ Docker deployment configs
│ ├── Dockerfile single-stage CUDA image
└── docker-compose.yml one-click local deployment
│ ├── Dockerfile CUDA by default; CI builds the ROCm variant from the same
│ file via BASE_IMAGE / GPU_FLAVOR overrides
│ ├── docker-compose.yml one-click local deployment
│ ├── torch-constraints.txt pinned torch resolution for the image
│ └── dockerhub-overview.md synced to the Docker Hub overview page at release
├── docs/ ⟵ developer docs, screenshots, branding
│ ├── ROADMAP.md where this project is going
│ ├── STRUCTURE.md you are here
│ ├── mcp.json MCP config template
│ ├── preview.png README hero image
│ ├── logo.png, logo.svg branding assets
│ ├── screenshot-*.png feature screenshots
│ ├── languages.md
│ ├── training.md
── data_preparation.md
│ ├── evaluation.md
│ └── voice-design.md
│ ├── RELEASING.md the release checklist — every deployment channel
│ ├── features.yaml canonical feature inventory (drives docs-drift.yml)
│ ├── adr/ architecture decision records
│ ├── agents/ agent-facing docs (issue tracker, triage labels, domain)
│ ├── engines/, dubbing/, install/, setup/, migration/, features/, playbooks/, specs/
│ ├── media/, screenshot-*.png, preview.png, logo.*
── languages.md, training.md, data_preparation.md, evaluation.md, voice-design.md
├── examples/ ⟵ runnable demos + sample inputs
├── examples/ ⟵ runnable demos + sample inputs (agentic/, speech-platform/)
├── notebooks/ ⟵ OmniVoice_Studio_Colab.ipynb
├── omnivoice-gallery/ ⟵ git submodule — the published voice gallery
├── omnivoice_data/ ⟵ Docker bind-mount target (gitignored)
│ DB + HF cache live here when running via compose
├── .github/workflows/ ⟵ ci, docker, release, security, docs-drift, evals,
│ install-smoke, build-omnivoice-tts
└── .git/
```
@@ -98,11 +143,22 @@ VoiceStudio/
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. |
## What lives where
@@ -110,10 +166,14 @@ VoiceStudio/
|---|---|
| User-facing product code | `backend/`, `frontend/` |
| 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 001007 — all shipped. | Deleted. |
| `specs/` | spec-kit specs for features 001007 — all shipped. | Deleted; `docs/specs/` is the current home. |
| `design/` | ASCII mockups of the pre-React target UX, superseded by the shipped app. | Deleted. |
| `research/` | Archived legacy Gradio UI + April-2026 competitor notes. | 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`. |
## Scaling path (proposed, not yet executed)
@@ -169,12 +229,13 @@ VoiceStudio/
- `backend.spec` (`['backend/main.py']`, `pathex=['.']`)
- `frontend/src-tauri/tauri.*.conf.json` sidecar paths
- every import that reads `from backend.main import …` (tests, scripts)
- `frontend/package.json` as the version source of truth, and the mirrors that track it
Migrate when adding the second `apps/*` or the second `packages/*`. Not before.
## Conventions
- **Filenames:** snake_case for Python, kebab-case or PascalCase for JS/TS components, lowercase for Markdown.
- **Tests mirror source paths.** `backend/services/dub_pipeline.py``tests/services/test_dub_pipeline.py`.
- **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*.
+13 -1
View File
@@ -38,7 +38,19 @@ To keep that path **fast** despite Xet being off, the app runs a built-in
**multi-connection (segmented) downloader on by default** — it fetches each file
over parallel byte-ranges (IDM/uGet style), so the legacy-LFS path is no longer
single-stream. It reports real live speed/ETA and **falls back to the normal
download on any error**, so it can never compromise a correct install. Adding a
download**, so it can never compromise a correct install.
Ranges are capped at 16 MB each and eight of them are in flight at a time, so a
completed range is committed to a resume manifest every few seconds. On a
connection that drops mid-transfer, only the ranges in flight are refetched: the
attempt is retried and the accelerator resumes from its manifest rather than
starting the file over. An origin that does not serve ranges at all is handled
inside the accelerator as a single stream, not as a failure.
The accelerator is disabled for the rest of the install — handing over to the
plain `snapshot_download` path — when it fails for a reason that is not
transient network trouble, and on the install's final attempt, so it can never
be the reason an install fails outright. Adding a
free Hugging Face token (first-run setup, or Settings → Credentials) makes this
faster still — authenticated downloads get higher rate limits and fewer stalls.
To force the old single-stream path, set `OMNIVOICE_SEGMENTED_DOWNLOAD=0`.
+6 -2
View File
@@ -19,6 +19,7 @@ Every engine in the tree owns at least one job. A job has exactly one holder.
| Fastest CPU render / lowest latency | *open — see #1306* |
| Best Chinese/Japanese expressiveness | `cosyvoice`, `indextts2` |
| CPU-realtime English, tiny footprint | `kittentts`, `supertonic3` |
| Bilingual reference-free voice design and direction | `audiocpp` |
| Best transcription accuracy | `whisperx`, `faster-whisper` |
| Fastest Apple-Silicon transcription | `parakeet-mlx`, `mlx-whisper` |
| Crash isolation for transcription | `faster-whisper-isolated` |
@@ -35,8 +36,11 @@ which is a property of the bar, not a judgement of the contributor.
does not cover it. Latency, language, hardware envelope or quality tier —
something a user would choose it *for*.
2. **Licence clean for commercial use.** Model weights *and* code. No
research-only weights, no ambiguous provenance. This is the one that most
often ends a proposal, so check it first.
research-only weights or ambiguous provenance. The single approved
exception is `audiocpp` with Breeze-TTS-2 research/non-commercial weights,
approved by the owner on 2026-09-08 for its requested bilingual voice-design
and direction workflow. It remains opt-in and discloses the restriction
before selection and download; `@debpalash` is its named steward.
3. **Every platform, or explicitly opt-in.** macOS (Apple Silicon and Intel),
Windows, Linux. A CPU path is required — an engine that only runs on one
accelerator is fine, but it must degrade rather than break, and a
+8 -1
View File
@@ -5,6 +5,12 @@ quirks. Select engines in **Model Catalogue → Engines** (or quick-switch with
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd>), or pin one with
`OMNIVOICE_TTS_BACKEND` / `OMNIVOICE_ASR_BACKEND`.
When an engine reports itself unavailable, expand its row's **Why?** panel and
use **Learn more** to jump straight to that engine's page here. The row's own
message stays deliberately generic — an availability probe can carry local
paths or credentials, so it is never shown verbatim — and the page below is
where the actual requirements and setup steps live.
The compute device (CUDA/ROCm/MPS/CPU) is auto-detected; pin it under
**Settings → Performance & Device** (or `OMNIVOICE_DEVICE`) if auto-detect
picks wrong — see [performance](../performance.md).
@@ -36,9 +42,10 @@ approval), [Windows](../install/windows.md), [Linux](../install/linux.md),
| Supertonic-3 | [supertonic3](supertonic3.md) | CPU | — (7 preset voices) | `uv sync --extra supertonic` + license |
| 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 |
| PocketTTS (Kyutai) | [pockettts](pockettts.md) | CPU (not Intel Mac) | ✅ | `uv sync --extra pockettts` + license |
| Confucius4-TTS | [confucius4-tts](confucius4-tts.md) | CUDA · CPU | ✅ | clone + env var |
| audio.cpp (Breeze-TTS-2) | [audio-cpp](audio-cpp.md) | CPU + Vulkan/Metal/CUDA/HIP/ROCm where compiled | ✅ + voice design | prebuilt binary + env var (weights research/non-commercial) |
## Speech-to-text
+142
View File
@@ -0,0 +1,142 @@
# VoiceStudio — audio.cpp Engine (Breeze-TTS-2)
[audio.cpp](https://github.com/0xShug0/audio.cpp) is a pure-C++ ggml audio
inference framework with prebuilt binaries for Windows, macOS, and Linux and
no Python dependency. VoiceStudio discovers the compute providers compiled
into the installed binary and selects the best available device. VoiceStudio drives its
`audiocpp_server` over loopback HTTP — v1 serves the **`breeze_tts`**
family: **Breeze-TTS-2** (BreezeBlue, 3B params, English + Chinese, voice
clone + voice design + voice direction, 24 kHz).
> **Opt-in, and never a default.** Select `audiocpp` explicitly in **Model
> Catalogue → Engines** (or `OMNIVOICE_TTS_BACKEND=audiocpp`).
## License — read before enabling
- **audio.cpp code:** Apache-2.0.
- **Breeze-TTS-2 weights** (upstream `BreezeBlue/Breeze-TTS-2` and the
`audio-cpp/audio.cpp-gguf` GGUF repack): **research and non-commercial
use only** under the [BreezeBlue Research and Non-Commercial License](
https://huggingface.co/BreezeBlue/Breeze-TTS-2/blob/main/LICENSE).
Self-hosted outputs inherit the restriction; a BreezeBlue paid
subscription covers hosted-platform outputs only, not this engine.
## Platform support
| Host | Binary | Compute |
|---|---|---|
| Windows x64 | CPU, Vulkan, CUDA 12.4/13.3 prebuilts | CPU, Vulkan, CUDA |
| Linux x64 | CPU and Vulkan prebuilts | CPU, Vulkan; CUDA/ROCm from a self-build |
| macOS arm64 | upstream Metal prebuilt | Metal + CPU |
| macOS x64 | upstream `metal` archive (Metal disabled by upstream) | CPU |
| Linux aarch64 | none upstream | unavailable in v1 |
VoiceStudio runs `audiocpp_server --list-devices` once per installed binary,
prefers CUDA, ROCm, Metal, then a discrete Vulkan GPU, and keeps CPU as a safe
fallback. Device numbers are local to each backend registry. The Q8_0 GGUF is
approximately 4.73 GiB, plus runtime memory.
Allow about 6 GB of dedicated VRAM for CUDA, HIP, or a discrete GPU exposed
through Vulkan. Metal and Vulkan integrated GPUs use unified-memory handling
instead of that dedicated-VRAM floor.
## Install
1. Download the v0.7.2 prebuilt for your platform from
[audio.cpp releases](https://github.com/0xShug0/audio.cpp/releases/tag/v0.7.2)
and extract it. Use the Vulkan archive on Windows or Linux for broad GPU
support, the CPU archive when Vulkan is unavailable, or the matching CUDA
archive on Windows for NVIDIA. A Windows CUDA install needs both the
`bin-…-cuda…` and matching `cudart-…-cuda…` archives extracted into the
same directory, as required by upstream. VoiceStudio does not download
executable code for this engine. Linux archives do not preserve the
executable bit, so run `chmod +x audiocpp_server` after extracting one.
Verify the archive before extracting it. The pinned SHA-256 checksums are:
| Archive | SHA-256 |
|---|---|
| `audio-v0.7.2-bin-windows-x64-cpu-portable.zip` | `0b1f4bd78c5226ee3fa0eb24d95d603a429439cdf5dab45872d44a87412dd8c1` |
| `audio-v0.7.2-bin-windows-x64-vulkan.zip` | `15b8232eae740e21e507d87f827a89966de9451b085a45932d9e214e032962c1` |
| `audio-v0.7.2-bin-windows-x64-cuda12.4.zip` | `06c426095008022a2984ff1c75de4c9fab463c4201c0ff0a5dc4e14043f52326` |
| `audio-v0.7.2-cudart-windows-x64-cuda12.4.zip` | `7115be4d462817ad293f7932a8ac436d51023128e6728af09bba92a85593f393` |
| `audio-v0.7.2-bin-windows-x64-cuda13.3.zip` | `f975fec52745807b8c787e826c110acc3424a45156092455e1635604b68ec832` |
| `audio-v0.7.2-cudart-windows-x64-cuda13.3.zip` | `9b508f702636a9cdf3bf4dd8e75a86c20a0b87bdc39ca07e714c82f748efc1fa` |
| `audio-v0.7.2-bin-ubuntu-x64-cpu.tar.gz` | `6f5e43dd7b80e8ddf688ef84b411fadcd1f934d2c83963178bc4e2d9c4f07736` |
| `audio-v0.7.2-bin-ubuntu-x64-vulkan.tar.gz` | `fee1f978cee76453cf17f00196554bc2ee294645739538af0726a143b6a69a23` |
| `audio-v0.7.2-bin-macos-arm64-metal.tar.gz` | `c01e4f82971bedbe341697e63a9cebd5a5d1f72d5a9bcb51a3191f95ddab7a95` |
| `audio-v0.7.2-bin-macos-x64-metal.tar.gz` | `3862270f33439077225324169313f727064f727305b54d8ce920244d75ddcc24` |
Run `sha256sum <archive>` on Linux, `shasum -a 256 <archive>` on macOS,
or `Get-FileHash <archive> -Algorithm SHA256` in PowerShell and compare the
complete result with the table.
2. Set `OMNIVOICE_AUDIOCPP_BIN` to the `audiocpp_server` binary
(`audiocpp_server.exe` on Windows):
```bash
# macOS / Linux
echo 'export OMNIVOICE_AUDIOCPP_BIN=$HOME/apps/audio.cpp/audiocpp_server' >> ~/.zshrc
source ~/.zshrc
```
Alternatively set `OMNIVOICE_AUDIOCPP_DIR` to the directory containing it.
3. Restart VoiceStudio, open **Model Catalogue → Models**, find
**Breeze-TTS-2 Q8_0 for audio.cpp**, review its research/non-commercial
license note, and click **Install**. Generation never starts this ~4.73 GiB
download automatically.
4. Pick `audiocpp` in **Model Catalogue → Engines**. The server starts
lazily on first generate (`server.json` + `server.log` live under the app
data `audiocpp/` directory).
## Voice modes
All three go through the one speech endpoint — reference presence selects:
- **Clone:** `ref_audio` + `ref_text` (exact transcript, as upstream).
- **Direction:** `ref_audio` + `ref_text` + `instruct`
(e.g. "Speak slowly with a restrained, serious tone").
- **Design:** `description` (or `instruct`) with no `ref_audio`
(e.g. "A warm, thoughtful young woman…"). Upstream strengthens
instruction-following with `guidance_scale` ≈ 4.
## Optional env knobs
| Variable | Default | Purpose |
|----------|---------|---------|
| `OMNIVOICE_AUDIOCPP_BIN` | — | Absolute path to `audiocpp_server`. |
| `OMNIVOICE_AUDIOCPP_DIR` | — | Directory containing `audiocpp_server`. |
| `OMNIVOICE_AUDIOCPP_MODEL` | Model Catalogue cache | GGUF file or directory override. |
| `OMNIVOICE_AUDIOCPP_PACKAGE` | `breeze-tts-2-q8_0.gguf` | Package filename (`…-bf16.gguf` for full precision). |
| `OMNIVOICE_AUDIOCPP_PORT` | `17860` | Loopback port. |
| `OMNIVOICE_AUDIOCPP_BACKEND` | Settings, then auto | Exact runtime: `cuda`, `hip`/`rocm`, `vulkan`, `metal`, or `cpu`. |
| `OMNIVOICE_AUDIOCPP_DEVICE` | best device | Backend-local non-negative device index; requires `OMNIVOICE_AUDIOCPP_BACKEND`. |
The audio.cpp overrides take precedence over the global Settings compute
choice. A global CUDA/ROCm choice can match an NVIDIA/AMD GPU exposed through
Vulkan. An unavailable explicit audio.cpp override is an error; an unavailable
global preference falls back to CPU and is shown as a routing fallback.
## Common errors
### `audiocpp_server not found ...`
The binary isn't installed. Follow **Install** — the message carries the
exact release URL and SHA for your platform.
### `audiocpp_server exited during startup ...`
The managed loopback port may be taken. Check `server.log` next to
`server.json` in the app data `audiocpp/` directory, or set a different
`OMNIVOICE_AUDIOCPP_PORT` and restart VoiceStudio.
### `Breeze-TTS-2 ... not installed` or `package ... not completely installed`
Install the model from **Model Catalogue → Models**. If an interrupted install
left it incomplete, use **Reinstall** there. If the error persists after a
complete reinstall, file an issue with the package listing.
---
audio.cpp runs as a managed native server (no Python venv, no
`transformers` conflict). Only the downloaded GGUF counts toward
[sidecar disk usage](disk-usage.md).
+12
View File
@@ -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
+11
View File
@@ -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 1216 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`).
+17 -1
View File
@@ -30,6 +30,18 @@ interpreter, so MOSS runs behind
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
MOSS-TTS-v1.5 is **not** bundled (the model is large and the package pins a
@@ -50,9 +62,13 @@ into an isolated venv on demand.
```bash
cd MOSS-TTS
uv venv .venv
uv pip install -e ".[torch-runtime]"
uv pip install -e ".[torch-runtime]" --extra-index-url https://download.pytorch.org/whl/cu128 --index-strategy unsafe-best-match
```
The extra pins `torch==2.9.1+cu128`, which is published only on PyTorch's
own index, so the `--extra-index-url` is required — without it uv reports
the requirements as unsatisfiable on every host.
On a **non-CUDA / CPU host** (e.g. Apple Silicon), install plain
`torch`/`torchaudio`/`transformers==5.0.0` into the venv instead of the
`+cu128` extra (the auto-bootstrap below only targets CUDA hosts).
+6 -4
View File
@@ -32,12 +32,14 @@ lower call overhead.
## Selecting it
- **Model Catalogue → Engines**, or
- **Model Catalogue → Engines** on CUDA, ROCm, or CPU, or
- `OMNIVOICE_TTS_BACKEND=omnivoice-subprocess`
The explicit engine is opt-in on CUDA, ROCm, and CPU. Apple Silicon gets the
same isolation automatically while keeping the default `omnivoice` id in APIs,
Settings, and saved projects.
The explicit engine is opt-in on CUDA, ROCm, and CPU. On Apple Silicon it is
not listed separately: the canonical `omnivoice` choice automatically uses the
same isolation while keeping that default id in APIs, Settings, and saved
projects. Existing explicit `omnivoice-subprocess` configuration remains
accepted for compatibility.
## Platform support
+10 -3
View File
@@ -24,7 +24,13 @@ for this model.
uv sync --extra pockettts
```
(Or enable it from **Model Catalogue → Engines**.)
Or click **Install** in **Model Catalogue → Engines → PocketTTS**. That
installs the same pinned package into the engine's own Python environment
under VoiceStudio's data directory, with the CPU build of PyTorch, because
PocketTTS never uses a GPU. Nothing it installs touches VoiceStudio itself
or any other engine, and **Uninstall** in the same row removes only that
folder. An install made with `uv sync` keeps working as it is. The button
is not offered on Intel Macs (see Platform notes).
2. **Accept the license in-app**
([#1306](https://github.com/debpalash/VoiceStudio/issues/1306)). The code
@@ -50,8 +56,9 @@ for this model.
- Output is 24 kHz mono.
- Six languages, one model per language, chosen by the `language` you
request; cloning takes a short reference clip.
- Runs in a crash-isolated sidecar process (parent Python environment): a
wedged generation is hard-killed by a watchdog and its memory reclaimed —
- Runs in a crash-isolated sidecar process: from its own environment after
a one-click install, otherwise from VoiceStudio's (where `uv sync --extra
pockettts` puts it). A wedged generation is hard-killed by a watchdog and its memory reclaimed —
something an in-process engine cannot do.
- The first use downloads the gated weights; the sidecar heartbeats
progress during the download so the watchdog doesn't fire.
+8 -5
View File
@@ -19,8 +19,11 @@ crashes and cold init never block the rest of VoiceStudio.
uv sync --extra supertonic
```
(Or enable it from **Model Catalogue → Engines**, which installs the
pinned `supertonic` wheel for you.)
Or click **Install** in **Model Catalogue → Engines → Supertonic-3**. That
installs the same pinned wheel into the engine's own Python environment
under VoiceStudio's data directory. Nothing it installs touches VoiceStudio
itself or any other engine, and **Uninstall** in the same row removes only
that folder. An install made with `uv sync` keeps working as it is.
2. **Accept the license in-app.** First use is gated behind an explicit
acceptance dialog: the inference SDK is MIT, but the model weights are
@@ -45,9 +48,9 @@ log line.
## Behaviour notes
- Output is 44.1 kHz mono.
- Runs as a long-lived sidecar in the parent Python environment (its
dependencies — onnxruntime, numpy, soundfile — already match
VoiceStudio's pins); subsequent calls reuse the warm ONNX session.
- Runs as a long-lived sidecar: from its own environment after a one-click
install, otherwise from VoiceStudio's (where `uv sync --extra supertonic`
puts it). Subsequent calls reuse the warm ONNX session.
- `speed` is clamped to 0.72.0; quality steps clamp to 512.
- Language is an ISO 639-1 code; Auto engages the SDK's multilingual
fallback.
+4
View File
@@ -18,6 +18,10 @@ ignores.
| 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**
+2
View File
@@ -60,6 +60,8 @@ tts_engines:
readme: "**Confucius4-TTS**"
doc: docs/engines/confucius4-tts.md
- id: pockettts
- id: audiocpp
doc: docs/engines/audio-cpp.md
# Same contract against backend/services/asr_backend.py _REGISTRY.
asr_engines:
+19 -1
View File
@@ -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.
+3 -1
View File
@@ -31,7 +31,9 @@ request succeeds in ~1s (reproduced 5x: 1.574s / 1.034s / 1.065s / 0.995s / 0.91
(`repo_id` is required — `InstallModelRequest` in `backend/api/schemas.py` rejects a bare/empty
body — and must match one of the entries in `KNOWN_MODELS`, e.g. the default engine's
`k2-fsa/OmniVoice`.) Progress streams over the existing `/setup/download-stream` SSE feed.
- Or raise `OMNIVOICE_GENERATE_TIMEOUT_S` for the first request.
- Or raise the compute-time budget in **Settings → Performance & Device** for the first
request (`OMNIVOICE_GENERATE_TIMEOUT_S` does the same thing from the environment, and
takes precedence over the setting when both are present).
## OpenAI-compatible endpoint doesn't expose `num_step` / `guidance_scale`
+58
View File
@@ -7,6 +7,25 @@ it in a normal browser.
**Official images:** [`ghcr.io/debpalash/omnivoice-studio`](https://github.com/debpalash/VoiceStudio/pkgs/container/omnivoice-studio)
and [`palashdeb/omnivoice-studio` on Docker Hub](https://hub.docker.com/r/palashdeb/omnivoice-studio) — same images, same tags.
## Architecture
The published images are **`linux/amd64` (x86-64) only**, including `:stable`,
`:latest`, and the ROCm variants. There is no native `linux/arm64` image.
On an ARM64 host, pulling without an explicit platform can fail with
`no matching manifest for linux/arm64/v8 in the manifest list entries`.
- **Apple Silicon (M-series Macs):** use the [native macOS app](macos.md),
which supports Apple GPU acceleration. The Linux container cannot access
the Mac's Apple GPU through MPS or MLX.
- **AMD64 emulation on ARM64 (including Apple Silicon):** if your Docker
installation supports it, place `--platform linux/amd64` **before the image
name** in both `docker pull` and `docker run` from the CPU instructions
below. This is an emulated CPU option, not native
ARM64 support; inference can be much slower and is not a GPU workaround.
Without emulation, use an AMD64 server for this Docker deployment.
## Image tags
> **Image ↔ version mapping**
>
> | Tag | What you get |
@@ -237,6 +256,45 @@ docker compose -f deploy/docker-compose.yml --profile gpu up -d
docker compose -f deploy/docker-compose.yml --profile rocm up -d
```
> **ARM64 hosts:** Compose has no per-command `--platform` flag, so the
> override that works for `docker pull` and `docker run` does not reach it.
> Set `DOCKER_DEFAULT_PLATFORM=linux/amd64` in the shell you run Compose from,
> or the image resolves to the ARM64 manifest that does not exist and fails
> with `no matching manifest for linux/arm64/v8`. Only the CPU profile makes
> sense under emulation — it is not a GPU workaround.
>
> ```bash
> export DOCKER_DEFAULT_PLATFORM=linux/amd64
> docker compose -f deploy/docker-compose.yml --profile cpu pull
> docker compose -f deploy/docker-compose.yml --profile cpu up -d
> ```
>
> In PowerShell, set both the administrator key and the platform for the
> session before running the same two commands:
>
> ```powershell
> $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
> try {
> $keyBytes = New-Object byte[] 32
> $rng.GetBytes($keyBytes)
> # URL-safe, like the `secrets.token_urlsafe` the Bash line uses: the
> # key is also accepted as an `?api_key=` query parameter, where a
> # raw Base64 `+` decodes to a space and silently mismatches.
> $env:OMNIVOICE_API_KEY =
> [Convert]::ToBase64String($keyBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
> }
> finally {
> $rng.Dispose()
> }
> $env:DOCKER_DEFAULT_PLATFORM = 'linux/amd64'
> docker compose -f deploy/docker-compose.yml --profile cpu pull
> docker compose -f deploy/docker-compose.yml --profile cpu up -d
> ```
>
> Either way the setting lives only in that shell and the processes it starts.
> The [architecture limits](#architecture) still apply: this is emulated CPU
> inference, not native ARM64 support.
The `docker-compose.yml` shipped in `deploy/` defaults to `127.0.0.1:3900`
on the host. The backend inside the container binds to `0.0.0.0` so the
host port mapping can forward — the host-side `127.0.0.1` binding is what
+128 -1
View File
@@ -248,6 +248,70 @@ peak memory footprint that exceeds free VRAM. Windows-only quirk.
**Linked issue:** [#65](https://github.com/debpalash/VoiceStudio/issues/65)
## 5b. RTX 50-series (Blackwell, sm_120): backend crashes during `ml_imports`
**Symptom:** on an RTX 5070 / 5070 Ti / 5080 / 5090, the backend never becomes
ready. The desktop app sits on "starting backend", `/health` returns 503, and
`/startup/progress` shows `ml_imports` active. From source you see `import
torch` die with a native access violation rather than a Python traceback.
**Cause:** VoiceStudio pins `torch 2.8.0`. That build carries no `sm_120`
kernels, so on a Blackwell card the CUDA initializer faults inside the native
library. This is not a VoiceStudio bug and no setting works around it — the
wheel does not contain code for the GPU.
**Fix:** move the whole torch trio to a build with `sm_120` kernels. They must
move together — upgrading one past the ABI the others were built against gives
you `RuntimeError: operator torchvision::nms does not exist`, which is the
next section's problem instead.
Edit **both** pin lists, keeping them identical:
- `[tool.uv] constraint-dependencies` in `pyproject.toml`
- `deploy/torch-constraints.txt`
```
torch==2.9.1
torchaudio==2.9.1
torchvision==0.24.1
```
Then relock and reinstall:
```bash
uv lock
uv sync
```
Confirm the GPU is actually usable before relaunching:
```bash
uv run python -c "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_capability())"
```
`True (12, 0)` means the kernels are there.
**Why both files:** `constraint-dependencies` governs `uv sync` / `uv lock` /
`uv run`, and `deploy/torch-constraints.txt` governs the `uv pip install`
paths (Docker and the Colab notebook), which ignore project-level uv settings.
`tests/test_torch_constraints_are_applied.py` fails if the two drift, so a
one-sided edit is caught rather than shipped.
**What you do not have to do:** `torchaudio 2.9` removed `set_audio_backend()`,
which VoiceStudio used to call unguarded — that turned this upgrade into a
different hard startup crash (`AttributeError` inside `ml_imports`). It is
guarded now, so the upgrade path above is clean on a current checkout.
**Keeping the change:** these are the repo's own pins, so a `git pull` that
touches them will conflict or overwrite. Re-apply after updating until the
default pin moves — the default cannot move for everyone until the newer torch
is verified across the older GPUs VoiceStudio supports, since a build that adds
`sm_120` can drop older architectures.
**Linked issue:** [#1931](https://github.com/debpalash/VoiceStudio/issues/1931)
— thanks to the reporter for the full diagnosis, including the verification
commands above.
## 6. `uv venv` Python download fails (restricted network)
**Symptom:** during first launch, `uv` exits with a network error pulling
@@ -300,7 +364,8 @@ version** reverts to the build the app shipped with.
First update yt-dlp under **Settings → Audio tools**. If YouTube still requires
your signed-in session, export its cookies in Netscape `cookies.txt` format,
then choose that file beside the URL field before importing. VoiceStudio uses
then open **Advanced** on the dubbing import card and choose that file under
**YouTube sign-in** before importing. VoiceStudio uses
the export for that import only and makes two best-effort attempts to delete
its temporary copy.
@@ -962,6 +1027,68 @@ repair is required.
**Linked issue:** [#1590](https://github.com/debpalash/VoiceStudio/issues/1590)
## Reading the first-run install log after setup finishes
The Activity panel on the first-run screen shows the install as it happens, and
that screen closes the moment setup succeeds — so it is not where you go
afterwards to check what was installed, or to attach the log to a bug report.
The same lines are written to **`bootstrap.log`**, beside the backend logs:
| Platform | Location |
|---|---|
| macOS | `~/Library/Logs/OmniVoice/bootstrap.log` |
| Windows | `%LOCALAPPDATA%\OmniVoice\Logs\bootstrap.log` |
| Linux | `~/.local/state/OmniVoice/bootstrap.log` |
`OMNIVOICE_LOG_DIR` moves it, along with the other logs.
It covers the current run only — it is truncated when a bootstrap starts, so a
retry replaces the previous attempt rather than appending to it. If you need
the log from an attempt that has already been superseded, copy it before
retrying.
## RTX 50-series (Blackwell, `sm_120`): backend never starts
**Symptom.** The desktop app stays on "starting backend", `/health` returns 503,
and the backend log ends inside the `ml_imports` phase — often with a native
crash (exit code `0xffffffff` / `-1073741819`) rather than a Python traceback.
**Cause.** VoiceStudio pins `torch 2.8.0+cu128`, which ships no `sm_120`
kernels. On an RTX 50-series card `import torch` dies natively, before any
VoiceStudio code can classify it — which is why the app can only say the
backend did not start. This is a property of the pinned build, not of your
driver or your install.
**Fix.** Move to a torch build that has Blackwell kernels. From a source
checkout, in the project folder:
1. Edit `pyproject.toml``[tool.uv] constraint-dependencies` and raise the
torch constraint to `torch==2.9.1+cu128` (matching `torchaudio` /
`torchvision` for that release).
2. `uv lock`
3. `uv sync --all-extras`
Then confirm the card is actually visible:
```bash
uv run python -c "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_capability())"
```
`True (12, 0)` means Blackwell is working.
**Note.** torchaudio 2.9 removed `set_audio_backend()`. VoiceStudio's call is
guarded, so the upgrade above no longer trades one startup crash for another —
but if you are on a build older than that guard you will see
`AttributeError: module 'torchaudio' has no attribute 'set_audio_backend'`
in the same phase. Update VoiceStudio, or delete that line in
`backend/main.py`.
**Why the pin has not moved.** Raising it for everyone changes the CUDA build
on every platform, in Docker, and in CI, so it is a deliberate decision rather
than a patch. Track it in [#1931](https://github.com/debpalash/VoiceStudio/issues/1931).
## Uninstalling / removing all of VoiceStudio's data
VoiceStudio is fully local — no accounts, no services, nothing to deactivate. To
+9
View File
@@ -320,3 +320,12 @@ system build's resource snapshot. The Windows MSI authoring
job runs after the test suite and preserves verbose WiX logs and rendered XML.
For focused diagnosis, dispatch CI with `windows_wix_diagnostic=true`; it skips
the other jobs and never signs, publishes, or installs the fixture bundles.
The release smoke installs and removes the current-user MSI using a standard
Windows account. On disposable GitHub-hosted Windows Server runners, it explicitly
allows unmanaged MSI installations for the duration of this test, then restores
the previous Installer policy value and type (or its absence), even on failure.
This test-host preparation does not change installer privileges or user machines.
Verbose MSI logs are printed if installation or removal fails. The Windows CI
job also rejects an invalid MSI and verifies policy absence, value types, account
cleanup, and verbose failure logs using Windows PowerShell 5.1.
@@ -31,11 +31,11 @@ Nothing studio-grade is on the default surface; everything is one click away.
### Full-height pro workflow (2026-08-11)
1. **Start working immediately:** on a pristine install, Stories creates and opens **The Lighthouse at Wits' End** as a normal saved project. Its 2 chapters, 3-character cast, pauses, expressive tags, and voice assignments exercise the real preview, stems, and Generate paths—not a visual mock.
2. **Set up on the left:** a persistent production rail owns project naming/saving, saved projects, cast-to-voice mapping, global pacing, and stems. Sections collapse independently without covering the manuscript.
2. **Choose a workspace tab:** Script holds the manuscript, Cast maps characters to voices, Export holds pacing and output options, and Projects handles naming, saving, and opening stories. Tabs share the Clone workspace navigation and support keyboard arrow keys.
3. **Write in the center:** the full-height manuscript canvas owns import, paste/auto-cast, line and chapter creation, editing, reorder, preview, and per-line direction. Long stories stay fast through `content-visibility`.
4. **Deliver from the header:** story length, runtime, format, progress, and Generate stay reachable while the manuscript scrolls.
4. **Deliver from Export:** choose the audio format and reading speed, generate the complete story, or export character stems. Story length and estimated runtime remain visible in the header.
The hierarchy is spatial instead of label-heavy: setup rail → manuscript → output header. The default sample is authored content stored through the same project actions as user work, automatically adopts installed voice profiles, and remains fully editable or deletable.
The task tabs give the manuscript the full workspace width; project data and unfinished text remain intact when switching tabs. The default sample is authored content stored through the same project actions as user work, automatically adopts installed voice profiles, and remains fully editable or deletable.
## 3. Interaction model (chosen: line cards)
+1 -1
View File
@@ -12,7 +12,7 @@ import globals from 'globals';
import { defineConfig, globalIgnores } from 'eslint/config';
export default defineConfig([
globalIgnores(['dist']),
globalIgnores(['dist', 'src-tauri']),
{
files: ['**/*.{js,jsx}'],
extends: [reactHooks.configs.flat.recommended],
+25 -25
View File
@@ -40,29 +40,29 @@
"@radix-ui/react-toggle": "^1.1.18",
"@radix-ui/react-toggle-group": "^1.1.19",
"@radix-ui/react-tooltip": "^1.2.16",
"@scalar/api-reference-react": "^0.9.63",
"@scalar/api-reference-react": "^0.9.67",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-query": "^5.102.8",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.9",
"@tauri-apps/plugin-dialog": "^2.7.2",
"@tauri-apps/plugin-opener": "^2.5.4",
"@tanstack/react-virtual": "^3.14.11",
"@tauri-apps/plugin-dialog": "^2.7.3",
"@tauri-apps/plugin-opener": "^2.5.5",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-updater": "^2.11.0",
"@tauri-apps/plugin-window-state": "^2.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"country-flag-icons": "^1.6.20",
"i18next": "^26.3.6",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.31.0",
"posthog-js": "^1.417.0",
"lucide-react": "^1.43.0",
"posthog-js": "^1.428.11",
"qrcode": "^1.5.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-hot-toast": "^2.6.0",
"react-i18next": "^17.0.11",
"react-window": "^2.3.0",
"react-i18next": "^17.0.13",
"react-window": "^2.3.1",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
@@ -70,24 +70,24 @@
"zustand": "^5.0.15"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@playwright/test": "1.63.0",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/cli": "^2.11.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
"eslint": "^10.8.1",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"eslint": "^10.10.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.11.0",
"jsdom": "^29.1.1",
"knip": "^6.32.2",
"globals": "^17.12.0",
"jsdom": "^30.0.1",
"knip": "^6.35.1",
"oxfmt": "^0.57.0",
"oxlint": "1.71.0",
"playwright-core": "1.62.1",
"playwright-core": "1.63.0",
"typescript": "^6.0.3",
"vite": "^8.2.1",
"vitest": "4.1.9"
"vite": "^8.2.2",
"vitest": "4.1.11"
}
}
+699 -923
View File
File diff suppressed because it is too large Load Diff
+589 -29
View File
@@ -12,7 +12,7 @@ use std::time::Duration;
use tauri::Manager;
use crate::bootstrap::{
BootstrapStage, emit_log, ensure_venv_ready, set_stage,
BootstrapStage, current_attempt, emit_log_for_attempt, ensure_venv_ready, set_stage,
};
use crate::config::load_config;
use crate::tools::{resolve_ffmpeg, resolve_ffprobe};
@@ -416,6 +416,137 @@ pub fn free_port_or_report(port: u16) -> bool {
false
}
/// Who is holding the port, as far as an HTTP request can tell (#1933).
///
/// The port-conflict failure used to assert "already in use by **another
/// application**" without asking. That is wrong in the common case: the holder
/// is usually the user's own orphaned backend from an earlier run, which has
/// no window to quit — so the message sent them to close a copy of VoiceStudio
/// they cannot see, and gave them nothing that would work.
///
/// The identity check already existed (`running_backend_version`, used to
/// decide whether to attach to a healthy same-version backend, a far more
/// consequential decision). It just was not consulted here.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PortHolder {
/// Nothing answered — either genuinely free, or a listener that accepts a
/// connection but does not respond in time.
Unknown,
/// A VoiceStudio backend, carrying the version it reports. Empty string
/// when it predates the `app_version` field.
OurBackend(String),
/// Something answered `/system/info` and it was not us.
Foreign,
}
/// Ask the listener on `port` who it is.
///
/// Stricter than `running_backend_version`, deliberately. That one accepts a
/// `/system/info` body containing `model_checkpoint` or `data_dir` — a
/// substring sniff, which is fine for deciding whether to ATTACH but not for
/// deciding what to tell a user to kill. This answer ends in a message naming
/// a process to end, so it requires the `x-omnivoice-backend` marker header
/// that `backend/main.py` stamps on every response (CodeRabbit). A body alone
/// can be served by anything; the header is what our backend actually asserts,
/// and `startup_progress` already gates on it for the same reason.
pub fn port_holder(port: u16) -> PortHolder {
if !port_in_use(port) {
return PortHolder::Unknown;
}
let url = format!("http://127.0.0.1:{}/system/info", port);
let Ok(resp) = raw_http_get(&url, Duration::from_millis(500)) else {
return PortHolder::Foreign;
};
let head_end = resp.find("
").unwrap_or(resp.len());
if !resp[..head_end].to_ascii_lowercase().contains("x-omnivoice-backend") {
// Something is listening but it does not identify as VoiceStudio.
// That covers a genuinely foreign app AND one of ours too wedged to
// answer — `Foreign` is the conservative reading either way, since it
// is the one that never tells a user to kill what is not theirs.
return PortHolder::Foreign;
}
let body = &resp[resp.find("
").map(|i| i + 4).unwrap_or(0)..];
if !is_omnivoice_body(body) {
return PortHolder::Foreign;
}
PortHolder::OurBackend(parse_app_version(body).unwrap_or_default())
}
/// How to find and end the listener on `port`, for the platform this build
/// runs on. Offered only when the holder identified itself as our own backend.
///
/// Two steps, deliberately, and never a one-liner that pipes a lookup straight
/// into `kill`. `lsof -ti tcp:PORT` matches *connected clients* as well as the
/// listener, and Windows `findstr :3900` matches `:39001` and established
/// connections too — so the convenient one-liner can end a process that merely
/// talks to VoiceStudio, or one that has nothing to do with it. The identity
/// `port_holder` established is a fact about the moment the message was
/// written; by the time the user runs a command it has to be re-established,
/// and only they can do that. So the first command shows exactly one listening
/// process to look at, and the second ends that pid.
fn reclaim_command(port: u16) -> String {
if cfg!(target_os = "windows") {
format!(
"Get-NetTCPConnection -LocalPort {port} -State Listen | \
Select-Object OwningProcess, @{{n='Name';e={{(Get-Process -Id \
$_.OwningProcess).ProcessName}}}}\n\n \
...then, once you have confirmed it is python or omnivoice:\n\n \
Stop-Process -Id <OwningProcess>"
)
} else {
format!(
"lsof -nP -iTCP:{port} -sTCP:LISTEN\n\n \
...then, once you have confirmed the COMMAND is python or \
omnivoice:\n\n kill <PID>"
)
}
}
/// What to tell the user when the port could not be freed.
///
/// Pure, so the three wordings are unit-tested without a listener.
///
/// Every branch must contain a phrase `BootstrapSplash.detectHints` matches
/// ("port … in use"), because that is what turns this English Rust string into
/// the LOCALISED `bootstrap.hint_port` the user actually reads. Pinned by
/// `frontend/src/test/portInUseHint.test.js`.
pub fn port_conflict_message(port: u16, holder: &PortHolder, suffix: &str) -> String {
let body = match holder {
PortHolder::OurBackend(version) if version.is_empty() || same_app_version(version) => {
format!(
"Port {port} is in use by a VoiceStudio backend from an earlier \
session that never shut down. It has no window to quit, so \
closing VoiceStudio will not release it. End it from a \
terminal:\n\n {}",
reclaim_command(port)
)
}
PortHolder::OurBackend(version) => {
format!(
"Port {port} is in use by a VoiceStudio backend from version \
{version}, left running by an earlier install. This build is \
{}, so it cannot use that one. Find and end it from a terminal:\n\n {}",
env!("CARGO_PKG_VERSION"),
reclaim_command(port)
)
}
PortHolder::Foreign | PortHolder::Unknown => format!(
"Port {port} is already in use by another application, and \
VoiceStudio could not free it. Quit whatever is using that port \
and try again."
),
};
if suffix.is_empty() {
body
} else {
format!("{body}\n\n{suffix}")
}
}
/// An HTTP response can justify attaching to a healthy same-version backend,
/// but never grants process ownership. Deliberately refuse orphan cleanup:
/// signalling a PID discovered through lsof/netstat has an unavoidable reuse
@@ -436,7 +567,12 @@ pub fn backend_log_path() -> PathBuf {
// harness gives every scenario its own tempdir through this.
if let Ok(dir) = std::env::var("OMNIVOICE_LOG_DIR") {
if !dir.trim().is_empty() {
let log_dir = PathBuf::from(dir);
// Trim here too, not only in the emptiness test above. The Python
// reader strips this variable before joining (see
// api/routers/system.py::_backend_redirect_log_candidates), so a
// padded value had the writer and the reader looking at different
// directories — the exact divergence #1925 exists to close.
let log_dir = PathBuf::from(dir.trim());
let _ = fs::create_dir_all(&log_dir);
return log_dir.join("backend.log");
}
@@ -499,14 +635,53 @@ pub fn err_log_run_start() -> u64 {
/// This is the reader every death path must use: it cannot see another run's
/// output, so a crash marker carries the dying process's words or nothing.
pub fn read_error_log_tail_for_run(max_lines: usize) -> String {
read_error_log_tail_range(err_log_run_start(), None, max_lines)
}
/// Last N lines of one run's slice, `[start, end)`.
///
/// A death path pins `start` BEFORE it settles the drainers (#1850, Greptile):
/// 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" afterwards would hand the dead process's
/// crash marker the REPLACEMENT's healthy startup — the cross-run attribution
/// #1510 exists to prevent, reintroduced through the wait that fixed the tail.
///
/// `end` closes the other side (CodeRabbit). A start offset with an unbounded
/// end still does not identify ONE run: the replacement writes below the dying
/// run's lines, and a tail reads the last N of the file, so the newer run's
/// startup is exactly what a caller would get. `end` is where the next run's
/// slice begins, or `None` when no run started in the meantime.
pub fn read_error_log_tail_range(start: u64, end: Option<u64>, max_lines: usize) -> String {
let err_path = backend_log_path().with_file_name("backend_err.log");
read_error_log_tail_at(&err_path, err_log_run_start(), max_lines)
read_error_log_slice(&err_path, start, end, max_lines)
}
/// The run that has just died, read as a closed range.
///
/// Call AFTER settling: the end is taken from wherever the current run now
/// begins, which is either still `start` (nothing replaced it) or the
/// replacement's offset (which is exactly where this run's slice ends).
pub fn read_dead_run_tail(start: u64, max_lines: usize) -> String {
let now = err_log_run_start();
let end = if now > start { Some(now) } else { None };
read_error_log_tail_range(start, end, max_lines)
}
/// Tail of `path` starting at byte `start` (whole file when `start` is 0 or
/// no longer valid — an externally replaced/shrunk file must degrade to the
/// old whole-file behaviour, never to a silent empty capture).
fn read_error_log_tail_at(path: &Path, start: u64, max_lines: usize) -> String {
read_error_log_slice(path, start, None, max_lines)
}
/// `read_error_log_tail_at` with the far end closed too.
fn read_error_log_slice(
path: &Path,
start: u64,
end: Option<u64>,
max_lines: usize,
) -> String {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return String::new(),
@@ -517,36 +692,107 @@ fn read_error_log_tail_at(path: &Path, start: u64, max_lines: usize) -> String {
} else {
&content[..]
};
// An end that is not a usable boundary degrades to "the rest of the file",
// matching how an unusable start degrades to the whole file: evidence
// beats precision, and a silent empty capture is the one outcome that
// helps nobody.
let slice = match end.and_then(|e| usize::try_from(e).ok()) {
Some(e) if e >= start && e <= content.len() && content.is_char_boundary(e) => {
&content[..e - start]
}
_ => slice,
};
let lines: Vec<&str> = slice.lines().collect();
let from = lines.len().saturating_sub(max_lines);
lines[from..].join("\n")
}
/// The previous run's stderr-drainer thread. Joined (bounded) before a new
/// spawn records its offset, so a dying run's still-buffered stderr cannot be
/// appended AFTER the new run's start offset and get attributed to the new
/// run. (Full per-child offset binding isn't needed: spawns are serialized by
/// the #1223 spawn-once flow, so the only race left was this buffered tail.)
static ERR_LOG_DRAINER: Mutex<Option<std::thread::JoinHandle<()>>> = Mutex::new(None);
/// Stderr-drainer threads that have not finished writing yet.
///
/// A list, not a slot. Two callers wait on these — a respawn before it records
/// its start offset (#1510) and a crash marker before it captures the tail
/// (#1850) — and either can time out on a wedged drainer (a pipe held open by
/// an orphaned grandchild) while a NEW run installs its own. A single slot
/// loses the timed-out handle at that moment: dropping a JoinHandle detaches
/// the thread, so nothing can ever wait for that run's output again and both
/// guarantees quietly stop holding. Keeping every unfinished handle costs a
/// Vec that is empty in the normal case, since a finished drainer is dropped
/// on the next settle.
static ERR_LOG_DRAINERS: Mutex<Vec<std::thread::JoinHandle<()>>> = Mutex::new(Vec::new());
/// Wait briefly for the previous run's stderr drainer to flush. A wedged
/// drainer (pipe held open by an orphaned grandchild) must not block a
/// respawn forever — after the bound we proceed; the offset then simply
/// includes whatever the old run still manages to write, which degrades to
/// attributing too MUCH to the new run, never to destroying evidence.
fn join_previous_err_drainer(bound: Duration) {
let handle = ERR_LOG_DRAINER.lock().ok().and_then(|mut g| g.take());
if let Some(handle) = handle {
let deadline = std::time::Instant::now() + bound;
while !handle.is_finished() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
}
/// Register a run's stderr drainer so the settles below can wait for it.
fn track_err_drainer(handle: std::thread::JoinHandle<()>) {
if let Ok(mut guard) = ERR_LOG_DRAINERS.lock() {
guard.push(handle);
}
}
/// Serializes a whole settlement. `settle_err_log` takes the handles out of
/// the list and then waits without holding that lock, so two callers could
/// otherwise interleave: the second finds an empty list, concludes there is
/// nothing to wait for, and reads the log while the first is still waiting for
/// exactly the drainer it needs (CodeRabbit). One settlement at a time makes a
/// caller that returns a caller for whom the waiting is genuinely done.
static ERR_LOG_SETTLE_LOCK: Mutex<()> = Mutex::new(());
/// Wait briefly for every outstanding stderr drainer to finish writing.
///
/// Two callers, one guarantee: everything a run wrote is on disk before anyone
/// reads it.
///
/// - Before a respawn takes its start offset, so a dying run's buffered tail
/// cannot be appended past that offset and get attributed to the new run
/// (#1510).
/// - Before a crash marker captures `last_stderr` (#1850). `wait()` returns the
/// moment the child exits, but the drainer is a separate thread reading a
/// pipe: its last lines — the traceback naming the cause — can still be in
/// flight. Reading the file at that instant captures a tail that stops BEFORE
/// the death, which is how a crash report arrives with a log ending a minute
/// early and nothing to diagnose.
///
/// A wedged drainer must not block forever. After the bound we proceed, and
/// every handle still running is kept for the next caller to wait on. For a
/// respawn that degrades to attributing too MUCH to the new run; for a marker,
/// to a short tail. Never to destroyed evidence, and never to a detached
/// thread nobody can wait for again.
pub fn settle_err_log(bound: Duration) {
let _settling = ERR_LOG_SETTLE_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let pending: Vec<_> = match ERR_LOG_DRAINERS.lock() {
Ok(mut guard) => guard.drain(..).collect(),
Err(_) => return,
};
if pending.is_empty() {
return;
}
let deadline = std::time::Instant::now() + bound;
while std::time::Instant::now() < deadline && pending.iter().any(|h| !h.is_finished()) {
std::thread::sleep(Duration::from_millis(20));
}
let mut still_running = Vec::new();
for handle in pending {
if handle.is_finished() {
let _ = handle.join();
} else {
still_running.push(handle);
}
}
if !still_running.is_empty() {
if let Ok(mut guard) = ERR_LOG_DRAINERS.lock() {
// Put them back at the FRONT: they are older than anything a
// concurrent spawn pushed while this was waiting.
still_running.append(&mut guard);
*guard = still_running;
}
}
}
/// How long a death path waits for the dying run's final stderr. Bounded so a
/// wedged pipe cannot stall crash recording, generous enough to cover a
/// traceback already sitting in the drainer's buffer.
pub const ERR_LOG_SETTLE: Duration = Duration::from_secs(2);
/// Open backend_err.log for a new run: append-only (a respawn must not
/// destroy the previous run's evidence), rotated when oversized, with the
/// run's start offset returned for `ERR_LOG_RUN_START`.
@@ -730,7 +976,7 @@ pub(crate) fn spawn_backend<R: tauri::Runtime>(
// Append + per-run offset, never truncate: the previous run's stderr is
// crash evidence until someone reads it (#1510). Flush the previous
// drainer first so old buffered lines land BEFORE this run's offset.
join_previous_err_drainer(Duration::from_secs(2));
settle_err_log(Duration::from_secs(2));
let (err_log_file, err_log_start) = open_err_log_for_run(&err_path);
ERR_LOG_RUN_START.store(err_log_start, std::sync::atomic::Ordering::SeqCst);
if let Some(ref f) = err_log_file {
@@ -882,6 +1128,11 @@ pub(crate) fn spawn_backend<R: tauri::Runtime>(
}
};
// The attempt these pumps drain, captured up front: the threads outlive
// the run, and a restart must not relabel its trailing output as the new
// attempt's evidence (#1900).
let pump_attempt = current_attempt();
if let Some(stdout_pipe) = contained.child.stdout.take() {
let app_clone = app.clone();
let mut out_file = stdout_file;
@@ -890,7 +1141,7 @@ pub(crate) fn spawn_backend<R: tauri::Runtime>(
let reader = BufReader::new(stdout_pipe);
for line in reader.lines().flatten() {
log::info!("[backend_stdout] {}", line);
emit_log(&app_clone, "starting_backend", &line);
emit_log_for_attempt(&app_clone, pump_attempt, "starting_backend", &line);
if let Some(ref mut f) = out_file {
let _ = writeln!(f, "{}", line);
}
@@ -908,15 +1159,13 @@ pub(crate) fn spawn_backend<R: tauri::Runtime>(
let mut log_file = err_log_file;
for line in reader.lines().flatten() {
log::info!("[backend_stderr] {}", line);
emit_log(&app_clone, "starting_backend", &line);
emit_log_for_attempt(&app_clone, pump_attempt, "starting_backend", &line);
if let Some(ref mut f) = log_file {
let _ = writeln!(f, "{}", line);
}
}
});
if let Ok(mut guard) = ERR_LOG_DRAINER.lock() {
*guard = Some(drainer);
}
track_err_drainer(drainer);
}
Some(contained)
@@ -938,6 +1187,11 @@ mod tests {
/// other's toes (cargo runs tests in threads by default).
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// `ERR_LOG_DRAINERS` is one process-global list, and several tests push
/// a handle onto it. Without this they race: one test's settle drains and
/// joins another's thread, and both assert on state they no longer own.
static DRAINER_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn a_baked_token_reaches_the_spawned_backend() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
@@ -1464,6 +1718,7 @@ mod tests {
#[test]
fn a_dying_runs_buffered_stderr_flushes_before_the_next_offset() {
let _g = DRAINER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("backend_err.log");
@@ -1476,10 +1731,10 @@ mod tests {
let mut f = fs::OpenOptions::new().append(true).open(&p).unwrap();
writeln!(f, "run1: buffered last words").unwrap();
});
*ERR_LOG_DRAINER.lock().unwrap() = Some(late);
track_err_drainer(late);
// …must land BEFORE the next run records where its output begins.
join_previous_err_drainer(Duration::from_secs(2));
settle_err_log(Duration::from_secs(2));
let (_file, start) = open_err_log_for_run(&path);
let run2 = read_error_log_tail_at(&path, start, 10);
assert!(
@@ -1522,4 +1777,309 @@ mod tests {
"old evidence must survive rotation in the sibling file"
);
}
// ── #1933: name who actually holds the port ───────────────────────────
#[test]
fn our_own_orphan_is_not_reported_as_another_application() {
// The report that opened #1933: the holder was the user's own backend
// from an earlier run, and the message told them to quit "another
// application" — then a copy of VoiceStudio with no window. Nothing in
// it would have worked.
let msg = port_conflict_message(
3900,
&PortHolder::OurBackend(env!("CARGO_PKG_VERSION").to_string()),
"",
);
assert!(msg.contains("VoiceStudio backend from an earlier session"), "{msg}");
assert!(!msg.contains("another application"), "{msg}");
// And a way out, not just a diagnosis.
assert!(msg.contains("terminal"), "{msg}");
}
#[test]
fn a_backend_from_another_version_is_named() {
let msg = port_conflict_message(3900, &PortHolder::OurBackend("0.1.0".into()), "");
assert!(msg.contains("0.1.0"), "the stale version is what identifies it: {msg}");
assert!(msg.contains(env!("CARGO_PKG_VERSION")), "{msg}");
}
#[test]
fn an_unidentified_listener_keeps_the_conservative_wording() {
// Never tell a user to go kill a process that may not be theirs.
for holder in [PortHolder::Foreign, PortHolder::Unknown] {
let msg = port_conflict_message(3900, &holder, "");
assert!(msg.contains("another application"), "{msg}");
assert!(!msg.contains("lsof"), "{msg}");
assert!(!msg.contains("Get-NetTCPConnection"), "{msg}");
assert!(!msg.contains("kill"), "{msg}");
}
}
/// A one-shot loopback responder: serves `response` verbatim to the first
/// connection, then stops. Enough to answer one `/system/info` probe.
fn serve_once(response: String) -> u16 {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for stream in listener.incoming().take(2) {
let Ok(mut stream) = stream else { continue };
let mut scratch = [0u8; 1024];
let _ = stream.read(&mut scratch);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
});
port
}
#[test]
fn a_body_that_merely_looks_like_ours_is_not_treated_as_ours() {
// CodeRabbit: running_backend_version accepts a body containing
// "model_checkpoint" or "data_dir" — a substring sniff. Fine for
// deciding whether to ATTACH; not fine here, where the answer ends in
// a message naming a process for the user to kill. Anything can serve
// that body. Only our backend stamps x-omnivoice-backend.
let body = r#"{"model_checkpoint": "x", "data_dir": "/tmp", "app_version": "9.9.9"}"#;
let port = serve_once(format!(
"HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: {}
{body}",
body.len()
));
assert_eq!(
port_holder(port),
PortHolder::Foreign,
"an unmarked responder must never be named as our backend"
);
let msg = port_conflict_message(port, &port_holder(port), "");
assert!(!msg.contains("lsof"), "{msg}");
assert!(!msg.contains("Get-NetTCPConnection"), "{msg}");
}
#[test]
fn the_marker_header_is_what_identifies_our_backend() {
let body = r#"{"model_checkpoint": "x", "data_dir": "/tmp", "app_version": "9.9.9"}"#;
let port = serve_once(format!(
"HTTP/1.1 200 OK
x-omnivoice-backend: 9.9.9
Content-Length: {}
{body}",
body.len()
));
assert_eq!(port_holder(port), PortHolder::OurBackend("9.9.9".into()));
}
#[test]
fn the_reclaim_guidance_never_pipes_a_lookup_into_kill() {
// Greptile, security: the convenient one-liner does not preserve the
// identity `port_holder` established. `lsof -ti tcp:3900 | xargs kill`
// matches CONNECTED CLIENTS as well as the listener, and Windows
// `findstr :3900` matches `:39001` and established connections — so a
// user following it can end a process that merely talks to
// VoiceStudio, or one unrelated to it. The lookup has to be shown for
// a human to check before anything is signalled.
let guidance = reclaim_command(3900);
assert!(
!guidance.contains("| xargs kill") && !guidance.contains("|xargs kill"),
"a lookup piped straight into kill can end a process nobody identified: {guidance}"
);
// The listener, not every socket on the port.
if cfg!(target_os = "windows") {
assert!(guidance.contains("-State Listen"), "{guidance}");
} else {
assert!(guidance.contains("-sTCP:LISTEN"), "{guidance}");
}
// And a step where the user confirms what they found.
assert!(guidance.contains("confirmed"), "{guidance}");
}
#[test]
fn every_wording_still_triggers_the_localised_port_hint() {
// detectHints matches /port.*in use/i to swap this English string for
// the translated bootstrap.hint_port. An earlier draft of one of these
// said "is held by" and silently dropped the translation.
for holder in [
PortHolder::OurBackend(env!("CARGO_PKG_VERSION").to_string()),
PortHolder::OurBackend("0.1.0".into()),
PortHolder::Foreign,
PortHolder::Unknown,
] {
let msg = port_conflict_message(3900, &holder, "").to_lowercase();
let port_at = msg.find("port").expect("no 'port' in the message");
assert!(
msg[port_at..].contains("in use"),
"detectHints will not match this, so the user loses the translated hint: {msg}"
);
}
}
#[test]
fn a_caller_suffix_is_appended_not_substituted() {
let msg = port_conflict_message(3900, &PortHolder::Foreign, "so the backend can't restart.");
assert!(msg.contains("another application"), "{msg}");
assert!(msg.ends_with("so the backend can't restart."), "{msg}");
}
#[test]
fn a_crash_tail_waits_for_the_dying_runs_last_words() {
let _g = DRAINER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// #1850: `wait()` returns the moment the child exits, but the stderr
// drainer is a separate thread still reading the pipe. Capturing
// `last_stderr` at that instant produced a crash report whose log
// stopped a minute before the death — the traceback that named the
// cause never made it into the file in time.
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("backend_err.log");
fs::write(&path, "steady state\n").unwrap();
let p = path.clone();
let dying = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(120));
let mut f = fs::OpenOptions::new().append(true).open(&p).unwrap();
writeln!(f, "Traceback (most recent call last):").unwrap();
writeln!(f, "RuntimeError: the actual cause").unwrap();
});
track_err_drainer(dying);
// Without the settle this reads "steady state" and nothing else.
settle_err_log(ERR_LOG_SETTLE);
let tail = read_error_log_tail_at(&path, 0, 30);
assert!(
tail.contains("RuntimeError: the actual cause"),
"the crash marker captured a tail that predates the death: {tail:?}"
);
}
#[test]
fn a_pinned_offset_survives_a_respawn_during_the_settle() {
// Greptile on #1994: settling can take up to two seconds, and a Retry
// 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 hand the dead process's crash marker the REPLACEMENT's healthy
// startup — the cross-run attribution #1510 exists to prevent,
// reintroduced through the wait that fixes the tail. Death paths pin
// the offset first, so the slice is the dying run's either way.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("backend_err.log");
fs::write(&path, "run1: RuntimeError: the actual cause
").unwrap();
let pinned = 0u64;
// A respawn lands mid-settle and the new run starts after that line.
let moved_on = fs::metadata(&path).unwrap().len();
fs::write(
&path,
"run1: RuntimeError: the actual cause
run2: healthy startup
",
)
.unwrap();
assert!(
read_error_log_tail_at(&path, pinned, 30).contains("the actual cause"),
"the pinned offset must still name the dying run's slice"
);
assert!(
!read_error_log_tail_at(&path, moved_on, 30).contains("the actual cause"),
"sanity: reading from the new run's offset really does lose it"
);
}
#[test]
fn a_dead_runs_slice_stops_where_the_replacement_begins() {
// CodeRabbit: a start offset with an unbounded end does not identify
// ONE run. The replacement writes BELOW the dying run's 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.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("backend_err.log");
let run1 = "run1: RuntimeError: the actual cause
";
fs::write(&path, run1).unwrap();
let boundary = run1.len() as u64;
fs::write(&path, format!("{run1}run2: healthy startup
run2: listening
")).unwrap();
let closed = read_error_log_slice(&path, 0, Some(boundary), 30);
assert!(closed.contains("the actual cause"), "{closed}");
assert!(!closed.contains("run2"), "the replacement's output leaked in: {closed}");
// Unbounded, the tail is the replacement — the bug this closes.
let open = read_error_log_slice(&path, 0, None, 2);
assert!(open.contains("run2"), "sanity: an open end really does read the newer run");
}
#[test]
fn an_unusable_end_degrades_to_the_rest_of_the_file() {
// Same principle as an unusable start: evidence beats precision, and a
// silent empty capture is the one outcome that helps nobody.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("backend_err.log");
fs::write(&path, "only line
").unwrap();
// Past EOF, and an end that sits before the start — both mean the
// caller cannot pin the far side, so the slice runs to the end of the
// file. The start is honoured independently either way.
assert_eq!(read_error_log_slice(&path, 0, Some(10_000), 10), "only line");
assert_eq!(read_error_log_slice(&path, 5, Some(1), 10), "line");
}
#[test]
fn a_wedged_drainer_is_handed_back_rather_than_detached() {
let _g = DRAINER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// The bound exists so an orphaned grandchild holding the pipe cannot
// stall crash recording. But giving up must not drop the handle:
// every later caller would then have nothing to wait on, and the
// respawn-offset guarantee (#1510) would quietly stop holding.
let (tx, rx) = std::sync::mpsc::channel::<()>();
let wedged = std::thread::spawn(move || {
let _ = rx.recv();
});
track_err_drainer(wedged);
settle_err_log(Duration::from_millis(60));
assert!(
!ERR_LOG_DRAINERS.lock().unwrap().is_empty(),
"a drainer that outlived the bound was detached instead of retained"
);
// CodeRabbit: a NEW run installing its own drainer while the wait was
// timing out must not evict the old one. A single slot dropped it here,
// which detaches the thread — nothing could wait for that run's output
// again, and both the #1510 and #1850 guarantees silently stopped
// holding.
let (tx2, rx2) = std::sync::mpsc::channel::<()>();
track_err_drainer(std::thread::spawn(move || {
let _ = rx2.recv();
}));
settle_err_log(Duration::from_millis(60));
assert_eq!(
ERR_LOG_DRAINERS.lock().unwrap().len(),
2,
"an unfinished drainer was dropped when another run installed one"
);
let _ = tx.send(());
let _ = tx2.send(());
for handle in ERR_LOG_DRAINERS.lock().unwrap().drain(..) {
let _ = handle.join();
}
}
}
+477 -66
View File
@@ -48,6 +48,51 @@ pub struct BootstrapState {
pub logs: Arc<Mutex<Vec<LogPayload>>>,
}
/// Which bootstrap attempt the current stage and log lines belong to (#1900).
///
/// The splash used to *infer* attempt boundaries from the ~1 s status poll:
/// arriving at `checking`/`awaiting_setup`, or leaving `failed`, meant a new
/// attempt had begun. Inference from a sampled signal cannot be airtight — a
/// restart begun on this side, which the UI did not initiate, can be sampled
/// up to a full interval late, and the new attempt's earliest stage-tagged log
/// lines are then discarded as the previous attempt's. The consequence was
/// cosmetic and deliberately conservative (a fast stage shows *pending* though
/// it ran), but it was a guess.
///
/// The producer knows the answer exactly, so it says so: every
/// `bootstrap_status` reply and every `bootstrap-log` line carries the attempt
/// it belongs to, and the frontend scopes evidence by equality, not by clock.
///
/// **Guarded by the stage mutex.** Every write happens while that lock is held
/// (`begin_attempt_with`), and `bootstrap_status` reads stage and attempt under
/// it. Without that discipline a restart landing between the two reads returns
/// the PREVIOUS attempt's stage stamped with the new attempt's id — and the
/// splash records `installing_deps` as work this attempt did, which is exactly
/// the #1894 fabrication the attempt id exists to remove (Greptile).
///
/// Starts at 1 so 0 is never a live attempt — a payload carrying no attempt at
/// all deserializes to 0 and stays distinguishable from the first one.
static ATTEMPT: AtomicU64 = AtomicU64::new(1);
/// The attempt now in progress. Read without the stage lock by log emission,
/// which only needs the current value, never a pair.
pub fn current_attempt() -> u64 {
ATTEMPT.load(Ordering::SeqCst)
}
/// Open a new attempt and move the stage into it, atomically.
///
/// Called wherever the bootstrap really restarts: both retry commands funnel
/// through `respawn_backend`, and the supervisor's automatic venv rebuild
/// re-enters `Checking` on its own. Taking the stage lock across both writes is
/// what makes the pair a reader observes always self-consistent.
pub fn begin_attempt_with(stage: &Arc<Mutex<BootstrapStage>>, next: BootstrapStage) -> u64 {
let mut guard = stage.lock().unwrap_or_else(|e| e.into_inner());
let id = ATTEMPT.fetch_add(1, Ordering::SeqCst) + 1;
*guard = next;
id
}
/// The last `Failed { message }` diagnosis this session, retained after the
/// stage itself has moved on (#1177).
///
@@ -115,18 +160,89 @@ pub fn already_diagnosed(state: &Arc<Mutex<BootstrapStage>>) -> bool {
#[derive(Clone, Serialize)]
pub struct LogPayload {
/// The attempt this line was produced during (#1900). A stage-tagged line
/// proves its stage ran — but only for the attempt that emitted it.
pub attempt: u64,
pub stage: String,
pub line: String,
}
/// Where the first-run log is kept so it outlives the splash.
///
/// 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
/// pause and nowhere to retrieve it (#1847). A user who wanted to check what
/// had just been installed, or hand it to a bug report, had nothing.
///
/// Sits beside backend.log so everything about a run is in one directory.
fn bootstrap_log_path() -> PathBuf {
crate::backend::backend_log_path().with_file_name("bootstrap.log")
}
/// Truncate once per process, then append.
///
/// A bootstrap is a single episode, and the interesting question is always
/// "what happened THIS time" — an ever-growing file would bury that and grow
/// without bound across retries. Truncating on the first write of the process
/// keeps it to the current run without needing a hook on every restart path.
static BOOTSTRAP_LOG_STARTED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
fn append_bootstrap_log(stage: &str, line: &str) {
use std::io::Write;
let path = bootstrap_log_path();
let fresh = BOOTSTRAP_LOG_STARTED.set(()).is_ok();
let opened = fs::OpenOptions::new()
.create(true)
.write(true)
.append(!fresh)
.truncate(fresh)
.open(&path);
// Best effort throughout: a log we cannot write must never take the
// bootstrap down with it.
if let Ok(mut file) = opened {
let _ = writeln!(file, "[{stage}] {line}");
}
}
/// Emit a stage-tagged log line for the attempt in progress.
///
/// Correct for every caller that runs inside the attempt it is describing —
/// which is all of them except the output pumps, whose thread outlives the run
/// it is draining. Those use [`emit_log_for_attempt`].
pub fn emit_log<R: tauri::Runtime>(app: &tauri::AppHandle<R>, stage: &str, line: &str) {
let payload = LogPayload { stage: stage.to_string(), line: line.to_string() };
emit_log_for_attempt(app, current_attempt(), stage, line)
}
/// Emit a log line stamped with the attempt that PRODUCED it, not the one
/// running when it happened to be read (#1900, CodeRabbit).
///
/// An output pump is a thread reading a pipe: it lives as long as the process
/// it drains, which can outlive the attempt that started it. A restart bumps
/// the counter, and the dying run's remaining lines — read a moment later —
/// would be stamped with the NEW attempt and counted as its evidence. The pump
/// captures its attempt when it starts, so a line is labelled by the work that
/// wrote it.
pub fn emit_log_for_attempt<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
attempt: u64,
stage: &str,
line: &str,
) {
let payload = LogPayload {
attempt,
stage: stage.to_string(),
line: line.to_string(),
};
// Buffer the log so the frontend can backfill on mount.
if let Some(state) = app.try_state::<BootstrapState>() {
if let Ok(mut logs) = state.logs.lock() {
logs.push(payload.clone());
}
}
// Persist before emitting: the in-memory buffer and the event both die
// with the splash, the file does not.
append_bootstrap_log(stage, line);
let _ = app.emit("bootstrap-log", payload);
}
@@ -157,11 +273,15 @@ pub fn run_streaming<R: tauri::Runtime>(
let app_err = app.clone();
let stage_out = stage.to_string();
let stage_err = stage.to_string();
// The attempt these pumps are draining, captured before either can outlive
// it: a restart during a long `uv sync` must not relabel this run's
// remaining output as the next attempt's work.
let attempt = current_attempt();
let h_out = std::thread::spawn(move || {
if let Some(s) = stdout {
for line in BufReader::new(s).lines().flatten() {
log::info!("[{}] {}", stage_out, line);
emit_log(&app_out, &stage_out, &line);
emit_log_for_attempt(&app_out, attempt, &stage_out, &line);
}
}
});
@@ -169,7 +289,7 @@ pub fn run_streaming<R: tauri::Runtime>(
if let Some(s) = stderr {
for line in BufReader::new(s).lines().flatten() {
log::info!("[{}] {}", stage_err, line);
emit_log(&app_err, &stage_err, &line);
emit_log_for_attempt(&app_err, attempt, &stage_err, &line);
}
}
});
@@ -201,13 +321,32 @@ pub fn run_streaming<R: tauri::Runtime>(
// ── Tauri commands ────────────────────────────────────────────────────────
/// A stage together with the attempt that produced it (#1900).
///
/// `BootstrapStage` is internally tagged, so flattening it here keeps the wire
/// shape the frontend already reads — `{ "stage": "checking" }` plus whatever
/// fields the variant carries — and only adds a sibling `attempt`.
#[derive(Clone, Serialize, Debug)]
pub struct BootstrapStatus {
pub attempt: u64,
#[serde(flatten)]
pub stage: BootstrapStage,
}
#[tauri::command]
pub fn bootstrap_status(state: tauri::State<'_, BootstrapState>) -> BootstrapStage {
state
.stage
.lock()
.map(|g| g.clone())
.unwrap_or(BootstrapStage::Checking)
pub fn bootstrap_status(state: tauri::State<'_, BootstrapState>) -> BootstrapStatus {
// Both reads under the stage lock, which every attempt bump also holds
// (`begin_attempt_with`). Sampling them separately lets a restart land in
// between and return the PREVIOUS attempt's stage stamped with the new
// attempt's id — the splash then records `installing_deps` as work this
// attempt did, which is the fabrication the id exists to remove.
match state.stage.lock() {
Ok(guard) => BootstrapStatus { attempt: current_attempt(), stage: guard.clone() },
Err(poisoned) => BootstrapStatus {
attempt: current_attempt(),
stage: poisoned.into_inner().clone(),
},
}
}
#[tauri::command]
@@ -268,9 +407,9 @@ pub fn respawn_backend<R: tauri::Runtime>(
// Before anything reaches for lifecycle ownership: a readiness wait may be
// holding it while a slow backend starts (#1791).
preempt_backend_wait();
if let Ok(mut guard) = stage.lock() {
*guard = BootstrapStage::Checking;
}
// One lock across the bump and the stage write, so no poll can observe the
// old stage paired with the new attempt.
begin_attempt_with(&stage, BootstrapStage::Checking);
if let Ok(mut logs) = logs.lock() {
logs.clear();
}
@@ -341,6 +480,41 @@ struct BackendStopError {
restart_safe: bool,
}
/// Record a deliberate stop before the backend is force-terminated.
///
/// On Windows the shell terminates the 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 `run_sentinel.clear_sentinel()`
/// never executes. Every deliberate quit therefore came back on the next
/// launch as "The backend did not shut down cleanly last run — it likely
/// crashed or was killed" (#1898). The backend-side fix in #1895 only helps
/// platforms where teardown actually begins.
///
/// The process about to be killed cannot record its own intent, so the shell
/// records it: retire the sentinel here, immediately before terminating.
/// Anything that dies WITHOUT passing through this 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 is read 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 this change, never worse.
fn retire_run_sentinel_at(dir: &std::path::Path) {
let path = dir.join("run_sentinel.json");
match std::fs::remove_file(&path) {
Ok(()) => log::info!("Retired run sentinel for a deliberate stop: {}", path.display()),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => log::warn!("Could not retire {}: {error}", path.display()),
}
}
fn retire_run_sentinel(port: u16) {
match crate::backend::backend_data_dir(port) {
Some(dir) => retire_run_sentinel_at(std::path::Path::new(&dir)),
None => log::debug!("No advertised data dir; leaving run_sentinel.json in place"),
}
}
fn stop_backend_locked<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
) -> Result<(), BackendStopError> {
@@ -385,6 +559,9 @@ fn stop_backend_locked<R: tauri::Runtime>(
// The unreaped root/process handle and containment handle move
// together, closing PID-reuse and post-crash descendant races.
log::info!("Stopping tracked backend tree (root pid {})", child.id());
// Before the kill, not after: on Windows the child gets no
// chance to clear this itself.
retire_run_sentinel(backend_port());
crate::tools::terminate_process_tree(child, tree, Duration::from_secs(2)).err()
}
(None, None) => None,
@@ -414,13 +591,10 @@ fn stop_backend_locked<R: tauri::Runtime>(
if crate::backend::port_in_use(backend_port())
&& !crate::backend::free_port_or_report(backend_port())
{
// Ask who holds it before saying who holds it (#1933).
let holder = crate::backend::port_holder(backend_port());
return Err(BackendStopError {
message: format!(
"Port {} is already in use by another application, and VoiceStudio \
could not free it. Quit whatever is using that port (another copy \
of VoiceStudio, or an app that claimed it) and try again.",
backend_port()
),
message: crate::backend::port_conflict_message(backend_port(), &holder, ""),
restart_safe: false,
});
}
@@ -752,7 +926,14 @@ fn spawn_backend_until_ready<R: tauri::Runtime>(
}
};
if let Some((exit_info, real_exit)) = process_dead {
let err_tail = crate::backend::read_error_log_tail_for_run(30);
// Pin the dying run's slice BEFORE waiting for its drainer: a
// Retry arriving during the settle installs a new run and moves
// the current-run offset past this output (#1850, Greptile).
let run_start = crate::backend::err_log_run_start();
// The child is gone; its last stderr may still be in the
// drainer. Let it land before anything reads the tail (#1850).
crate::backend::settle_err_log(crate::backend::ERR_LOG_SETTLE);
let err_tail = crate::backend::read_dead_run_tail(run_start, 30);
// #941: persist the forensics for every true process death —
// startup crashes included — unless the app is shutting down
// or a retry flow deliberately killed the child.
@@ -764,7 +945,10 @@ fn spawn_backend_until_ready<R: tauri::Runtime>(
crate::crash::record_crash(crate::crash::marker_now(
exit,
backend_uptime_s(app),
crate::backend::read_error_log_tail_for_run(CRASH_STDERR_TAIL_LINES),
crate::backend::read_dead_run_tail(
run_start,
CRASH_STDERR_TAIL_LINES,
),
));
}
}
@@ -796,7 +980,11 @@ fn spawn_backend_until_ready<R: tauri::Runtime>(
"Backend failed because the Python environment is broken — rebuilding it automatically",
);
if quarantine_broken_venv(&venv_dir) {
set_stage(stage_handle, BootstrapStage::Checking);
// A restart nobody clicked. Rebuilding the venv
// re-runs the whole bootstrap, so it opens a new
// attempt and says so, instead of leaving the
// splash to infer the boundary from the poll.
begin_attempt_with(stage_handle, BootstrapStage::Checking);
continue 'bootstrap;
}
log::error!(
@@ -847,13 +1035,14 @@ fn spawn_backend_until_ready<R: tauri::Runtime>(
.and_then(|e| e.code)
.is_some_and(|c| c == crate::backend::EXIT_PORT_IN_USE)
{
format!(
"Port {} is already in use, so the backend could not \
start. Another copy of VoiceStudio or an app that \
claimed that port is holding it. Quit it and try \
again; if nothing is visibly running, an orphaned \
backend from a previous session still has the port.",
backend_port()
// Same question, same answer as the other two sites
// (#1933): the holder is usually the user's own orphan,
// and "another copy of VoiceStudio" is not something they
// can quit.
crate::backend::port_conflict_message(
backend_port(),
&crate::backend::port_holder(backend_port()),
"",
)
} else if err_tail.is_empty() {
format!("Backend process exited ({}) — no error output captured", exit_info)
@@ -1365,6 +1554,11 @@ fn supervise_backend<R: tauri::Runtime>(
attachment_lifecycle = Some(lifecycle);
}
let exit_info = exit.description.clone();
// Same as the startup path: pin this run's slice, then wait for its
// drainer. `wait()` beat the drainer to the punch, and a Retry during
// the wait would otherwise move the offset onto the replacement run.
let run_start = crate::backend::err_log_run_start();
crate::backend::settle_err_log(crate::backend::ERR_LOG_SETTLE);
// #941: make the death self-documenting BEFORE any restart attempt —
// the marker (exit code/signal + stderr tail + uptime) is what turns
// the next "Can't reach the backend" report into a diagnosable one.
@@ -1379,7 +1573,7 @@ fn supervise_backend<R: tauri::Runtime>(
crate::crash::record_crash(crate::crash::marker_now(
&exit,
uptime_s,
crate::backend::read_error_log_tail_for_run(CRASH_STDERR_TAIL_LINES),
crate::backend::read_dead_run_tail(run_start, CRASH_STDERR_TAIL_LINES),
));
} else {
log::info!(
@@ -1387,7 +1581,7 @@ fn supervise_backend<R: tauri::Runtime>(
);
}
if restart_budget_exhausted(&mut restart_times, Instant::now()) {
let tail = crate::backend::read_error_log_tail_for_run(30);
let tail = crate::backend::read_dead_run_tail(run_start, 30);
let msg = format!(
"The backend kept {} ({} times in {} min; last stop: {}) and couldn't \
be kept running. Use Clean & Retry, or check Settings Logs Backend.{}",
@@ -1488,21 +1682,17 @@ fn supervise_backend<R: tauri::Runtime>(
if crate::backend::port_in_use(backend_port())
&& !crate::backend::free_port_or_report(backend_port())
{
// Who is actually holding it decides the wording (#1933). The
// hint-matching contract lives with the message builder, which is
// unit-tested for it.
let holder = crate::backend::port_holder(backend_port());
set_stage(
stage_handle,
BootstrapStage::Failed {
// Wording note: every one of these must contain a phrase
// `BootstrapSplash.detectHints` matches ("port … in use"),
// because that is what turns an English Rust message into
// the LOCALISED `bootstrap.hint_port` the user actually
// reads. Pinned in frontend/src/test/portInUseHint.test.js
// — an earlier draft of this one said "is held by" and
// silently lost the translated guidance.
message: format!(
"Port {} is still in use by another application and \
VoiceStudio could not free it, so the backend can't \
restart. Quit whatever is using that port and relaunch.",
backend_port()
message: crate::backend::port_conflict_message(
backend_port(),
&holder,
"The backend cannot restart until that port is free.",
),
},
);
@@ -1909,16 +2099,54 @@ fn apply_uv_http_env(cmd: &mut Command) {
.env("UV_HTTP_RETRIES", "5");
}
/// Default Aliyun PyPI simple index for the `china` region preset.
const CHINA_PYPI_INDEX: &str = "https://mirrors.aliyun.com/pypi/simple/";
/// Resolve the PyPI simple-index URL for `uv` / `uv pip` subprocesses.
/// Explicit setup-screen override wins; otherwise the `china` region preset
/// points at Aliyun. Other regions leave the index unset (uv's default PyPI).
fn resolve_pypi_index_url(region: &str, override_url: Option<&str>) -> Option<String> {
if let Some(url) = override_url.map(str::trim).filter(|u| !u.is_empty()) {
return Some(url.to_string());
}
if region == "china" {
return Some(CHINA_PYPI_INDEX.to_string());
}
None
}
/// Apply `UV_INDEX_URL` when a custom or region-preset PyPI mirror is active.
/// Must run for *every* `uv` path that may fetch packages — including the
/// repair sync. Omitting it there left China-region installs hitting
/// `pypi.org` for build backends (e.g. hatchling) and failing with
/// `tls handshake eof` while the UI already showed the China (mirror) region.
fn apply_pypi_index_env<R: tauri::Runtime>(app: &tauri::AppHandle<R>, cmd: &mut Command) {
let cfg = crate::config::load_config(app);
let region = get_effective_region(app);
// Clear any ambient value first. Without this a uv call inherits the
// parent process's UV_INDEX_URL whenever resolve_pypi_index_url returns
// None, so a stale mirror set in the developer's shell silently outranks
// the region the user actually chose.
cmd.env_remove("UV_INDEX_URL");
if let Some(url) = resolve_pypi_index_url(&region, cfg.mirrors.pypi_index.as_deref()) {
cmd.env("UV_INDEX_URL", url);
}
}
/// The one env applicator every `uv` invocation must go through: HTTP
/// resilience (above) + volume co-location. The latter pins UV_CACHE_DIR /
/// UV_PYTHON_INSTALL_DIR under the env root when the install is rooted on a
/// different volume than uv's default cache (D:-drive installs / portable
/// mode) — otherwise every wheel is downloaded+unpacked on the system drive
/// and then cross-volume *copied* into the venv, silently requiring the full
/// install size on C: and ENOSPC-ing installs the user deliberately pointed
/// at another drive. See `setup::uv_env_overrides_for` for the exact rules.
/// resilience (above) + volume co-location + PyPI mirror. The latter pins
/// UV_CACHE_DIR / UV_PYTHON_INSTALL_DIR under the env root when the install
/// is rooted on a different volume than uv's default cache (D:-drive
/// installs / portable mode) — otherwise every wheel is downloaded+unpacked
/// on the system drive and then cross-volume *copied* into the venv,
/// silently requiring the full install size on C: and ENOSPC-ing installs
/// the user deliberately pointed at another drive. See
/// `setup::uv_env_overrides_for` for the exact rules. PyPI index goes here
/// so first-run, drift, repair, and targeted `uv pip` repairs all honor
/// the China / custom mirror — not only the happy-path sync.
fn apply_uv_env<R: tauri::Runtime>(app: &tauri::AppHandle<R>, cmd: &mut Command) {
apply_uv_http_env(cmd);
apply_pypi_index_env(app, cmd);
for (k, v) in crate::setup::uv_env_overrides(app) {
cmd.env(k, v);
}
@@ -2775,13 +3003,8 @@ creating the new environment at an ASCII-safe path instead (#1783)"
Ok(uv_path) => {
let mut drift_cmd = Command::new(&uv_path);
scrub_python_env(&mut drift_cmd); // #144
// apply_uv_env sets UV_INDEX_URL for china / custom mirrors
apply_uv_env(app, &mut drift_cmd);
let user_cfg = crate::config::load_config(app);
if let Some(pypi) = user_cfg.mirrors.pypi_index.as_deref() {
drift_cmd.env("UV_INDEX_URL", pypi);
} else if get_effective_region(app) == "china" {
drift_cmd.env("UV_INDEX_URL", "https://mirrors.aliyun.com/pypi/simple/");
}
drift_cmd
.args(DRIFT_SYNC_ARGS)
.current_dir(&project_dir);
@@ -3132,12 +3355,7 @@ the existing venv; newly added dependencies may be missing (#307)",
.args(["sync", "--no-dev", "--verbose"])
.current_dir(&project_dir);
}
// PyPI index precedence: explicit setup-screen mirror > region preset.
if let Some(pypi) = custom_mirrors.pypi_index.as_deref() {
sync_cmd.env("UV_INDEX_URL", pypi);
} else if get_effective_region(app) == "china" {
sync_cmd.env("UV_INDEX_URL", "https://mirrors.aliyun.com/pypi/simple/");
}
// UV_INDEX_URL (china / custom) applied via apply_uv_env above.
let mut sync_ok = matches!(run_streaming(app, "installing_deps", &mut sync_cmd), Ok(ref s) if s.success());
// #569: the big cu128 torch wheel (~2.5 GB) is the most common first-run
@@ -3160,13 +3378,9 @@ the existing venv; newly added dependencies may be missing (#307)",
emit_log(app, "installing_deps", "Retrying the install with the wheels you provided locally…");
let mut retry = Command::new(&uv_path);
scrub_python_env(&mut retry);
// apply_uv_env sets UV_INDEX_URL for china / custom mirrors
apply_uv_env(app, &mut retry);
retry.env("UV_FIND_LINKS", &wheels_dir);
if let Some(pypi) = custom_mirrors.pypi_index.as_deref() {
retry.env("UV_INDEX_URL", pypi);
} else if get_effective_region(app) == "china" {
retry.env("UV_INDEX_URL", "https://mirrors.aliyun.com/pypi/simple/");
}
retry.args(["sync", "--no-dev", "--verbose"]).current_dir(&project_dir);
sync_ok = matches!(run_streaming(app, "installing_deps", &mut retry), Ok(ref s) if s.success());
}
@@ -3455,6 +3669,32 @@ mod tests {
assert_eq!(envs.get("UV_HTTP_RETRIES").map(String::as_str), Some("5"));
}
#[test]
fn resolve_pypi_index_url_honors_override_then_china_preset() {
// Repair / first-run / drift all share this resolver via apply_uv_env.
// China must not fall through to pypi.org (tls handshake eof on
// hatchling when the UI already shows the China (mirror) region).
assert_eq!(
resolve_pypi_index_url("china", None).as_deref(),
Some(CHINA_PYPI_INDEX)
);
assert_eq!(
resolve_pypi_index_url("global", None),
None,
"non-china regions keep uv's default PyPI"
);
assert_eq!(
resolve_pypi_index_url("china", Some("https://example.com/simple/")).as_deref(),
Some("https://example.com/simple/"),
"explicit override wins over the china preset"
);
assert_eq!(
resolve_pypi_index_url("china", Some(" ")),
Some(CHINA_PYPI_INDEX.to_string()),
"blank override falls back to the china preset"
);
}
#[test]
fn crash_loop_policy_is_three_deaths_in_ten_minutes() {
// #941 escalation guard: ≥3 crashes inside 10 min must stop the
@@ -4186,14 +4426,17 @@ UnicodeDecodeError: 'gbk' codec can't decode byte 0x80 in position 11: illegal m
fn failed_command_message_carries_the_newest_relevant_output() {
let logs = vec![
LogPayload {
attempt: 1,
stage: "downloading_uv".into(),
line: "unrelated".into(),
},
LogPayload {
attempt: 1,
stage: "installing_deps".into(),
line: "resolver context".into(),
},
LogPayload {
attempt: 1,
stage: "installing_deps".into(),
line: "actual dependency conflict".into(),
},
@@ -4384,4 +4627,172 @@ mod code_fingerprint_tests {
let backend_only = hash_python_sources(&[backend_dir]).unwrap();
assert_ne!(both, backend_only);
}
// #1847: the splash is the only surface with a Show/Copy affordance for
// the first-run log, and it unmounts the moment the stage flips to ready,
// so on a successful install the whole log was gone for good. It is
// written beside backend.log now.
#[test]
fn bootstrap_log_sits_beside_the_backend_log() {
// One directory for everything about a run, so a bug report does not
// have to hunt in two places.
let bootstrap = bootstrap_log_path();
let backend = crate::backend::backend_log_path();
assert_eq!(bootstrap.parent(), backend.parent());
assert_eq!(bootstrap.file_name().unwrap(), "bootstrap.log");
}
#[test]
fn append_bootstrap_log_writes_the_stage_and_line() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bootstrap.log");
// Exercise the same write shape the helper uses, against a path we
// control: the helper itself resolves a per-OS location, and a test
// that redirected that would be testing the redirection.
use std::io::Write;
let mut file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&path)
.unwrap();
writeln!(file, "[{}] {}", "installing_deps", "Collecting torch").unwrap();
drop(file);
let body = fs::read_to_string(&path).unwrap();
assert!(body.contains("[installing_deps] Collecting torch"), "{body}");
}
#[test]
fn append_bootstrap_log_never_panics_on_an_unwritable_path() {
// Best effort by contract: a log we cannot write must not take the
// bootstrap down with it.
append_bootstrap_log("checking", "a line");
}
// #1898: on Windows the backend is force-terminated with no graceful
// phase, so it never clears its own run sentinel and every deliberate
// quit was reported as a crash on the next launch. The shell retires the
// sentinel instead, immediately before the kill.
#[test]
fn retire_run_sentinel_at_removes_the_file() {
let dir = tempfile::tempdir().unwrap();
let sentinel = dir.path().join("run_sentinel.json");
fs::write(&sentinel, "{}").unwrap();
retire_run_sentinel_at(dir.path());
assert!(!sentinel.exists(), "a deliberate stop must retire the sentinel");
}
#[test]
fn retire_run_sentinel_at_is_quiet_when_there_is_nothing_to_retire() {
// A backend that already cleared it, or never wrote one. Must not
// panic or log an error on the ordinary path.
let dir = tempfile::tempdir().unwrap();
retire_run_sentinel_at(dir.path());
}
#[test]
fn retire_run_sentinel_leaves_the_file_when_the_backend_is_unreachable() {
// The crash record must survive when we cannot confirm where it
// lives: reporting a crash we are unsure about beats silently
// erasing evidence of a real one. Nothing listens on this port.
let dir = tempfile::tempdir().unwrap();
let sentinel = dir.path().join("run_sentinel.json");
fs::write(&sentinel, "{}").unwrap();
retire_run_sentinel(59_999);
assert!(sentinel.exists(), "an unreachable backend must not erase the sentinel");
}
// ── #1900: the attempt id the splash scopes evidence by ────────────────
#[test]
fn a_status_reply_carries_the_stage_and_its_attempt() {
// The wire shape the frontend already reads must survive: `stage` stays
// a sibling key at the top level (serde flatten on an internally tagged
// enum), with `attempt` added beside it — not nested under it.
let status = BootstrapStatus {
attempt: 4,
stage: BootstrapStage::InstallingDeps,
};
let json = serde_json::to_value(&status).unwrap();
assert_eq!(json["stage"], "installing_deps");
assert_eq!(json["attempt"], 4);
}
#[test]
fn a_failed_status_keeps_its_message_alongside_the_attempt() {
// The variant's own fields flatten up too, so `message` does not move
// and the failure card keeps rendering.
let status = BootstrapStatus {
attempt: 2,
stage: BootstrapStage::Failed { message: "uv sync failed".into() },
};
let json = serde_json::to_value(&status).unwrap();
assert_eq!(json["stage"], "failed");
assert_eq!(json["message"], "uv sync failed");
assert_eq!(json["attempt"], 2);
}
/// `ATTEMPT` is one process-global counter and cargo runs tests in
/// threads. Without this the three below interleave: one reads the counter
/// another just advanced, and asserts on a value it never owned
/// (CodeRabbit).
static ATTEMPT_LOCK: Mutex<()> = Mutex::new(());
fn a_stage_slot() -> Arc<Mutex<BootstrapStage>> {
Arc::new(Mutex::new(BootstrapStage::InstallingDeps))
}
#[test]
fn beginning_an_attempt_moves_the_counter_forward() {
// Monotonic and never reused: the frontend scopes by equality, so a
// repeated id would let a previous attempt's log lines count toward
// the current one — the misattribution this exists to remove.
let _g = ATTEMPT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let slot = a_stage_slot();
let before = current_attempt();
let first = begin_attempt_with(&slot, BootstrapStage::Checking);
let second = begin_attempt_with(&slot, BootstrapStage::Checking);
assert!(first > before, "an attempt must not reuse an earlier id");
assert!(second > first, "attempts must keep moving forward");
assert_eq!(current_attempt(), second);
}
#[test]
fn an_attempt_is_never_zero() {
// 0 is reserved for "no attempt stated" — a payload from a build that
// predates this field deserializes to it, and must never collide with
// a real attempt.
let _g = ATTEMPT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
assert!(current_attempt() >= 1);
assert!(begin_attempt_with(&a_stage_slot(), BootstrapStage::Checking) >= 1);
}
#[test]
fn a_new_attempt_never_carries_the_previous_stage() {
// Greptile P1: the bump and the stage write have to be one atomic
// move. Sampled separately, a restart landing between them returns
// `installing_deps` stamped with the NEW attempt, and the splash
// records an install this attempt never ran.
let _g = ATTEMPT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let slot = a_stage_slot();
let opened = begin_attempt_with(&slot, BootstrapStage::Checking);
// Whatever a reader sees after the call, the pair is consistent: the
// new attempt's id can only ever come with the new attempt's stage.
let stage = slot.lock().unwrap().clone();
assert!(matches!(stage, BootstrapStage::Checking), "{stage:?}");
assert_eq!(current_attempt(), opened);
}
}
+153 -5
View File
@@ -12,7 +12,7 @@ use tauri_plugin_dialog::DialogExt;
use crate::config::{load_config, save_config};
use crate::dictation_shortcut::{update_tray_hint, DictationShortcutManager, ShortcutInfo};
use crate::{AppFlags, TrayHandle};
use crate::{AppFlags, CaptureAcceptanceTimeout, CaptureReceiptCancellation, TrayHandle};
use crate::{TRAY_ICON_DEFAULT, TRAY_ICON_RECORDING};
// ── Native host-path authorization ───────────────────────────────────────
@@ -1006,12 +1006,145 @@ pub fn get_effective_dictation_shortcut(
}
#[tauri::command]
pub fn request_dictation_capture(app: tauri::AppHandle, action: String) -> Result<(), String> {
pub async fn request_dictation_capture(
app: tauri::AppHandle,
action: String,
) -> Result<(), String> {
if action != "start" && action != "stop" && action != "toggle" {
return Err("capture action must be start, stop, or toggle".into());
}
crate::dispatch_dictation_capture(&app, &action);
Ok(())
let delivery_id = crate::request_dictation_capture_delivery(&app, &action)
.ok_or_else(|| "capture request could not be queued".to_string())?;
let wait_app = app.clone();
let mut acknowledged = tauri::async_runtime::spawn_blocking(move || {
wait_for_capture_delivery(
|| {
let flags = wait_app.state::<AppFlags>();
let capture = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?;
Ok(capture.delivery_pending(delivery_id))
},
CAPTURE_DELIVERY_TIMEOUT,
)
})
.await
.map_err(|error| format!("capture acknowledgement worker failed: {error}"))??;
if !acknowledged {
let flags = app.state::<AppFlags>();
let timeout_outcome = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?
.cancel_unreceived_delivery(delivery_id);
match timeout_outcome {
CaptureReceiptCancellation::Received => acknowledged = true,
CaptureReceiptCancellation::Cancelled(event) => {
if event.name == "tray-dictate" {
flags.output.finish_session(event.payload.session_id);
}
return Err("capture window did not acknowledge the request".into());
}
CaptureReceiptCancellation::Missing => {
return Err("capture request disappeared before acknowledgement".into());
}
}
}
debug_assert!(acknowledged);
let outcome_app = app.clone();
let completed = tauri::async_runtime::spawn_blocking(move || {
wait_for_capture_delivery(
|| {
let flags = outcome_app.state::<AppFlags>();
let capture = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?;
Ok(!capture.completion_ready(delivery_id))
},
CAPTURE_ACCEPTANCE_TIMEOUT,
)
})
.await
.map_err(|error| format!("capture acceptance worker failed: {error}"))??;
let flags = app.state::<AppFlags>();
if completed {
let completion = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?
.take_completion(delivery_id);
return completion
.unwrap_or_else(|| Err("capture request completed without an outcome".into()));
}
let timeout_outcome = flags
.capture
.lock()
.map_err(|_| "Dictation capture state lock poisoned".to_string())?
.take_completion_or_cancel(delivery_id);
match timeout_outcome {
CaptureAcceptanceTimeout::Completed(completion) => return completion,
CaptureAcceptanceTimeout::Cancelled(event) if event.name == "tray-dictate" => {
flags.output.finish_session(event.payload.session_id);
}
CaptureAcceptanceTimeout::Cancelled(_) | CaptureAcceptanceTimeout::Missing => {}
}
Err("dictation capture did not start in time".into())
}
const CAPTURE_DELIVERY_TIMEOUT: Duration = Duration::from_secs(2);
const CAPTURE_ACCEPTANCE_TIMEOUT: Duration = Duration::from_secs(60);
const CAPTURE_DELIVERY_POLL: Duration = Duration::from_millis(20);
fn wait_for_capture_delivery<F>(mut pending: F, timeout: Duration) -> Result<bool, String>
where
F: FnMut() -> Result<bool, String>,
{
let deadline = Instant::now() + timeout;
loop {
if !pending()? {
return Ok(true);
}
if Instant::now() >= deadline {
return Ok(false);
}
std::thread::sleep(CAPTURE_DELIVERY_POLL);
}
}
#[cfg(test)]
mod capture_request_tests {
use super::wait_for_capture_delivery;
use std::time::Duration;
#[test]
fn listener_acknowledgement_completes_the_request() {
let mut polls = 0;
let acknowledged = wait_for_capture_delivery(
|| {
polls += 1;
Ok(polls < 2)
},
Duration::from_millis(50),
)
.expect("poll succeeds");
assert!(acknowledged);
}
#[test]
fn missing_listener_acknowledgement_times_out() {
let acknowledged = wait_for_capture_delivery(
|| Ok(true),
Duration::from_millis(0),
)
.expect("poll succeeds");
assert!(!acknowledged);
}
}
/// Distance from the bottom edge of the work area, in logical pixels — clear of
@@ -1162,13 +1295,28 @@ pub fn acknowledge_dictation_capture_delivery(
app: tauri::AppHandle,
registration_id: u64,
delivery_id: u64,
) -> bool {
let flags = app.state::<AppFlags>();
let Ok(mut capture) = flags.capture.lock() else {
log::warn!("Dictation capture state lock poisoned");
return false;
};
capture.acknowledge(registration_id, delivery_id)
}
#[tauri::command]
pub fn complete_dictation_capture_delivery(
app: tauri::AppHandle,
registration_id: u64,
delivery_id: u64,
error: Option<String>,
) {
let flags = app.state::<AppFlags>();
let Ok(mut capture) = flags.capture.lock() else {
log::warn!("Dictation capture state lock poisoned");
return;
};
capture.acknowledge(registration_id, delivery_id);
capture.complete(registration_id, delivery_id, error);
}
#[tauri::command]
+20
View File
@@ -247,7 +247,27 @@ fn pick_region(direct: Option<std::time::Duration>, mirror: Option<std::time::Du
/// and we stay direct ("global", no proxy hop); on a throttled/blocked network
/// the mirror answers first (or GitHub times out) and we switch ("restricted").
/// Both probes run in parallel, so the check costs one timeout, not two.
/// Cached result of the auto-detection probe, for the life of the process.
///
/// The probe costs up to one 4-second timeout and makes two outbound requests.
/// That was acceptable while it ran once during bootstrap, but the PyPI mirror
/// applicator now runs for EVERY `uv` invocation (#1892), and with the default
/// `region = "auto"` each of those re-raced the network. On a blocked or
/// offline network that is a repeated 4-second stall per uv call, and it
/// multiplies the outbound calls a local-first app makes without being asked.
///
/// The answer cannot meaningfully change mid-session — it describes which way
/// out of the machine is faster — so racing it again is pure cost. A user who
/// changes networks restarts the app or picks the region explicitly, both of
/// which bypass this path.
static AUTO_REGION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
/// Race github.com against the ghproxy mirror once, then reuse the verdict.
pub fn auto_detect_region() -> String {
AUTO_REGION.get_or_init(auto_detect_region_uncached).clone()
}
fn auto_detect_region_uncached() -> String {
log::info!("Auto-detecting region (racing github.com vs ghproxy mirror)...");
const PROBE_TIMEOUT: Duration = Duration::from_secs(4);
// Probe the SAME resource through both paths so the latencies compare fairly.
+14 -1
View File
@@ -64,7 +64,20 @@ impl DictationShortcutManager {
Ok(()) => {
manager.publish(&app, accelerator, None, "native");
}
Err(error) => log::warn!("Failed to register global shortcut: {error}"),
Err(error) => {
// Publish the failure instead of only logging it. Whichever
// app registers a global shortcut first wins, and the default
// collides with 1Password Quick Access on macOS — so for a lot
// of installs the hotkey the onboarding screen advertises
// silently does nothing. With no publish on this path the
// frontend kept reporting whatever accelerator was REQUESTED,
// with no way to know the OS never granted it (#1858).
//
// The accelerator is still published so the UI can name the
// shortcut that failed; `backend` carries the outcome.
log::warn!("Failed to register global shortcut: {error}");
manager.publish(&app, accelerator, None, "unregistered");
}
}
}
+510 -53
View File
@@ -26,7 +26,7 @@ pub mod watch_folder;
#[cfg(target_os = "linux")]
pub mod wayland_shortcut;
use std::collections::VecDeque;
use std::collections::{HashMap, VecDeque};
use std::process::Child;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@@ -107,6 +107,29 @@ pub struct CaptureDispatchState {
registration_counter: u64,
delivery_counter: u64,
active_registration: Option<u64>,
in_flight: HashMap<u64, CaptureInFlight>,
}
struct CaptureInFlight {
event: CaptureEvent,
outcome: Option<Result<(), String>>,
}
pub(crate) enum CaptureReceiptCancellation {
Received,
Cancelled(CaptureEvent),
Missing,
}
pub(crate) enum CaptureAcceptanceTimeout {
Completed(Result<(), String>),
Cancelled(CaptureEvent),
Missing,
}
struct CaptureEnqueue {
delivery_id: u64,
event: Option<CaptureEvent>,
}
impl Default for CaptureDispatchState {
@@ -117,6 +140,7 @@ impl Default for CaptureDispatchState {
registration_counter: 0,
delivery_counter: 0,
active_registration: None,
in_flight: HashMap::new(),
}
}
}
@@ -147,25 +171,125 @@ impl CaptureDispatchState {
.collect()
}
pub(crate) fn enqueue(&mut self, mut event: CaptureEvent) -> Option<CaptureEvent> {
fn enqueue(&mut self, mut event: CaptureEvent) -> CaptureEnqueue {
self.delivery_counter = self.delivery_counter.wrapping_add(1).max(1);
event.payload.delivery_id = self.delivery_counter;
self.pending.push_back(event.clone());
let registration_id = self.active_registration.filter(|_| self.ready)?;
event.payload.registration_id = registration_id;
Some(event)
let ready_event = self
.active_registration
.filter(|_| self.ready)
.map(|registration_id| {
event.payload.registration_id = registration_id;
event
});
CaptureEnqueue {
delivery_id: self.delivery_counter,
event: ready_event,
}
}
pub(crate) fn acknowledge(&mut self, registration_id: u64, delivery_id: u64) {
pub(crate) fn acknowledge(&mut self, registration_id: u64, delivery_id: u64) -> bool {
if self.active_registration != Some(registration_id) {
return;
return false;
}
if let Some(index) = self
.pending
.iter()
.position(|event| event.payload.delivery_id == delivery_id)
{
self.pending.remove(index);
if let Some(event) = self.pending.remove(index) {
if event.await_result {
self.in_flight.insert(
delivery_id,
CaptureInFlight {
event,
outcome: None,
},
);
}
return true;
}
}
false
}
pub(crate) fn complete(
&mut self,
registration_id: u64,
delivery_id: u64,
error: Option<String>,
) {
if self.active_registration != Some(registration_id) {
return;
}
if let Some(delivery) = self.in_flight.get_mut(&delivery_id) {
delivery.outcome = Some(error.map_or_else(|| Ok(()), Err));
}
}
pub(crate) fn completion_ready(&self, delivery_id: u64) -> bool {
self.in_flight
.get(&delivery_id)
.is_some_and(|delivery| delivery.outcome.is_some())
}
pub(crate) fn take_completion(&mut self, delivery_id: u64) -> Option<Result<(), String>> {
let ready = self.completion_ready(delivery_id);
ready
.then(|| self.in_flight.remove(&delivery_id))
.flatten()
.and_then(|delivery| delivery.outcome)
}
pub(crate) fn delivery_pending(&self, delivery_id: u64) -> bool {
self.pending
.iter()
.any(|event| event.payload.delivery_id == delivery_id)
}
pub(crate) fn cancel_unreceived_delivery(
&mut self,
delivery_id: u64,
) -> CaptureReceiptCancellation {
if self.in_flight.contains_key(&delivery_id) {
return CaptureReceiptCancellation::Received;
}
let Some(index) = self
.pending
.iter()
.position(|event| event.payload.delivery_id == delivery_id)
else {
return CaptureReceiptCancellation::Missing;
};
self.pending
.remove(index)
.map_or(CaptureReceiptCancellation::Missing, CaptureReceiptCancellation::Cancelled)
}
pub(crate) fn cancel_delivery(&mut self, delivery_id: u64) -> Option<CaptureEvent> {
if let Some(index) = self
.pending
.iter()
.position(|event| event.payload.delivery_id == delivery_id)
{
return self.pending.remove(index);
}
self.in_flight
.remove(&delivery_id)
.map(|delivery| delivery.event)
}
pub(crate) fn take_completion_or_cancel(
&mut self,
delivery_id: u64,
) -> CaptureAcceptanceTimeout {
if let Some(completion) = self.take_completion(delivery_id) {
CaptureAcceptanceTimeout::Completed(completion)
} else {
self.cancel_delivery(delivery_id).map_or(
CaptureAcceptanceTimeout::Missing,
CaptureAcceptanceTimeout::Cancelled,
)
}
}
@@ -189,6 +313,7 @@ pub(crate) struct DictationCapturePayload {
pub(crate) struct CaptureEvent {
pub(crate) name: &'static str,
pub(crate) payload: DictationCapturePayload,
await_result: bool,
}
pub struct TrayHandle {
@@ -205,10 +330,22 @@ fn dictation_capture_event(action: &str, dictating: bool) -> &'static str {
}
pub fn dispatch_dictation_capture(app: &tauri::AppHandle, action: &str) {
dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut);
let _ = dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut, false);
}
fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin: CaptureOrigin) {
pub(crate) fn request_dictation_capture_delivery(
app: &tauri::AppHandle,
action: &str,
) -> Option<u64> {
dispatch_dictation_capture_from(app, action, CaptureOrigin::Shortcut, true)
}
fn dispatch_dictation_capture_from(
app: &tauri::AppHandle,
action: &str,
origin: CaptureOrigin,
await_result: bool,
) -> Option<u64> {
let flags = app.state::<AppFlags>();
let event = dictation_capture_event(action, flags.dictating.load(Ordering::SeqCst));
let session_id = if event == "tray-dictate" {
@@ -217,8 +354,21 @@ fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin:
session_id
} else {
log::warn!("Dictation capture '{action}' ignored — no active output session");
return;
return None;
};
// The recorder lives in the widget WebView. WebKit can suspend that
// document while its window is hidden, so an event cannot be relied on to
// wake the very listener that must receive it. Preserve the output target
// first, then show the non-activating pill before enqueueing/emitting the
// start event. The widget's idle reconcile hides it again if capture is
// disabled or startup exits early.
if event == "tray-dictate" {
if let Err(error) = commands::show_dictation_pill(app.clone()) {
log::warn!("Dictation capture '{action}' could not wake the capture window: {error}");
}
}
let capture_event = CaptureEvent {
name: event,
payload: DictationCapturePayload {
@@ -226,12 +376,15 @@ fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin:
delivery_id: 0,
registration_id: 0,
},
await_result,
};
let Ok(mut capture) = flags.capture.lock() else {
log::warn!("Dictation capture state lock poisoned");
return;
return None;
};
if let Some(capture_event) = capture.enqueue(capture_event) {
let enqueued = capture.enqueue(capture_event);
let delivery_id = enqueued.delivery_id;
if let Some(capture_event) = enqueued.event {
drop(capture);
// A press that reaches Rust but produces no recording is otherwise
// indistinguishable from one the compositor never delivered, so say
@@ -246,12 +399,14 @@ fn dispatch_dictation_capture_from(app: &tauri::AppHandle, action: &str, origin:
"Dictation capture '{action}' queued — the capture window has not registered yet"
);
}
Some(delivery_id)
}
#[cfg(test)]
mod dictation_capture_tests {
use super::{
dictation_capture_event, CaptureDispatchState, CaptureEvent, DictationCapturePayload,
dictation_capture_event, CaptureAcceptanceTimeout, CaptureDispatchState, CaptureEvent,
CaptureReceiptCancellation, DictationCapturePayload,
};
fn capture_event(name: &'static str) -> CaptureEvent {
@@ -262,6 +417,7 @@ mod dictation_capture_tests {
delivery_id: 0,
registration_id: 0,
},
await_result: false,
}
}
@@ -295,10 +451,108 @@ mod dictation_capture_tests {
assert_eq!(retried[0].payload.delivery_id, delivery_id);
assert_eq!(retried[0].payload.registration_id, current);
state.acknowledge(stale, delivery_id);
assert!(!state.acknowledge(stale, delivery_id));
assert_eq!(state.pending.len(), 1);
state.acknowledge(current, delivery_id);
assert!(state.delivery_pending(delivery_id));
assert!(state.acknowledge(current, delivery_id));
assert!(state.pending.is_empty());
assert!(!state.delivery_pending(delivery_id));
}
#[test]
fn timed_out_delivery_can_be_cancelled_without_touching_others() {
let mut state = CaptureDispatchState::default();
state.enqueue(capture_event("tray-dictate"));
state.enqueue(capture_event("tray-dictate-stop"));
let first_id = state.pending[0].payload.delivery_id;
let second_id = state.pending[1].payload.delivery_id;
let cancelled = state.cancel_delivery(first_id).expect("delivery exists");
assert_eq!(cancelled.payload.session_id, 7);
assert!(!state.delivery_pending(first_id));
assert!(state.delivery_pending(second_id));
}
#[test]
fn awaited_delivery_preserves_frontend_rejection_for_the_requester() {
let mut state = CaptureDispatchState::default();
let registration_id = state.begin_registration();
state.mark_registration_ready(registration_id);
let mut event = capture_event("tray-dictate");
event.await_result = true;
let delivery_id = state.enqueue(event).delivery_id;
assert!(state.acknowledge(registration_id, delivery_id));
assert!(!state.completion_ready(delivery_id));
state.complete(
registration_id,
delivery_id,
Some("Dictation is disabled".into()),
);
assert!(state.completion_ready(delivery_id));
assert_eq!(
state.take_completion(delivery_id),
Some(Err("Dictation is disabled".into()))
);
assert_eq!(state.take_completion(delivery_id), None);
}
#[test]
fn cancellation_suppresses_an_event_cloned_for_ready_emission() {
let mut state = CaptureDispatchState::default();
let registration_id = state.begin_registration();
state.mark_registration_ready(registration_id);
let mut event = capture_event("tray-dictate");
event.await_result = true;
let enqueued = state.enqueue(event);
let emitted = enqueued.event.expect("ready event was cloned");
assert!(matches!(
state.cancel_unreceived_delivery(enqueued.delivery_id),
CaptureReceiptCancellation::Cancelled(_)
));
assert!(!state.acknowledge(
emitted.payload.registration_id,
emitted.payload.delivery_id
));
}
#[test]
fn listener_receipt_wins_atomically_over_timeout_cancellation() {
let mut state = CaptureDispatchState::default();
let registration_id = state.begin_registration();
state.mark_registration_ready(registration_id);
let mut event = capture_event("tray-dictate");
event.await_result = true;
let delivery_id = state.enqueue(event).delivery_id;
assert!(state.acknowledge(registration_id, delivery_id));
assert!(matches!(
state.cancel_unreceived_delivery(delivery_id),
CaptureReceiptCancellation::Received
));
}
#[test]
fn completion_at_the_timeout_boundary_wins_over_cancellation() {
let mut state = CaptureDispatchState::default();
let registration_id = state.begin_registration();
state.mark_registration_ready(registration_id);
let mut event = capture_event("tray-dictate");
event.await_result = true;
let delivery_id = state.enqueue(event).delivery_id;
state.acknowledge(registration_id, delivery_id);
state.complete(registration_id, delivery_id, None);
assert!(matches!(
state.take_completion_or_cancel(delivery_id),
CaptureAcceptanceTimeout::Completed(Ok(()))
));
assert!(matches!(
state.take_completion_or_cancel(delivery_id),
CaptureAcceptanceTimeout::Missing
));
}
#[test]
@@ -540,24 +794,114 @@ fn mark_pill_noactivate(win: &tauri::WebviewWindow) {
/// Show the pill without granting it foreground activation.
///
/// The only correct way to show it on Windows (#982): a plain `show()` steals
/// foreground from the app being dictated into, and the paste then lands in the
/// pill instead of the user's document. `show_dictation_pill` is the call site.
/// Two steps, and both are load-bearing.
///
/// `win.show()` is what tells TAURI the window is visible. Raw `ShowWindow`
/// alone puts it on screen behind Tauri's back, and Tauri goes on believing it
/// is hidden — so `isVisible()` answers `false` while the user is looking at
/// the thing, `hide()` becomes a no-op on a window it thinks is already
/// hidden, and the capture widget's idle reconcile (which asks `isVisible()`
/// before deciding to clean up) concludes there is nothing to clean up. The
/// result is an empty dark rectangle stranded on the desktop after the pill is
/// dismissed, with no way to remove it short of quitting the app.
///
/// `SW_SHOWNOACTIVATE` is what keeps the foreground where it belongs (#982): a
/// pill that steals focus makes the paste land in the pill instead of the
/// user's document. `WS_EX_NOACTIVATE` is already on the window from
/// `mark_pill_noactivate` at creation, which is what makes the `show()` above
/// safe — the style bit, not the show flag, is what actually refuses
/// activation. The flag stays anyway: it costs nothing and holds even if the
/// style bit could not be applied (`hwnd()` can fail).
#[cfg(target_os = "windows")]
pub(crate) fn show_pill_noactivate(win: &tauri::WebviewWindow) {
use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOWNOACTIVATE};
let Ok(hwnd) = win.hwnd() else {
log::warn!("pill: could not resolve HWND for non-activating show (#982)");
show_pill_noactivate_with(
|| win.show().map_err(|error| error.to_string()),
|| {
let hwnd = win.hwnd().map_err(|_| "no HWND".to_string())?;
unsafe {
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
}
Ok(())
},
)
}
/// The ordering itself, with both shows as parameters.
///
/// Split out so a test can pin the contract that the bug broke: Tauri's own
/// `show` must run, and it must run FIRST. A native-only show is what left an
/// empty pill window stranded on the desktop.
///
/// And when Tauri's show FAILS, the native show must not run at all (Greptile).
/// Showing it natively anyway puts an always-on-top window on screen that
/// Tauri believes is hidden — the exact stranded-window bug, reached by a
/// different door. A pill that does not appear is the lesser failure: the
/// tray's red dot still says the user is being recorded, and nothing is left
/// behind that cannot be removed.
pub(crate) fn show_pill_noactivate_with<T, N>(show_tauri: T, show_native: N)
where
T: FnOnce() -> Result<(), String>,
N: FnOnce() -> Result<(), String>,
{
if let Err(error) = show_tauri() {
log::warn!("pill: Tauri show failed; not showing it natively either, or it could never be hidden: {error}");
return;
};
unsafe {
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
}
if let Err(error) = show_native() {
log::warn!("pill: non-activating show failed ({error}) (#982)");
}
}
#[cfg(test)]
mod pill_noactivate_tests {
use super::{with_noactivate_style, WS_EX_NOACTIVATE_BIT};
use super::{show_pill_noactivate_with, with_noactivate_style, WS_EX_NOACTIVATE_BIT};
#[test]
fn showing_the_pill_tells_tauri_before_it_tells_windows() {
// The bug: only the raw Win32 show ran, so the window went on screen
// behind Tauri's back. Tauri then answered `isVisible()` with false
// while the user was looking at it, `hide()` did nothing on a window
// it believed was already hidden, and the widget's idle reconcile —
// which asks `isVisible()` before cleaning up — concluded there was
// nothing to clean up. An empty rectangle stayed on the desktop until
// the app was quit.
use std::cell::RefCell;
let order = RefCell::new(Vec::new());
show_pill_noactivate_with(
|| {
order.borrow_mut().push("tauri");
Ok(())
},
|| {
order.borrow_mut().push("native");
Ok(())
},
);
assert_eq!(
order.into_inner(),
["tauri", "native"],
"Tauri's own show must run, and run first"
);
}
#[test]
fn a_failing_tauri_show_does_not_fall_back_to_a_native_one() {
// Greptile: a native-only show after Tauri's show failed puts an
// always-on-top window on screen that Tauri believes is hidden, so
// neither dismiss() nor the idle reconcile can ever remove it — the
// stranded-window bug again. A pill that does not appear is the lesser
// failure; the tray's red dot still signals recording.
let mut native_ran = false;
show_pill_noactivate_with(
|| Err("no window".to_string()),
|| {
native_ran = true;
Ok(())
},
);
assert!(!native_ran, "a native show after a failed Tauri show strands an unhidable window");
}
#[test]
fn adds_noactivate_bit_without_clobbering_existing_style() {
@@ -611,6 +955,90 @@ pub fn shutdown_backend_for_exit<R: tauri::Runtime>(app_handle: &tauri::AppHandl
}
}
/// Show, unminimize and focus the main window. Shared by the tray's "Show
/// VoiceStudio" menu item and the macOS `RunEvent::Reopen` handler below (Dock
/// icon clicked while the main window is hidden), so the two recovery paths
/// behave identically instead of drifting apart over time.
fn show_and_focus_main_window<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
#[cfg(not(target_os = "macos"))]
let _ = win.set_skip_taskbar(false);
let _ = win.unminimize();
let _ = win.set_focus();
// Self-recovery: if the webview failed to load the dev/prod URL
// earlier (Vite restarted, backend not up yet at first show, etc.)
// the window shows a blank `<body></body>` with a "Could not connect
// to the server" console error. Reload only when the body is empty
// so a healthy window doesn't blink on every show.
let _ = win.eval(
"if (document.body && document.body.childElementCount === 0) { location.reload(); }",
);
}
}
/// Whether a macOS `RunEvent::Reopen` (Dock icon clicked — Cocoa's
/// `applicationShouldHandleReopen:hasVisibleWindows:`) should restore the
/// main window. Pure so it's unit-testable — the actual event only fires
/// inside the real Cocoa event loop and can't be synthesized under
/// `cargo test` (see the `with_noactivate_style` comment above for the same
/// rationale). `CloseRequested` (see `on_window_event` below) hides the main
/// window rather than destroying it, so it is merely invisible once the user
/// has closed it — exactly when the Dock icon should bring it back.
///
/// Keyed on the MAIN window specifically, not on Cocoa's `has_visible_windows`
/// flag. This app owns a second window: the always-on-top dictation pill
/// (`widget`, built below), which is shown and hidden independently and can
/// sit on screen for a long time on its own — the Accessibility-setup state
/// persists until the permission is granted. Keying on "any window visible"
/// would report `true` from the pill alone and leave the Dock icon dead in
/// precisely the case this handler exists to fix.
///
/// Only called from the macOS-gated `RunEvent::Reopen` arm below outside of
/// tests — `#[allow(dead_code)]` elsewhere, same treatment as `is_app_origin`
/// and `with_noactivate_style` above.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn should_restore_on_reopen(main_window_visible: bool, _cocoa_has_visible_windows: bool) -> bool {
// Cocoa's aggregate flag is accepted and deliberately ignored. Taking it
// as a parameter rather than dropping it at the call site is what lets
// the tests below pin the contract: `(main: false, cocoa: true)` — the
// pill up, the main window closed — must still restore. An earlier
// revision decided on the aggregate alone and left the Dock icon dead in
// exactly that state.
!main_window_visible
}
#[cfg(test)]
mod reopen_tests {
use super::should_restore_on_reopen;
#[test]
fn restores_when_the_main_window_is_hidden() {
assert!(should_restore_on_reopen(false, false));
}
#[test]
fn does_nothing_when_the_main_window_is_already_visible() {
assert!(!should_restore_on_reopen(true, true));
}
/// Regression guard: the dictation pill is a separate always-on-top
/// window that can be visible while the main window is closed — the
/// Accessibility-setup state stays up until the permission is granted.
/// An earlier revision keyed this decision on Cocoa's
/// `has_visible_windows`, which the pill alone sets to `true`, leaving
/// the Dock icon dead in exactly the situation this handler is for.
/// The decision must depend only on the main window.
#[test]
fn restores_even_when_another_window_such_as_the_pill_is_visible() {
// Cocoa reports a visible window (the pill) while the main window is
// hidden. Passing both values separately is the point: this case is
// what distinguishes the main-window rule from the aggregate one, and
// it fails if the body ever goes back to `!cocoa_has_visible_windows`.
assert!(should_restore_on_reopen(false, true));
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// #879: if the previous run requested a WebView cache repair (splash
@@ -727,6 +1155,7 @@ pub fn run() {
commands::begin_dictation_capture_registration,
commands::mark_dictation_capture_ready,
commands::acknowledge_dictation_capture_delivery,
commands::complete_dictation_capture_delivery,
commands::end_dictation_capture_registration,
commands::show_dictation_pill,
commands::get_launch_as_widget,
@@ -797,10 +1226,19 @@ pub fn run() {
WebviewUrl::App("index.html".into()),
)
.title("Capture")
.inner_size(300.0, 64.0)
.inner_size(460.0, 164.0)
.resizable(false)
.transparent(true)
.decorations(false)
// No window shadow. On Windows, Tauri's default (`true`) gives
// an undecorated window a 1px white border and, on Windows 11,
// rounded corners — drawn around the WHOLE 460x164 window, not
// the pill inside it, which is at most 284px wide. The result
// is a visible card framing empty space around the capsule,
// there whether the pill is showing or not. The capsule draws
// its own edge and shadow in CSS; the window must draw nothing.
// (Unsupported on Linux, where it was never the problem.)
.shadow(false)
.always_on_top(true)
.visible(false)
.focused(false)
@@ -879,12 +1317,8 @@ pub fn run() {
match event.state {
ShortcutState::Pressed => {
log::info!("Global shortcut pressed: dictation start");
// The widget window stays hidden until the
// capture itself reaches a state worth
// showing — the widget calls
// `show_dictation_pill` then, so a press
// that bails early never strands an empty
// capsule on the desktop.
// Dispatch preserves the focused target,
// wakes the recorder WebView, then emits.
dispatch_dictation_capture(app_handle, "start");
}
ShortcutState::Released => {
@@ -983,23 +1417,7 @@ pub fn run() {
.on_menu_event(move |app, event| {
match event.id().as_ref() {
"show" => {
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
#[cfg(not(target_os = "macos"))]
let _ = win.set_skip_taskbar(false);
let _ = win.set_focus();
// Self-recovery: if the webview failed to load
// the dev/prod URL earlier (Vite restarted,
// backend not up yet at first show, etc.) the
// window shows a blank `<body></body>` with a
// "Could not connect to the server" console
// error. Reload only when the body is empty
// so a healthy window doesn't blink on every
// tray click.
let _ = win.eval(
"if (document.body && document.body.childElementCount === 0) { location.reload(); }",
);
}
show_and_focus_main_window(app);
}
"open_studio" => {
// Persist the preference (so next launch is studio, not pill)
@@ -1034,9 +1452,19 @@ pub fn run() {
// current by the frontend's existing
// `set_tray_recording` call on every start and stop.
if app.state::<AppFlags>().dictating.load(Ordering::SeqCst) {
dispatch_dictation_capture_from(app, "stop", CaptureOrigin::Tray);
let _ = dispatch_dictation_capture_from(
app,
"stop",
CaptureOrigin::Tray,
false,
);
} else {
dispatch_dictation_capture_from(app, "start", CaptureOrigin::Tray);
let _ = dispatch_dictation_capture_from(
app,
"start",
CaptureOrigin::Tray,
false,
);
}
}
"settings" => {
@@ -1193,12 +1621,41 @@ pub fn run() {
.build(tauri::generate_context!())
.expect("error while building tauri application");
app.run(|app_handle, event| {
if let tauri::RunEvent::ExitRequested { code, api, .. } = event {
app.run(|app_handle, event| match event {
tauri::RunEvent::ExitRequested { code, api, .. } => {
if !persistence_exit::handle_exit_requested(app_handle, code, &api) {
return;
}
shutdown_backend_for_exit(app_handle);
}
// macOS: clicking the Dock icon while the app has no visible windows
// fires this (instead of relaunching) via Cocoa's
// `applicationShouldHandleReopen:hasVisibleWindows:`. CloseRequested
// (see `on_window_event` above) hides the main window rather than
// destroying it, so without this arm the click did nothing — the
// process stayed alive with a live Dock icon and the only way back
// was the tray's "Show VoiceStudio" item. `show_and_focus_main_window`
// is the same sequence that item runs, so both paths behave
// identically.
#[cfg(target_os = "macos")]
tauri::RunEvent::Reopen {
has_visible_windows,
..
} => {
// Cocoa's `has_visible_windows` is deliberately NOT used: the
// dictation pill is a separate always-on-top window that sets it
// to `true` on its own. Ask the main window directly instead.
// `is_visible()` errors only if the window has gone away, and a
// redundant show is harmless next to a Dock icon that stays dead,
// so treat an error as "not visible" and restore.
let main_visible = app_handle
.get_webview_window("main")
.map(|win| win.is_visible().unwrap_or(false))
.unwrap_or(false);
if should_restore_on_reopen(main_visible, has_visible_windows) {
show_and_focus_main_window(app_handle);
}
}
_ => {}
});
}
+3 -2
View File
@@ -31,12 +31,13 @@
{
"label": "widget",
"title": "Capture",
"width": 300,
"height": 64,
"width": 460,
"height": 164,
"resizable": false,
"fullscreen": false,
"transparent": true,
"decorations": false,
"shadow": false,
"alwaysOnTop": true,
"visible": false,
"skipTaskbar": true,
+3 -2
View File
@@ -21,12 +21,13 @@
{
"label": "widget",
"title": "Capture",
"width": 300,
"height": 64,
"width": 460,
"height": 164,
"resizable": false,
"fullscreen": false,
"transparent": true,
"decorations": false,
"shadow": false,
"alwaysOnTop": true,
"visible": false,
"skipTaskbar": true,
@@ -1,6 +1,9 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "VoiceStudio (Current User)",
"build": {
"beforeBuildCommand": null
},
"bundle": {
"targets": ["msi"],
"createUpdaterArtifacts": true,
+15 -2
View File
@@ -1546,9 +1546,22 @@ fn port_conflict_is_named_as_a_port_conflict() {
join_with_timeout(h, Duration::from_secs(30), "port conflict");
let msg = t.failed_message().expect("stage must be Failed");
// The contract is the MATCHER, not one sentence. detectHints turns any
// message with "port … in use" into the localised `bootstrap.hint_port`,
// and pinning a literal instead made this test fail on a rewording that
// still matched perfectly well (#1933). The wording now depends on who
// holds the port; what must never change is that the matcher fires.
let lowered = msg.to_lowercase();
let port_at = lowered.find("port").unwrap_or_else(|| {
panic!("diagnosis does not mention a port at all, got: {msg}")
});
assert!(
msg.contains("is already in use, so the backend could not"),
"diagnosis must carry the detectHints-matchable port phrasing, got: {msg}"
lowered[port_at..].contains("in use"),
"detectHints will not match this, so the user loses the localised port hint, got: {msg}"
);
assert!(
msg.contains(&app_lib::backend_port().to_string()),
"the diagnosis must name the port it is about, got: {msg}"
);
let store = t.markers();
assert_eq!(store.markers.len(), 1, "one real death → one marker");
+61 -36
View File
@@ -45,7 +45,7 @@ import NavRail from './components/NavRail';
import TitleTabs from './components/TitleTabs';
import WorkspaceHistory from './components/WorkspaceHistory';
import WorkspaceVoices from './components/WorkspaceVoices';
import WorkspaceProjects from './components/WorkspaceProjects';
import DubWorkspaceSidebar from './components/DubWorkspaceSidebar';
import ErrorBoundary from './components/ErrorBoundary';
import FloatingPill from './components/FloatingPill';
import GlobalAudioPlayer from './components/GlobalAudioPlayer';
@@ -122,7 +122,11 @@ function App() {
// publishes progress via the `bootstrap_status` Tauri command. Hook below
// polls every 1 s; until `ready`, we render BootstrapSplash instead of the
// normal app shell, so the user sees real progress instead of a hung UI.
const { stage: bootstrapStage, message: bootstrapMessage } = useBootstrapStage();
const {
stage: bootstrapStage,
message: bootstrapMessage,
attempt: bootstrapAttempt,
} = useBootstrapStage();
// Read once, like api/client.ts. Saving or disabling a remote backend reloads
// the app, so this value and API's module-level base always move together.
const [remoteBackend] = useState(() => configuredRemoteBackend());
@@ -188,6 +192,7 @@ function App() {
const locale = useAppStore((s) => s.locale);
const font = useAppStore((s) => s.font);
const reduceMotion = useAppStore((s) => s.reduceMotion);
// Hydrate the theme, locale & font so persisted preferences take effect after
// zustand persist rehydrates (async from localStorage) and when the user
@@ -198,6 +203,14 @@ function App() {
} else {
document.documentElement.removeAttribute('data-theme');
}
// Same reason as the theme above: a persisted preference has to be
// re-applied after zustand rehydrates, or the toggle reads as on while
// the app animates (#1857).
if (reduceMotion) {
document.documentElement.setAttribute('data-motion', 'reduce');
} else {
document.documentElement.removeAttribute('data-motion');
}
if (locale) {
i18n.changeLanguage(locale);
}
@@ -206,7 +219,7 @@ function App() {
const fontStack = FONT_STACKS[font];
if (fontStack) document.documentElement.style.setProperty('--font-sans', fontStack);
else document.documentElement.style.removeProperty('--font-sans');
}, [locale, theme, font]);
}, [locale, theme, font, reduceMotion]);
const mode = useAppStore((s) => s.mode);
const setMode = useAppStore((s) => s.setMode);
// "Define voice" method inside the Voice (studio) workspace replaces the
@@ -399,6 +412,7 @@ function App() {
setPendingTrimFile,
isGenerating,
generationTime,
generationProgress,
textAreaRef,
ingestRefAudio,
insertTag,
@@ -1254,7 +1268,11 @@ function App() {
if (!remoteBackend && bootstrapStage === 'awaiting_setup') {
return (
<div className="app-bootstrap-scale" style={{ '--ui-scale': effectiveUiScale }}>
<BootstrapSplash stage={bootstrapStage} message={bootstrapMessage} />
<BootstrapSplash
stage={bootstrapStage}
message={bootstrapMessage}
attempt={bootstrapAttempt}
/>
</div>
);
}
@@ -1267,22 +1285,27 @@ function App() {
if (!setupChecked || !storeHydrated) {
return (
<div className="app-bootstrap-scale" style={{ '--ui-scale': effectiveUiScale }}>
<BootstrapSplash stage={bootstrapStage} message={bootstrapMessage} />
</div>
);
}
if (remoteFailure) {
return (
<div className="app-bootstrap-scale" style={{ '--ui-scale': effectiveUiScale }}>
<RemoteBackendRecovery
failure={remoteFailure}
onRetry={retryRemoteBackend}
onOpenSettings={openRemoteBackendSettings}
<BootstrapSplash
stage={bootstrapStage}
message={bootstrapMessage}
attempt={bootstrapAttempt}
/>
</div>
);
}
if (!uiScaleConfigured && backendReady) {
// Legibility comes before everything the backend gates. UiScaleSetup makes
// no backend calls at all it is a client-side zoom but it used to wait
// for `backendReady`, so on a clean first run the user watched the entire
// bootstrap (and answered the macOS Accessibility prompt) at whatever size
// the app guessed, and was offered the size control only once all of that
// had finished (#1849). Asking first costs one screen and makes the rest of
// first-run readable.
//
// Still after the hydration guard above: `uiScaleConfigured` lives in the
// store, and reading it before hydration would flash this screen at someone
// who had already set their scale.
if (!uiScaleConfigured) {
return (
<div className="app-wizard-wrap" style={{ '--ui-scale': effectiveUiScale }}>
<div data-tauri-drag-region className="app-wizard-dragstrip" />
@@ -1297,6 +1320,17 @@ function App() {
</div>
);
}
if (remoteFailure) {
return (
<div className="app-bootstrap-scale" style={{ '--ui-scale': effectiveUiScale }}>
<RemoteBackendRecovery
failure={remoteFailure}
onRetry={retryRemoteBackend}
onOpenSettings={openRemoteBackendSettings}
/>
</div>
);
}
if (setupNeeded && backendReady) {
// Render outside the `app-container` grid so the wizard spans the full
// viewport instead of getting squeezed into whatever grid cell the
@@ -1343,7 +1377,11 @@ function App() {
if (!backendReady) {
return (
<div className="app-bootstrap-scale" style={{ '--ui-scale': effectiveUiScale }}>
<BootstrapSplash stage={bootstrapStage} message={bootstrapMessage} />
<BootstrapSplash
stage={bootstrapStage}
message={bootstrapMessage}
attempt={bootstrapAttempt}
/>
</div>
);
}
@@ -1577,19 +1615,6 @@ function App() {
<div
className={`studio-with-history ${dubStep === 'idle' ? '' : 'studio-with-history--editing'}`}
>
{dubStep === 'idle' && (
<div className="studio-projects">
<WorkspaceProjects
projects={studioProjects}
activeProjectId={activeProjectId}
canSave={false}
saveProject={saveProject}
loadProject={loadProject}
deleteProject={deleteProject}
renameProject={renameProject}
/>
</div>
)}
<div className="studio-with-history__main">
<ErrorBoundary name="dub">
<Suspense fallback={<LazyFallback />}>
@@ -1653,13 +1678,11 @@ function App() {
</Suspense>
</ErrorBoundary>
</div>
{/* Dub home: the Projects + History landing shows only when no project
is being edited. Opening/creating one switches to the full-width
editor (dubStep !== 'idle'). */}
{dubStep === 'idle' && (
{/* Keep the start screen focused after a source or project is selected.
The combined library rail is only part of the pristine Dub landing. */}
{dubStep === 'idle' && !dubVideoFile && !dubJobId && !activeProjectId && (
<div className="studio-right">
<WorkspaceHistory
variant="dub"
<DubWorkspaceSidebar
dubHistory={dubHistory}
restoreDubHistory={restoreDubHistory}
deleteHistory={deleteHistory}
@@ -1748,6 +1771,7 @@ function App() {
setVdStates={setVdStates}
isGenerating={isGenerating}
generationTime={generationTime}
generationProgress={generationProgress}
applyPreset={applyPreset}
insertTag={insertTag}
handleSelectProfile={handleSelectProfile}
@@ -1798,6 +1822,7 @@ function App() {
saveProject={saveProject}
loadProject={loadProject}
deleteProject={deleteProject}
renameProject={renameProject}
handleSelectProfile={handleSelectProfile}
handleDeleteProfile={handleDeleteProfile}
handleOpenVoiceProfile={openVoiceProfile}
+27
View File
@@ -125,6 +125,33 @@ export async function audiobookGenerate(
});
}
export interface ResumableAudiobookJob {
job_id: string;
type: 'audiobook' | 'longform';
status: string;
title: string;
total_chapters: number;
chapters_done: number;
created_at: number | null;
}
/** List server-owned longform manifests left by interrupted renders. */
export async function audiobookListJobs(): Promise<{ jobs: ResumableAudiobookJob[] }> {
const res = await apiFetch('/audiobook/jobs');
return res.json();
}
/** Resume one trusted server-side manifest and stream the normal audiobook events. */
export async function audiobookResume(
jobId: string,
opts: { signal?: AbortSignal } = {},
): Promise<Response> {
return apiFetch(`/audiobook/resume/${encodeURIComponent(jobId)}`, {
method: 'POST',
signal: opts.signal,
});
}
/** Upload a cover image; returns the server-side path to pass as `cover_path`. */
export async function audiobookUploadCover(file: File): Promise<{ path: string }> {
const form = new FormData();
+26 -1
View File
@@ -236,11 +236,29 @@ if (typeof window !== 'undefined') {
export class ApiError extends Error {
status?: number;
detail?: unknown;
constructor(message: string, init: { status?: number; detail?: unknown } = {}) {
/**
* The backend exception type behind an unclassified failure.
*
* The 500 handler puts `error_class` in the response body, but nothing
* lifted it onto the Error so the auto bug reporter, which reads the
* Error, filed "VoiceStudio hit an internal error; check the backend log"
* and nothing else. Every such report looked identical and none could be
* triaged (#1773).
*
* #1956 did this for the streaming path. This is the classic path, which
* had been carrying the datum on the wire the whole time.
*/
errorClass?: string;
constructor(
message: string,
init: { status?: number; detail?: unknown; errorClass?: string } = {},
) {
super(message);
this.name = 'ApiError';
this.status = init.status;
this.detail = init.detail;
this.errorClass =
typeof init.errorClass === 'string' && init.errorClass ? init.errorClass : undefined;
}
}
@@ -577,9 +595,16 @@ export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promis
typeof detail === 'string'
? detail
: ((detail as { message?: string })?.message ?? JSON.stringify(detail));
// The backend names the exception type in `error_class` on its 500s.
// Lifting it here is what lets the bug report say which failure it was.
const errorClass =
detail && typeof detail === 'object'
? (detail as { error_class?: unknown }).error_class
: undefined;
throw new ApiError(`${res.status} ${res.statusText}: ${msg}`, {
status: res.status,
detail,
errorClass: typeof errorClass === 'string' ? errorClass : undefined,
});
}
return res;
+5 -1
View File
@@ -18,7 +18,7 @@ export type EngineFamily = 'tts' | 'asr' | 'llm';
// (`effective_device` / `routing_status` / `routing_reason`). They stay
// optional so the matrix still renders a legacy/older payload that omits them
// (it gates with `??` / `?.length` and suppresses the routing badge).
type GPUTarget = 'cuda' | 'mps' | 'rocm' | 'xpu' | 'cpu';
type GPUTarget = 'cuda' | 'mps' | 'rocm' | 'vulkan' | 'xpu' | 'cpu';
// Where an engine actually runs on THIS host. `network` is LLM-only (remote).
type EffectiveDevice = GPUTarget | 'network';
// `n/a` is LLM-only; resolve_routing only ever returns the first four.
@@ -45,6 +45,10 @@ export interface EngineBackend {
// Copy-paste-ready `export VAR=...` line for a path-gated opt-in engine
// (IndexTTS / MOSS-v1.5 / dots.tts / Confucius4), else null/absent.
setup_snippet?: string | null;
// This engine's documentation page (#1866). A registry-authored constant, so
// it survives the public-metadata scrub that replaces `reason`/`last_error`.
// Absent on legacy payloads.
docs_url?: string | null;
// True when the backend's sidecar provisioner can install this engine
// in-app (Settings renders an Install button; the manual snippet is
// demoted to a collapsible fallback). Absent on legacy payloads.
+155
View File
@@ -0,0 +1,155 @@
/**
* AsrModelChooser the "no speech-to-text model installed" empty state's
* model picker.
*
* The page used to offer exactly one button: download the backend's
* recommended model. That hid the six other catalogue models the English
* Parakeet that is markedly more accurate, the 2544 MB streaming models that
* show text while you speak, the bilingual zh/en ones behind Settings, and
* a user who already had one of them on disk still saw "download Whisper
* Tiny". This lists the whole sherpa-onnx catalogue grouped by what the user
* is choosing between (accuracy vs latency), with languages and size on every
* row, and lets them install any of them, or switch to one already installed.
*
* Falls back to the single recommended-download button when the catalogue
* can't be read (backend restarting, older backend), so the page is never
* left without a way forward.
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Check, Download } from 'lucide-react';
import { apiJson } from '../api/client';
import { Badge, Button } from '../ui';
function fmtSize(sizeGb) {
if (sizeGb == null) return '';
if (sizeGb < 1) return `${Math.round(sizeGb * 1000)} MB`;
return `${sizeGb.toFixed(sizeGb < 10 ? 1 : 0)} GB`;
}
// Offline models transcribe once you stop (best accuracy); streaming ones emit
// partial text as you speak (lowest latency). That is the real trade-off, so
// it is the grouping not the engine's internal `kind`.
const GROUPS = [
{ tag: 'offline', title: 'asr_missing.group_offline' },
{ tag: 'streaming', title: 'asr_missing.group_streaming' },
];
export default function AsrModelChooser({ fallback, onInstall, onSelect, disabled = false }) {
const { t } = useTranslation();
// null = loading, [] = catalogue unavailable.
const [models, setModels] = useState(null);
useEffect(() => {
let live = true;
apiJson('/dictation/models')
.then((data) => {
if (live) setModels(Array.isArray(data?.models) ? data.models : []);
})
.catch(() => {
if (live) setModels([]);
});
return () => {
live = false;
};
}, []);
if (models === null) return null;
if (models.length === 0) {
if (!fallback?.repo_id) return null;
return (
<Button
size="sm"
variant="primary"
leading={<Download size={13} />}
disabled={disabled}
onClick={() => onInstall(fallback)}
>
{t('asr_missing.download', { label: fallback.label, size: fallback.size_gb })}
</Button>
);
}
const desc = (m) => t(`voicePanel.model_desc.${m.id}`, { defaultValue: m.languages || '' });
return (
<div className="flex flex-col gap-3" data-testid="asr-model-chooser">
<p className="m-0 text-xs text-fg-muted">{t('asr_missing.choose')}</p>
{GROUPS.map(({ tag, title }) => {
const rows = models
.filter((m) => m.tag === tag)
.sort((a, b) => Number(b.recommended) - Number(a.recommended));
if (rows.length === 0) return null;
return (
<section key={tag} aria-labelledby={`asr-group-${tag}`} className="flex flex-col gap-1">
<h3
id={`asr-group-${tag}`}
className="m-0 text-[11px] font-semibold uppercase tracking-[0.06em] text-fg-muted"
>
{t(title)}
</h3>
<ul className="m-0 flex list-none flex-col gap-1 p-0">
{rows.map((m) => (
<li
key={m.id}
data-testid={`asr-choice-${m.id}`}
className="flex flex-wrap items-center gap-2 rounded-md border border-border bg-bg px-3 py-2"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-sm font-semibold text-fg">{m.label}</span>
{m.recommended && (
<Badge tone="success" size="xs">
{t('voicePanel.badge_recommended')}
</Badge>
)}
{m.installed && (
<Badge tone="neutral" size="xs">
{t('asr_missing.installed')}
</Badge>
)}
<span className="text-xs tabular-nums text-fg-muted">
{fmtSize(m.size_gb)}
</span>
</div>
<p className="m-0 text-xs text-fg-muted">
{desc(m)}
{m.languages && (
<>
{' · '}
<span className="whitespace-nowrap">{m.languages}</span>
</>
)}
</p>
</div>
{m.installed ? (
<Button
size="sm"
variant="subtle"
leading={<Check size={13} />}
disabled={disabled}
onClick={() => onSelect(m)}
>
{t('asr_missing.use', { label: m.label })}
</Button>
) : (
<Button
size="sm"
variant={m.recommended ? 'primary' : 'subtle'}
leading={<Download size={13} />}
disabled={disabled}
onClick={() => onInstall(m)}
>
{t('asr_missing.download', { label: m.label, size: m.size_gb })}
</Button>
)}
</li>
))}
</ul>
</section>
);
})}
</div>
);
}
@@ -6,6 +6,7 @@ import { Button, Dialog } from '../ui';
import {
acknowledgeBackendCrash,
crashAge,
crashCauseHint,
describeCrashExit,
getUnacknowledgedBackendCrash,
hasCrashEvidence,
@@ -77,6 +78,16 @@ export default function BackendCrashNotice() {
// backend log instead; a real crash (exit code, signal, or a captured tail)
// keeps the one-click path.
const reportable = hasCrashEvidence(marker);
// What the exit code and the captured tail actually mean, and what to do
// about it (#1927). The classifier already knew a native fault points at a
// GPU driver that disagrees with the bundled CUDA runtime, exit 78 at a port
// conflict, an import traceback at a half-built environment but until now
// the only surface that showed it was a dropped stream. A user who opened
// this dialog after "Backend died (exit code -1073741819)" got a raw number,
// a timestamp and a log they cannot read, with nothing to try. A sentinel
// marker is excluded on purpose: it does not know a crash happened at all,
// so it has no cause to explain.
const cause = sentinel ? '' : crashCauseHint(marker);
return (
<>
@@ -156,6 +167,7 @@ export default function BackendCrashNotice() {
? t('crash.details_intro_unclean', { ago })
: t('crash.details_intro', { exit, ago })}
</p>
{cause && <p className="m-0 text-[length:var(--text-sm)] text-fg">{cause}</p>}
{!reportable && (
<p className="m-0 text-[length:var(--text-sm)] text-fg-muted">
{t('crash.report_needs_log')}

Some files were not shown because too many files have changed in this diff Show More