Compare commits

..
100 Commits
Author SHA1 Message Date
a721c1fdcf release: freeze v0.3.14 — version bump, lockfiles, changelog (#1027)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 09:12:24 +05:30
80f10289fe feat(asr): ASR engines get the same Settings picker TTS has (env var still wins) (#1026)
Settings → Engines now stacks one pinned Engine Compatibility Matrix per
family (TTS, ASR, LLM) instead of a single TTS-titled table with the other
families tucked behind a low-discoverability tab. The backend select/prefs
path (family="asr" → prefs.asr_backend, env > prefs > auto-detect) already
worked but was unexercised and undocumented — it's now locked by API and
resolution-order tests, and README + the openai-compat-asr doc stop
promising a picker that didn't exist / denying one that now does.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 04:43:16 +05:30
a0ad314736 docs: CLAUDE.md refresh — de-rot versions, compress shipped stack research, replace the dead GSD gate (#1025)
Three classes of staleness that actively misled agent sessions:

- The Project section hardcoded "latest stable v0.3.5 / main at v0.3.6"
  — eight releases behind. Now points at the sources of truth
  (frontend/package.json, the Releases page) and documents the current
  AUTO_VERSION_BUMP-off holding behavior instead of a version literal
  that rots every release.
- ~165 lines of May-2026 stack research for five capabilities that have
  ALL since shipped (HF-token panel, prefilled-URL bug reporting, uv
  mirror fallback, Supertonic-3, in-repo docs). Compressed to the
  durable don'ts it established (no telemetry endpoints, no app-side
  GitHub tokens, no setx, no MkDocs, no hf_transfer) plus a pointer to
  prefer what's already pinned.
- The GSD Workflow Enforcement gate referenced /gsd-quick//gsd-debug/
  /gsd-execute-phase skills that exist nowhere in this environment; the
  owner explicitly chose direct edits over restoring them (2026-07-08).
  It cost a real mid-task detour when a subagent correctly refused to
  work under an unsatisfiable rule. Replaced with the owner decision
  and the working conventions that actually bind (merge gating,
  check-the-PR-queue-first).

244 → 83 lines. GSD section markers preserved so the generating tool
can still find its blocks.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 04:25:14 +05:30
1808a373a1 fix(linux): AppRun workaround detection reads the BUNDLED WebKitGTK version, not the host's (#961 follow-up) (#1024)
The launcher decided whether to export WEBKIT_DISABLE_COMPOSITING_MODE
by asking the host's pkg-config — but LD_LIBRARY_PATH makes the
BUNDLED libwebkit2gtk the one that actually runs, so on any machine
where the two diverge the detection read the wrong number. This was
the second bug identified during #961's investigation (the reporter
built from source, so their dev packages answered pkg-config with a
healthy version while the shipped bundle ran an older lib) and was
explicitly deferred in #1007 as not-safely-fixable at runtime.

The fix makes it knowable by construction instead: inject-apprun.sh
runs at bundle time ON the build host whose libwebkit2gtk gets
bundled, so it stamps that version into .bundled-webkitgtk-version
inside the AppDir. AppRun reads the stamp first and only falls back to
host pkg-config for bundles predating it. Empty/unreadable stamp fails
safe (workaround on), same philosophy as the missing-pkg-config path.

Tests: 3 new cases in AppRun.test.sh — marker-beats-host in both
directions (broken-marker/healthy-host and the #961 inversion,
healthy-marker/broken-host) plus empty-marker fail-safe. Also wires
AppRun.test.sh into pytest (tests/test_apprun_launcher.py) — it was
previously run by NO CI job, so the launcher could regress silently.

Also documents Windows install-to-another-drive behavior in
docs/install/windows.md (#938): local drives work via the wizard's
directory picker, mapped network drives are a Windows Installer
limitation, and the data directory moves independently of the app.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 03:53:46 +05:30
67789fb31c release: freeze v0.3.13 — version bump, lockfiles, changelog (#1023)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 02:52:25 +05:30
da4bef8e42 docs: correct troubleshooting §16 — mic bug was a missing entitlement, not an upstream limitation; changelog for #1016/#1020/#1021 (#1022)
troubleshooting.md §16 claimed the macOS microphone-permission bug was
an unresolved upstream Tauri/wry limitation with no available fix.
That was wrong: @MahdiHedhli read the wry/tauri sources more carefully
and found the real cause — Tauri's Hardened Runtime default blocks mic
hardware access without com.apple.security.device.audio-input in the
bundle's entitlements, which also explains why TCC never listed the
app. Their fix (#1016) is merged; §16 now documents the real mechanism,
credits the correction, and keeps the record-elsewhere workaround for
users on ≤0.3.12 builds.

Also brings CHANGELOG [Unreleased] current for the three merges that
lacked entries: #1016 (mic fix), #1020 (shutdown wait 3s→20s), #1021
(CI flaky-trio root cause + guard).

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 02:27:00 +05:30
0076d0067e test(ci): root-cause and neutralize the flaky trio — a leaked torch fp16 default dtype (#1021)
test_effects_chain / test_generation_audio_guard / test_persona_bundle
failed intermittently on CI (never locally) with identical signatures
across three unrelated PRs today (#1002, #1019, #1016) — costing a
full CI cycle per occurrence and repeatedly muddying merge decisions.

Root cause, confirmed by local reproduction: a leaked
torch.set_default_dtype(torch.float16) from some earlier test in the
CI-Linux ordering. The smoking gun was test_generation_audio_guard's
observed 0.0999755859375 — exactly float16(0.1), i.e.
torch.tensor([0.1, …]) built under a leaked fp16 default. Reproducing
with a simulated polluter locally produced the trio's exact failures:
Pedalboard refuses fp16 audio outright ("only supports 32-bit and
64-bit floating point") and silently returns unmodified audio for
every preset, so test_effects_chain's preset outputs compare
identical; and the fp16 tensor value breaks the sanitize approx-check.

Fix: an autouse conftest guard (same philosophy as the existing
LLM-state isolation guard, #878) that checks torch's default dtype
after every test, resets any leak to float32, and emits a UserWarning
naming the offending test's nodeid — so the actual CI-only polluter
identifies itself in the next CI log instead of being chased blind.
Regression test: a deliberate-leak pair proving reset-between-tests.

Fail-before/pass-after verified: with the guard stashed, a simulated
polluter + the trio reproduced 2/3 failures locally with the exact CI
signatures; with the guard active, 73/73 pass and the warning names
the polluter.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 02:19:07 +05:30
Mahdi Hedhli faf34348c8 fix(macos): add microphone/camera entitlements so TCC ever sees a request (#1016)
Root cause of #1013 (macOS: "Microphone access denied" but OmniVoice never
appears in Privacy & Security → Microphone to enable it):

Tauri's macOS bundle config defaults `hardenedRuntime` to true, and Hardened
Runtime blocks camera/microphone hardware access unless the matching
entitlement is present — regardless of Info.plist's NSMicrophoneUsageDescription
(that only supplies the *prompt text*, it isn't itself the grant) and
regardless of wry's own WKUIDelegate already granting the request at the
WebKit/JS layer (WryWebViewUIDelegate::request_media_capture_permission
unconditionally calls WKPermissionDecision::Grant — confirmed by reading
wry 0.55.1's source; that part was never the problem). With Hardened Runtime
on and zero entitlements, TCC never registers a request at all, which is
exactly the reported symptom: nothing to enable because the OS never saw a
legitimately-entitled process ask. This also explains the workaround in
#1013 and its comments (launching the raw binary from Terminal works, but
as Terminal's identity, not the app's) — Terminal is a properly entitled,
hardened-runtime process; the ad-hoc/unentitled app binary isn't.

Adds src-tauri/entitlements.plist (com.apple.security.device.audio-input,
plus com.apple.security.device.camera matching the forward-looking
NSCameraUsageDescription already in Info.plist) and wires it in via
tauri.conf.json's bundle.macOS.entitlements. Also corrects the stale
"nothing to do here" module comment in lib.rs that documented the
incomplete assumption this bug falsified.

Verified: built a debug .app (`tauri build --debug --bundles app`) and
diffed `codesign -dv --entitlements -` before/after this change — the
entitlements dictionary goes from absent to containing exactly the two
keys added here, alongside the runtime (Hardened Runtime) flag that was
already on. `cargo test` — 60 passed, 0 failed.
2026-07-09 02:15:28 +05:30
69ce697ee5 fix(backend): shutdown wait bound 3s→20s — post-merge review finding on #1002; absorb #1015's design-path test (#1020)
Greptile's review of the merged #1002 flagged a real residual gap: a
cold transformers import alone can exceed the 3s shutdown wait, and
cancelling the asyncio task doesn't stop the underlying OS thread —
so quitting during an unusually slow preload could still let shutdown
report "done" while that thread was alive, the exact #1000 class with
lower odds. Python cannot forcibly kill a running thread, so no finite
bound eliminates this outright; 20s shrinks the window from "any
preload" to "an unusually slow cold-import," the practical ceiling
before a long shutdown becomes its own complaint. New source-level
contract test pins the production bound at ≥15s so a future edit
can't quietly shrink it back without deliberate consideration.

Also absorbs the one test case from community PR #1015 (superseded by
the earlier-merged #1017, which duplicated it — my fault for not
checking the PR queue) that the merged version lacked: the
design/instruct path with no ref kwargs at all stays untouched by the
ref_text forwarding fix.

Co-authored-by: mergetest <test@local>
Co-authored-by: MahdiHedhli <noreply@github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 02:10:06 +05:30
93a3cb260a fix(ui): dub editor play button no longer sticks disabled; remove donate heart from nav rail (#1019)
WaveformTimeline's play button (disabled={!ready}) stayed permanently
disabled whenever the initial WaveSurfer decode failed and the
component fell back to loading pre-computed peaks. The waveform still
rendered fine from those peaks (nothing looked visibly broken), but
`ready` was only ever flipped by the 'ready' event re-firing on that
recovery load — which this component's own error-handling never
actually confirmed, just assumed. Each of the three fallback ws.load()
calls now explicitly confirms readiness once it settles (via .then()/
.catch(), or the existing synchronous-throw catch), instead of hoping
the event fires again.

Regression test: WaveformTimeline.readyFallback.test.js — a
source-level contract guard (driving a real decode-failure/recovery
sequence through jsdom is brittle, same house pattern as the sibling
WaveformTimeline.unlock.test.js) asserting every fallback load in the
error handler is followed by an explicit setReady(true).

Also removes the "Support OmniVoice" heart button from NavRail — the
donate page stays reachable from Settings' footer and the Contact page.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 02:09:54 +05:30
33379890ad fix(voice): free-text instruct now filtered before every generate/save call (#1010) (#1018)
buildDesignInstruct() already keeps Studio's design/clone generate
calls (useTTS.js) from round-tripping a 400 "Unsupported instruct
items" — it filters free-text against the active engine's supported
vocabulary client-side, with a toast instead of a failed request. Three
other call sites built their own instruct string directly and skipped
it entirely:

- handleSegmentPreview (Dub tab's per-segment preview) — instruct comes
  straight from segment/preset data; a preset's raw attrs merged with a
  free-text style field can carry phrases outside the vocabulary.
- handleSaveProfile / handleSaveHistoryAsProfile — both always create a
  kind='clone' profile; the backend only sanitizes instruct on save for
  kind='design' (see profiles.py's heal_design_instruct branch), so a
  clone profile could silently persist an unusable instruct and then
  400 every single time it's later used to generate.

All three now filter through the same buildDesignInstruct({}, instruct)
call useTTS.js's own clone path already uses, with the same
unsupported/duplicate-item toasts.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 01:47:06 +05:30
7f8a42ce51 fix(tts): mlx-audio CSM cloning drops ref_text, breaking every clone attempt (#1012, #1013) (#1017)
MLXAudioBackend.generate() reads voice/ref_audio/language/speed from
its kwargs but never extracted ref_text — it was built, then silently
never passed through to self._model.generate(). CSM (sesame.py) only
builds its cloning context when BOTH ref_audio AND ref_text are
present; with ref_text missing, the context list stays empty and
indexing into it raises "IndexError: list index out of range" deep
inside mlx-audio, instead of the clone ever being attempted. Voice
cloning on the CSM engine could never have worked as shipped.

generation.py already threads ref_text all the way through — even
auto-transcribing it via the GPU pool when the caller supplies
ref_audio without one (~line 780) — so the value was always available
in kwargs; it just never survived the crossing into this specific
backend.

Reported with the precise root cause and a working fix (community
member independently diagnosed and patched it locally, confirmed
working on MPS/0.3.12). Two-line fix: extract ref_text and pass it
through when both ref_audio and ref_text are present (guards against
passing an orphaned ref_text with no accompanying audio to engines
that don't expect it).

Tests: tests/test_engines.py — ref_text is passed through when paired
with ref_audio, omitted when ref_audio is absent.

Also documents the second bug from the same report (#1013): macOS
microphone permission never prompts, so OmniVoice never appears in
System Settings to grant access. Root-caused to an unresolved upstream
Tauri/WebKit limitation (WKWebView's requestMediaCapturePermissionFor
delegate — wry#1195, tauri#11951, fix wry#1196 still open/unmerged, no
released version to bump to) — not something fixable here without an
unverified native Rust/WKWebView hack this session has no way to test.
Documented in docs/install/troubleshooting.md with the confirmed
workaround (record elsewhere, upload the file).

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 01:34:18 +05:30
4de3c824d0 fix(gallery): surface the real error instead of a generic guess (#1009)
Found while triaging Discord: a community member (lehoangan227) hit
"Could not create that voice — the engine may be loading" trying to
use an archetype from the Gallery. That message is hardcoded and
shown for ANY failure — the actual cause (a 500, a validation error,
anything) is caught and discarded.

api/client.js's ApiError already builds a clean, user-facing message
for every failure mode (HTTP status + backend detail, a network
failure, or a detected backend crash) — this codebase's own
established convention elsewhere is to interpolate that message via
`{{message}}` (see BatchQueue.jsx, Settings.jsx, ToolsPage.jsx). The
Gallery's own catch blocks just weren't following it.

Fixed the whole class across VoiceGallery.jsx (use/preview),
CommunityZone.jsx (add-to-voices, whose catch clause didn't even bind
the error), and ImportsZone.jsx (search/upload/save/delete/trim —
handleDelete previously failed completely silently, no message at
all). All now interpolate the real error message, matching the
gallery.download_failed key that already did this correctly a few
lines away.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 18:45:34 +05:30
11fbbd3f6d fix(dub): cross-language dub no longer speaks source-language reference text verbatim (#1004) (#1008)
extract_speaker_clones/extract_segment_refs pair each audio slice (cut
at ASR segment timestamps) with that segment's own `text` field, on
the assumption the two agree. They routinely don't — Whisper (and
friends) frequently drift on segment boundaries: a trailing word
audible in [start, end] but missing from text, or vice versa. When the
(ref_audio, ref_text) pair disagrees, zero-shot TTS prompt-priming
breaks down and the clone can emit the mismatched reference text
itself instead of the target-language line it was asked to speak —
reported with an exceptionally clear root-cause diagnosis and a
working A/B repro (matched pair: clean on the first try; mismatched
pair: wrong language 6/6 times).

Fix (as proposed in the report): re-transcribe each written reference
clip via the already-loaded, already-warm active ASR backend and use
that transcript as ref_text — this guarantees the pair matches by
construction, independent of whether the original segment text was
ever right. Falls back to the original text on any re-transcribe
failure or empty result — never a regression from current behavior,
only ever a fix.

New services.speaker_clone.refine_ref_text (single clip, unit-testable
against a duck-typed fake ASR backend) and refine_ref_texts (batch —
one executor round-trip per whole clones/seg_clones dict rather than
one per reference). Wired into dub_core.py's two clone-extraction call
sites, routed through _gpu_pool to match the established convention
for ASR-backend calls (the model is mid-lifecycle: TTS is offloaded,
ASR is loaded and exclusive, right where the existing per-chunk
transcribe calls already run on this same pool).

Tests: tests/test_speaker_clone_purity.py — 6 new cases covering the
mismatch-correction path, ASR-failure fallback, empty-transcript
fallback, no-backend no-op, and batch behavior (one failing entry
doesn't affect the others). Full backend suite: 2412 passed, 0 failed.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 18:18:15 +05:30
f546f04c8e fix(ci): bump Linux release runner to ubuntu-24.04, fixing stale bundled WebKitGTK (#961) (#1007)
The AppImage bundles whatever libwebkit2gtk-4.1-dev the build runner's
apt repos resolve at build time (see the "Linux system deps" step) —
AppRun's LD_LIBRARY_PATH then makes that bundled copy take priority
over the host's system WebKitGTK at runtime. ubuntu-22.04's version
was stale relative to what current distros (Ubuntu 24.04+, Fedora 44)
ship, which is why a from-source build (linking straight against the
host's healthy system library) worked fine on the exact machine where
the shipped AppImage white-screened — the released binary was running
an older, buggier WebKitGTK under the hood regardless of the host.

Bumped the Linux release matrix entry to ubuntu-24.04, and ci.yml's
Tauri shell-check job to match (its own comment already says "Mirror
release.yml" — now it actually does, so a green PR check accurately
predicts the release build will also succeed).

Raises the AppImage's glibc floor from 2.35 to 2.39 (Ubuntu 24.04+) —
README's system-requirements table corrected from the now-false
"Ubuntu 20.04+" claim. No reports of anyone on a pre-2022 distro.

This does not fix the AppRun launcher's separate, related bug (its
WebKitGTK-version auto-detection reads the *system's* pkg-config
version, not the version actually bundled and running) — that would
need a reliable way to read the bundled .so's version from within the
AppImage, which isn't straightforward (WebKitGTK's soname doesn't map
1:1 to its release version) and isn't verifiable without a real Linux
build environment to test against. Left as a known, separate gap.

Cannot be verified from here on a real Ubuntu 26.04 machine — shipped
on the strength of the root-cause diagnosis, pending the reporter's
confirmation.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:21:50 +05:30
270b3b1c4c fix(backend): quitting mid-preload no longer reports a clean shutdown while a GPU-pool thread is still importing (#1000) (#1002)
A user-pasted backend log revealed the real cause behind a class of
'can't reach backend' reports: three rapid restart cycles, each ending
with 'Shutdown: done.' immediately followed by a 'Model loading failed:
Could not import module AutoFeatureExtractor' error. That error text is
transformers' own generic lazy-import wrapper (import_utils.py's
_LazyModule.__getattr__), not a real dependency problem — pyproject.toml
already pins transformers/torch/torchaudio/soundfile/librosa as core,
non-optional deps, and the same venv loaded the model successfully 90
seconds later in the same log.

Root cause: preload_task and capture_preload_task were created at
startup but never referenced in the lifespan shutdown block — idle_task
and worker_task got cancelled-and-awaited, the preload tasks were simply
abandoned. Cancelling an asyncio task awaiting run_in_executor() can't
stop the underlying OS thread once it's inside blocking import/load
work, so 'Shutdown: done.' logged while a GPU-pool thread was still
mid-Version: ImageMagick 7.1.2-25 Q16-HDRI aarch64 037e46295:20260604 https://imagemagick.org
Copyright: (C) 1999 ImageMagick Studio LLC
License: https://imagemagick.org/license/
Features: Cipher DPC HDRI Modules
Delegates (built-in): bzlib freetype heic jng jpeg lcms ltdl lzma png tiff webp xml zlib zstd
Compiler: clang (21.0.0)
Usage: import [options ...] [ file ]

Image Settings:
  -adjoin              join images into a single multi-image file
  -border              include window border in the output image
  -channel type        apply option to select image channels
  -colorspace type     alternate image colorspace
  -comment string      annotate image with comment
  -compress type       type of pixel compression when writing the image
  -define format:option
                       define one or more image format options
  -density geometry    horizontal and vertical density of the image
  -depth value         image depth
  -descend             obtain image by descending window hierarchy
  -display server      X server to contact
  -dispose method      layer disposal method
  -dither method       apply error diffusion to image
  -delay value         display the next image after pausing
  -encipher filename   convert plain pixels to cipher pixels
  -endian type         endianness (MSB or LSB) of the image
  -encoding type       text encoding type
  -filter type         use this filter when resizing an image
  -format "string"     output formatted image characteristics
  -frame               include window manager frame
  -gravity direction   which direction to gravitate towards
  -identify            identify the format and characteristics of the image
  -interlace type      None, Line, Plane, or Partition
  -interpolate method  pixel color interpolation method
  -label string        assign a label to an image
  -limit type value    Area, Disk, Map, or Memory resource limit
  -monitor             monitor progress
  -page geometry       size and location of an image canvas
  -pause seconds       seconds delay between snapshots
  -pointsize value     font point size
  -quality value       JPEG/MIFF/PNG compression level
  -quiet               suppress all warning messages
  -regard-warnings     pay attention to warning messages
  -repage geometry     size and location of an image canvas
  -respect-parentheses settings remain in effect until parenthesis boundary
  -sampling-factor geometry
                       horizontal and vertical sampling factor
  -scene value         image scene number
  -screen              select image from root window
  -seed value          seed a new sequence of pseudo-random numbers
  -set property value  set an image property
  -silent              operate silently, i.e. don't ring any bells
  -snaps value         number of screen snapshots
  -support factor      resize support: > 1.0 is blurry, < 1.0 is sharp
  -synchronize         synchronize image to storage device
  -taint               declare the image as modified
  -transparent-color color
                       transparent color
  -treedepth value     color tree depth
  -verbose             print detailed information about the image
  -virtual-pixel method
                       Constant, Edge, Mirror, or Tile
  -window id           select window with this id or name
                       root selects whole screen

Image Operators:
  -annotate geometry text
                       annotate the image with text
  -colors value        preferred number of colors in the image
  -crop geometry       preferred size and location of the cropped image
  -encipher filename   convert plain pixels to cipher pixels
  -extent geometry     set the image size
  -geometry geometry   preferred size or location of the image
  -help                print program options
  -monochrome          transform image to black and white
  -negate              replace every pixel with its complementary color
  -quantize colorspace reduce colors in this colorspace
  -resize geometry     resize the image
  -rotate degrees      apply Paeth rotation to the image
  -strip               strip image of all profiles and comments
  -thumbnail geometry  create a thumbnail of the image
  -transparent color   make this color transparent within the image
  -trim                trim image edges
  -type type           image type

Miscellaneous Options:
  -debug events        display copious debugging information
  -help                print program options
  -list type           print a list of supported option arguments
  -log format          format of debugging information
  -version             print version information

By default, 'file' is written in the MIFF image format.  To
specify a particular image format, precede the filename with an image
format name and a colon (i.e. ps:image) or specify the image type as
the filename suffix (i.e. image.ps).  Specify 'file' as '-' for
standard input or output., and interpreter finalization tore down module
state under it — producing exactly this misleading error.

Fix: extract the existing cancel+bounded-await pattern into
_cancel_and_await_tasks() and apply it to all four background tasks, not
just two. An early-stage load (still importing, not yet mid weight-
download) now gets a real chance to finish before shutdown proceeds; a
load genuinely deep in blocking work still times out at the same 3s
bound, and _reset_gpu_pool() abandons it same as before. Also: both
error handlers around this path logged only str(exc), discarding
__cause__ — added exc_info so a future incident (even one this fix
doesn't fully prevent) surfaces the real underlying error instead of the
misleading generic wrapper text.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:09:18 +05:30
5a7d9cc05c feat(asr): generic OpenAI-compatible transcription backend (#877) (#1003)
First slice of the community's two-track proposal for #877: a generic
OpenAI-compatible ASR backend that works TODAY, without waiting on
transformers to ship a direct Qwen3-ASR integration (tracked separately,
still blocked upstream). Points OmniVoice's transcription at any server
exposing POST /v1/audio/transcriptions — a self-hosted Qwen3-ASR/
FunASR/SenseVoice server, or OpenAI's own API.

- New OpenAICompatASRBackend (backend/services/asr_backend.py): a pure
  network client, no local model, no install. Prefers
  response_format=verbose_json for real per-segment timestamps,
  degrades to plain text (matching MoonshineASRBackend's shape) when a
  minimal server rejects that format. Never leaks a raw SDK/httpx
  exception to the caller (#977 convention) — wraps network/auth
  failures in a clean, actionable RuntimeError naming the server.
- Settings persist via the same encrypted-secret convention as
  services/llm_providers.py (settings_store.set_secret for the API key
  — Fernet-encrypted, never a .env row, never echoed back; get_text/
  set_text for base_url/model). New GET/PUT /api/settings/
  asr-openai-compat, loopback-gated like every other settings route.
- Frontend: a small settings panel (Settings → Models) mirroring
  HFMirrorPanel's exact structure. No ASR engine picker exists yet for
  ANY ASR backend (only TTS has one) — activating this engine still
  needs OMNIVOICE_ASR_BACKEND=openai-compat-asr; documented plainly
  rather than pretending otherwise.
- README's ASR Engines table (9 → 10 engines) and docs/features.yaml's
  drift-checker inventory updated; the '9 engines, all fully local'
  claim corrected since this one genuinely isn't.
- docs/engines/openai-compatible-asr.md: setup steps + an explicit
  privacy note (unlike every other ASR engine, audio leaves the
  machine to whatever server is configured).

Regression tests: tests/test_asr_openai_compat_877.py (12 tests) —
is_available() gating, verbose_json + plain-text response adaptation,
network-failure error hygiene, SDK retry disabling, and the settings
endpoints' persist/mask/clear-vs-unchanged semantics.

Fixed two real full-suite-only failures found during verification (not
brushed aside): the API route inventory snapshot needed regenerating
for the two new routes, and this file's own tests had a module-
staleness bug — a collection-time settings_store import went stale
relative to a test-time-fresh fixture when another test elsewhere in
the ~2400-test suite reimports the module — fixed by making
settings_store itself a fixture resolved at test-run time, same
lesson already applied to tests/test_mm2_lifecycle.py earlier this
session.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 08:33:03 +05:30
5871ec9d28 release: freeze v0.3.12 — version bump, lockfiles, changelog (#1001)
19-issue GitHub sweep: 16 PRs merged since v0.3.11, most fixing reports
filed same-day or in the days prior. Highlights: MLX-Audio's 6 other
curated models are finally selectable (was silently stuck on Kokoro
regardless of what was downloaded), first-run no longer dead-ends behind
restricted networks or corporate TLS-inspecting proxies, dubbing/batch
TTS honor your active engine selection, and a run of sharp community
diagnoses (ROCm wheel index, Windows dictation focus-steal, a genuine
frontend crash regression) got fixed largely because reporters did the
hard diagnostic work themselves.

Full backend suite: 2390 passed, 0 failed. Full frontend suite: 918
passed, 0 failed. Version lockstep (tests/test_app_version.py): 6/6
passed. Docker frozen-lockfile parity (bun install --frozen-lockfile):
clean, no drift.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 07:28:41 +05:30
58b023ff16 fix(diagnostics): TTS generate timeout message tells you to Flush/Unload (#939) (#999)
The guard itself (#851/#896) is working as designed — this is a message
gap, not a logic bug. The GPU-branch timeout guidance explained VRAM
contention but never mentioned the Flush/Unload action that actually
resolves it, even though: (a) that action already exists (POST
/system/flush-memory, wired to the header's Flush button), and (b) the
sibling ASR-timeout guard's message already recommends it verbatim
(asr_backend.py's _CUDA_VRAM_BUDGET_GB guidance). The maintainer ended up
manually explaining 'Settings → Models → Flush caches / Unload' in an
issue thread reply — information the error message should have carried
itself.

String-only change, no control-flow touched, mirrors the exact precedent
of #896 (a guidance-only change to this same function).

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 05:18:00 +05:30
b61d17dd61 fix(diagnostics): filter Tauri's benign IPC-fallback warning from frontend log capture (#975) (#998)
On some Windows configurations, Tauri's custom-protocol IPC probe fails
once at startup and Tauri logs a console.warn before silently — and
successfully — falling back to postMessage + WebSocket. Fully functional,
happens at most once per launch, and not a bug in our code (confirmed:
this is Tauri's own internal fallback mechanism, structurally intentional
across its recent 2.11.x releases, not something being actively patched
upstream — so not bumping the framework speculatively for this).

It IS real noise though: as a captured console.warn it spuriously flips
the Settings > Logs footer's Frontend pill to "1 warning" on every
affected Windows launch. Filtered at the capture source (consoleBuffer.js)
rather than the display layer, so it never enters the ring buffer or a
copied diagnostic dump either — narrowly scoped to this one known message
prefix, not a general warning-suppression mechanism.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 05:17:12 +05:30
93aab6dadc docs(linux): mention yt-dlp as an optional prerequisite (#973) (#997)
The preflight system check already warns in-app when yt-dlp is missing
(Voice Gallery/Dub YouTube downloads fail without it), but the install
docs never mentioned it — a user has to hit the in-app warning first
instead of seeing it up front alongside the other optional prereqs.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 04:42:00 +05:30
7098be1c8b fix(dictation): Windows pill no longer steals foreground focus (#982) (#996)
Root-caused and verified against the actual code (community diagnosis in
#982 was accurate): showing the dictation pill — an always-on-top top-level
WebviewWindow — grants it Win32 foreground activation by default on
Windows, which macOS doesn't do for a shown window. Nothing marked the pill
non-activating, so on Windows the synthesized Ctrl+V from `simulate_paste`
landed back in the pill instead of the app being dictated into. Code review
also found a second, more direct culprit at the same call sites: the
global-shortcut Pressed handler's `win.set_focus()` was only skipped on
macOS (`#[cfg(not(target_os = "macos"))]`), so Windows explicitly focused
the pill on top of the implicit Win32 activation; the tray "dictate" menu
handler called `win.show(); win.set_focus();` unconditionally on every
platform, including Windows.

This is the Windows counterpart of #287 (macOS auto-paste — don't steal
focus): same class of bug, same "pill must stay unfocused so the paste
lands in the target app" intent (already stated in the `grant_webview_
media_permissions` module comment), different OS mechanism.

Fix, mirroring #287's platform-cfg pattern:
  1. WS_EX_NOACTIVATE applied to the pill's HWND once, right after creation
     (`mark_pill_noactivate`), via the `windows` crate pinned to the exact
     0.61.x tauri itself already resolves to — Cargo.lock confirms this
     unifies to the same `windows 0.61.3` already in the graph, so
     `WebviewWindow::hwnd()`'s HWND and our Win32 calls share one type and
     no new crate version was added.
  2. `ShowWindow(SW_SHOWNOACTIVATE)` (`show_pill_noactivate`) in place of
     `.show()` at the two dictation-trigger call sites (global shortcut +
     tray "Start Dictation"), since `.show()` alone still raced the style
     bit on some paths.
  3. The explicit `set_focus()` calls at those same two sites are now
     skipped on Windows too, the same way they already were on macOS.

macOS and Linux are untouched: the macOS cfg branch is unchanged, and the
Linux branch of the `set_focus()` guard still runs exactly as before.

The pill's auto-dismiss (`scheduleDismiss`/`dismiss` in CaptureWidget.jsx)
was checked and is a plain unconditional setTimeout chain — it is not
gated on any native focus-loss/paste-completion signal, so there's no
independent bug to fix there. The "never dismisses" symptom is a
consequence of the focus-steal, not a separate stall: once the pill wrongly
held foreground for the whole session, hiding it later left Windows'
foreground state inconsistent. With the pill never taking focus, the
target app stays foreground throughout and there's nothing to reconcile.

Win32 window-activation syscalls (`#[cfg(target_os = "windows")]`) can't
run under `cargo test`/`cargo build` on this non-Windows CI runner, so the
new `pill_noactivate_tests` module tests the pure flag math
(`with_noactivate_style`) instead — platform-agnostic, runs everywhere,
verified passing here. The actual HWND-touching code is logic-reviewed but
UNVERIFIED on real Windows; the reporter offered to test a patched build,
which is the recommended next step before this ships in a release.

`cargo build` and `cargo test --lib` both pass (60/60 tests, including the
3 new ones); the pre-existing `setup.rs` unreachable_code warning (#286) is
unrelated.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 04:41:25 +05:30
8520b84b68 fix(clone): voice-design panel no longer crashes on a partial vd_states shape (#983) (#995)
Crash: DesignMethodPanel's optLabel() called val.replace(...) on an
undefined category value — a regression from f678e33, which swapped a
safe plain template literal for an i18n lookup that assumes vdStates[key]
is always a string. Both occurrences (the label kicker and the chip
list) are now guarded, falling back to 'Auto' the same way the rest of
the component treats an unset category.

Root cause: vdStates could actually go partial in real usage. Selecting
a design profile (useProfiles.js) or restoring legacy localStorage state
(useAppData.js) applied the backend/stored vd_states object as-is, with
no check that all 6 CATEGORIES keys were present — so an older client,
hand-edited payload, or partial API write reproduced the crash on
selection. Both call sites now run the restored object through
mergeDescribedAttrs() (voiceInstruct.js), the existing completion helper
already used for the "describe your voice" path, which fills any
missing/unknown category with 'Auto'. useAppData.js also gained the
typeof === 'object' guard useProfiles.js already had.

Closes the class at the source: POST /profiles now completes vd_states
against CATEGORY_ORDER (core/describe_voice.py, the same list the
frontend's CATEGORIES mirrors) before persisting, so a design profile
can never be *saved* with an incomplete shape regardless of which
client wrote it — updated two existing tests whose fixtures asserted
the old (partial) persisted shape.

Regression tests: DesignMethodPanel render test with a partial vdStates
input, a mergeDescribedAttrs unit test for the exact partial shape from
the issue, and a backend test asserting POST /profiles fills all 6 keys.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 04:34:14 +05:30
549fa4009f feat(engines): expose MLX-Audio's curated model picker (#981) (#994)
mlx-audio multiplexes 7+ curated models (Kokoro, CSM, Qwen3-TTS, Dia,
Chatterbox, MeloTTS, OuteTTS) behind a single "mlx-audio" backend id, but
MLXAudioBackend resolved its active model ONLY from the
OMNIVOICE_MLX_AUDIO_MODEL env var — invisible to Settings and unreachable
without restarting the packaged app with that var set. A user who
downloaded e.g. Llama-OuteTTS via Settings → Models had no way anywhere
in the UI or API to actually load it; the backend silently kept using
Kokoro.

Fix:
- MLXAudioBackend.__init__ now resolves its model via
  prefs.resolve("mlx_audio_model_id", env=..., default=...), mirroring
  active_backend_id()'s env > prefs > default order exactly.
- get_active_tts_backend()'s switch-detection now also tracks the
  resolved mlx-audio model key, so a model-only change (same backend id)
  invalidates the cached instance and reconstructs it — no app restart
  needed to pick up a different curated model.
- POST /engines/select gained an optional model_id field; for
  family=tts/backend_id=mlx-audio it validates against
  MLXAudioBackend.CURATED_MODELS (or a raw HF repo id, matching the
  class's existing tolerance) and persists it via prefs.
- GET /engines now includes a curated_models roster + active_model_id on
  the mlx-audio entry only.
- Settings → Engines renders a small model dropdown on the mlx-audio row,
  pre-selected to the active model, wired through selectEngine's new
  optional modelId argument.

Regression coverage: prefs resolution + env override, cache invalidation
on model-only switch, /engines/select 400s on an unknown model id and
persists a valid one, curated_models present only on mlx-audio, and a
new EngineCompatibilityMatrix vitest suite for the dropdown.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 04:32:24 +05:30
dfe2bd1bc3 fix(install): classify SSL handshake failures + trust the OS cert store (#976) (#992)
Windows users behind a corporate/antivirus TLS-inspecting proxy got a raw
`[SSL: SSLV3_ALERT_HANDSHAKE_FAILURE]` on every model install — the TCP
connection reaches the server fine, but the handshake fails because the
OS trusts the proxy's re-signed root CA and Python's bundled certifi CA
list doesn't. A genuinely different failure mode from #984 (that was
TCP-level unreachability to a blocked host, before any TLS negotiation).

- backend/core/failure.py: new SSL_HANDSHAKE_FAILURE classification
  (handshake/cert-verify-failed/sslv3_alert/sslcertverificationerror
  substring markers) with an actionable hint, added to
  _CONTEXT_FREE_HINT_CLASSES so append_hint() (already called by
  setup/download.py's install worker) surfaces it without further wiring.
- backend/main.py: truststore.inject_into_ssl() at module level, before
  any huggingface_hub/requests/httpx network I/O — patches ssl.SSLContext
  to verify against the OS trust store instead of only certifi's bundled
  CA list. Not platform-gated (correctness improvement everywhere);
  wrapped in try/except so it never blocks startup.
- pyproject.toml/uv.lock: truststore>=0.9 — pure Python, MIT, PyPA-
  maintained, zero transitive deps, same class of fix as socksio.

Verified: uv lock --check + uv sync --frozen clean (lockfile diff is
just the one new package); main.py imports cleanly; full backend suite
passes; no hiddenimports entry needed (main.py is PyInstaller's direct
entry script per backend.spec, so a top-level import traces normally —
unlike socksio's case, which was httpx's internal lazy import).

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 04:29:21 +05:30
324d417d27 fix(engines): mlx-audio no longer crashes on unsupported languages, error messages never leak raw exception internals (#977) (#993)
Root cause: MLXAudioBackend.generate() blindly truncated the full
language display name to two characters (language[:2].lower()),
assuming an ISO code — 'Dutch' -> 'du', which crashed Kokoro's vendored
pipeline's internal assertion (assert lang_code in LANG_CODES, (lang_code,
LANG_CODES)) for any language whose first two letters didn't coincidentally
match one of Kokoro's single-letter codes. The raw AssertionError's
tuple-containing-a-dict args then leaked straight into the user-facing
500 message via two stacked f"...{e}" formatters in generation.py.

- resolve_kokoro_lang_code() resolves against the AUTHORITATIVE
  ALIASES/LANG_CODES table read from the installed mlx_audio package
  (never a hardcoded guess), and only applies when Kokoro is the actual
  active curated model — other curated models (CSM, Dia, Qwen3-TTS,
  OuteTTS, ...) either ignore the kwarg or expect a different format, so
  Kokoro's strict validation doesn't wrongly reject them. Unsupported
  languages now raise a clear ValueError naming what Kokoro supports,
  which generation.py already converts to a clean 400.
- _safe_exc_text() hardens both generic exception formatters in
  generation.py: if any element of an exception's .args is a container
  (dict/list/tuple/set), never interpolate str(e) raw — name the
  exception type and point at the log instead. Protects every current
  and future engine's generate() from leaking a raw container repr, not
  just this one Kokoro assertion.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 04:29:01 +05:30
637020b82b fix(engines): nemo-parakeet install hint stops recommending a shared-venv-breaking pip install (#974) (#991)
The Engines page told users to run `pip install nemo_toolkit[asr]` for
the NeMo Parakeet ASR engine. nemo_toolkit[asr]==2.7.3 hard-pins
transformers>=4.57,<4.58, which is unsatisfiable alongside OmniVoice's
own transformers>=5.3 requirement (needed by
omnivoice/models/omnivoice.py for HiggsAudioV2TokenizerModel). A user
who followed the hint ended up with a backend that wouldn't start
(ImportError: cannot import name 'HiggsAudioV2TokenizerModel').

_INSTALL_HINTS["nemo-parakeet"] in backend/services/asr_backend.py now
states plainly that installing into the shared venv will break the
backend, names the transformers conflict, and tells users to use a
separate/dedicated Python environment instead — without implying a
safe one-line fix or an isolated-venv env var exists (unlike
dots-tts/moss-tts-v15/confucius4-tts, nemo-parakeet has no isolated
venv option yet; that's a separate, larger follow-up).

Also adds one sentence to docs/install/troubleshooting.md's existing
"engine venv clash" section (#11) pointing at the same class of issue
on the ASR side, and a regression test asserting the hint never again
contains the literal bare `pip install nemo_toolkit[asr]` string.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 04:26:12 +05:30
dab6456581 docs(linux): stop advertising a .deb package that isn't published (#990)
README's Quickstart badges linked a 'Download Debian .deb' button
straight to the releases page — but .deb bundling was deliberately
dropped from release.yml (tauri-cli bug, 'Failed to create control
scripts') and no release has ever shipped one. A community member
investigating #961 confirmed this by checking the actual release
assets. Users clicking that badge got a broken promise, not a package.

Removed the badge; docs/install/linux.md's '## Install (.deb)' section
now honestly states it's unavailable pending a tauri-cli fix, points to
the AppImage as the supported path, and keeps the historical pre-v0.3
.deb upgrade note (ffprobe conflict) since that's still relevant to
existing installs.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 04:25:40 +05:30
015d29cafb test(voice-design): lock in RTL/non-Latin script handling for the instruct field (#980) (#989)
Issue #980 reported a raw 400 for Hebrew text ('שמואל') typed into the
Clone tab's Style field. Investigated: this is the same failure class
as #612 (Vietnamese free-text) and was already fixed when #612 landed
in commit 10b9d69 (first released v0.3.8) — buildDesignInstruct() drops
any unsupported free-text client-side before it ever reaches the
backend's validator, regardless of script. The reporter was on v0.3.7,
which predates that fix.

No behavior change needed — only a regression test, since the existing
Vietnamese test case covered Latin-script-with-diacritics but nothing
exercised a right-to-left / non-Latin script specifically.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 04:25:24 +05:30
1a03c59f82 fix(install): AMD ROCm torch reinstall targets rocm6.4, not rocm6.2 (#988)
Community-diagnosed (issue #972, Kaihui-AMD): pyproject.toml pins
torch==2.8.0, but the rocm6.2 wheel index only ever published up to
2.5.1 — the reinstall silently failed to resolve and fell back to the
default CUDA build, which runs on CPU on an AMD GPU. The failure was
correctly logged (bootstrap.rs's emit_log warning), just never actioned
because the index itself couldn't succeed. rocm6.4 carries a matching
torch==2.8.0 build.

Docs updated with the corrected index plus a repo.radeon.com find-links
path for users who want a driver-matched ROCm 7.2.x build the PyTorch
index doesn't carry (OMNIVOICE_TORCH_INDEX only accepts a PEP 503 index,
not find-links, so that's documented as a manual step).

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 04:25:00 +05:30
8281b7c798 fix(engines): dub and batch TTS honor the active-engine selection (#987)
* fix(engines): dub and batch TTS honor the active-engine selection — with a real capability gate, not a silent OmniVoice fallback

Dub generation and batch TTS hardcoded services.model_manager.get_model()
(OmniVoice) regardless of the engine picked in Settings → Engines. A user
selecting VoxCPM2 (or any other engine) still got OmniVoice output with no
error — the silent fallback IS the bug class, not just the one report.

Root-caused and fixed for the whole class:
  - New `TTSBackend.supports_cloning` capability flag (default True) marks
    engines that can only offer fixed preset voices — kittentts,
    supertonic3, sherpa-onnx set it False. MLXAudioBackend exposes it as an
    instance property (Kokoro doesn't clone, CSM does) since the adapter
    multiplexes multiple models with different capabilities.
  - `cloning_capable_engine_ids()` and a shared `resolve_generation_backend()`
    helper in services/tts_backend.py centralize engine resolution
    (id → is_available() → routing gate → optional cloning gate), mirroring
    generation.py's /generate resolution instead of inventing a third
    parallel mechanism. Both routers now standardize on the existing
    get_active_tts_backend() cache (unload-on-switch already handled).
  - dub_generate.py's two TTS-generate call sites (main run + OOM retry) and
    the /dub/preview-segment route resolve once, up front, with
    require_cloning=True — dub's ref_audio is populated for essentially
    every real job, so an engine that can't clone fails the whole job with
    one actionable message instead of mis-cloning per segment.
  - batch.py resolves once per job, require_cloning only when voice_id is
    pinned — an unpinned batch job runs fine on any engine.
  - Applied the three pre-existing TODO(#312) comments: mastering now skips
    via `applies_own_mastering` for both pipelines, matching generation.py.

Regression tests cover the capability-id list, the fail-fast gate (proving
no OmniVoice fallback), the success path on a selected non-OmniVoice
engine, batch's pinned-vs-unpinned voice_id behavior, and the mastering
skip for both pipelines. Three existing dub tests that mocked get_model()
directly were updated to mock the new resolver instead.

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

* fix(engines): exclude model-dependent adapters from cloning_capable_engine_ids()

getattr(cls, "supports_cloning", True) at the CLASS level returns a
property descriptor object (always truthy) when the flag is an instance
@property, not a plain attribute — MLXAudioBackend uses exactly this
pattern because its cloning capability depends on which of its 7+ curated
models is loaded (only CSM clones; Kokoro etc. don't). Without this fix,
the dub/batch capability-gate error message would always recommend
'switch to mlx-audio' even when the user's configured MLX model can't
clone, sending them in a circle back to the same error.

isinstance(value, bool) distinguishes a resolved boolean from a
descriptor object, so mlx-audio is excluded from the suggestion list
until its actual per-instance capability can be checked (already handled
correctly by resolve_generation_backend()'s per-call instance check).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): engine-aware dub/batch entry (#987)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 03:42:14 +05:30
efe57ee49d fix(ui): loaded-models panel says when a resident model is not the active engine (#985)
* fix(ui): loaded-models panel says when a resident model is not the active engine (#TBD)

A switched-away TTS model stays resident in VRAM until Unload/Flush or the
idle evictor, so users who picked another engine (e.g. voxcpm2) saw
"OmniVoice TTS - cuda:0 - 1937 MB" in the LOADED MODELS flyout and concluded
synthesis was still routing to OmniVoice. It wasn't - the panel just gave no
hint that resident != active.

/model/loaded entries for TTS-family models (in-process OmniVoice +
subprocess sidecars) now carry engine_id + is_active_engine, computed against
active_backend_id(); attribution failure degrades to the old shape
(is_active_engine: null) and non-TTS entries (ASR, diarization) are left
unannotated. The flyout renders a muted "not active - safe to unload" tag
(i18n: header.model_not_active, en + zh-CN) on inactive entries; Unload/Flush
behavior is unchanged. Regression tests cover both attribution states, the
ASR non-label, and the degradation path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): loaded-models active-engine hint entry (#985)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mm2): string-target monkeypatch for active_backend_id — immune to sys.modules reimports

The two attribution tests patched the collection-time module alias; other
suites pop+reimport services.* modules mid-run, so in full-suite order the
patch landed on a stale module object while _active_tts_id late-imported the
fresh one (CI-only failure). String targets resolve at patch time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 02:41:47 +05:30
7f77f4d7bf fix(audio): remove hidden reverb from the mastering pre-stage — reverb is preset-declared only (#986)
* fix(audio): remove hidden reverb from the mastering pre-stage — reverb is preset-declared only (#TBD)

Field report (Discord): baked-in echo/reverb on some voices. apply_mastering()
hardcoded a Reverb that ran on every non-raw synthesis before the user's
preset chain — broadcast shipped reverb it never declared, podcast broke its
"no reverb" promise, cinematic/warm got doubled reverb.

The mastering pre-stage is now data-driven (MASTERING_CHAIN: highpass +
compressor, same params as before) and reverb-free; cinematic/warm keep their
user-chosen reverb. Regression tests pin the contract, incl. a burst-then-
silence echo-tail check and pedalboard-missing passthrough.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): hidden mastering reverb entry (#986)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 02:26:19 +05:30
087309259b fix(setup): first-run network check is mirror-aware and never hard-blocks (#984)
* fix(setup): first-run network check is mirror-aware and never hard-blocks

Field report (Discord, China): the Launchpad preflight probed hardcoded
huggingface.co:443 and any failure disabled Continue outright — users behind
the GFW were stuck on the very first screen, before Settings (and its
HF mirror quick-pick) was even reachable.

- The probe now targets the HF endpoint actually in effect (HF_ENDPOINT /
  hf_endpoint pref via configured_hf_mirror), with the real port.
- An unreachable endpoint is a WARNING, not a blocker: local-first — cached
  models work offline, and downloads surface their own actionable errors.
- When huggingface.co is blocked but hf-mirror.com answers, the fix text says
  exactly that, and the wizard shows an inline mirror quick-pick (presets +
  custom URL) that applies via PUT /hf-mirror — effective immediately for
  downloads — then re-checks.
- Docs updated (downloading-models, install troubleshooting); regression
  tests cover warn-not-fail, mirror-host probing, and the mirror suggestion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): open [Unreleased] with the preflight mirror fix (#984)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 02:11:18 +05:30
453db55f12 release: freeze v0.3.11 — version bump, lockfiles, changelog (#970)
package.json + three mirrors -> 0.3.11 in lockstep; Cargo.lock/uv.lock/
bun.lock regenerated; CHANGELOG [Unreleased] -> [0.3.11] — 2026-07-05
with the multi-language-release headline; nine entries since v0.3.10.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:28:43 +05:30
620321a9cc feat(diagnostics): backend crashes become self-documenting — exit code + stderr tail surfaced and attached to bug reports (#969)
* feat(diagnostics): backend crashes become self-documenting — exit code + stderr tail surfaced and attached to bug reports

When the backend PROCESS died (native CUDA abort, OOM kill, DLL crash) the
user saw only "Can't reach the local OmniVoice backend" and the evidence
died with the process — every #941-class report needed a logs-please
round-trip nobody answers. The v0.3.9 guard fixed HANGS; this fixes the
class of invisible DEATHS:

- Rust (crash.rs): every unexpected child exit — detected by the startup
  health poll and the post-Ready supervisor — writes a rotating (last 3)
  JSON crash marker next to the backend logs: ts, exit code/signal,
  backend version, uptime, ~40-line stderr tail. Intentional shutdowns
  never forensicate: app-quit raises the quitting flag first (now also on
  macOS Cmd+Q via ExitRequested), and retry/clean-retry kills set a
  BACKEND_KILL_INTENDED flag cleared when the fresh child is tracked.
- Tauri commands get_last_backend_crash / acknowledge_backend_crash;
  ack is a persisted watermark, never a delete — bug reports still get
  the evidence after the user viewed it.
- Crash-loop escalation: the supervisor budget goes 5-in-60s → 3-in-10min
  so slow crash loops stop respawning and land on the Failed screen with
  the last exit code + stderr tail.
- Frontend: apiFetch's transport-failure path swaps the vague message for
  "the backend crashed (exit code X) N s ago…" when an unacknowledged
  marker exists, and BackendCrashNotice (banner + details dialog,
  i18n'd, ack-on-view) surfaces it even with no request in flight.
- Bug-report prefill gains a "Last backend crash" section (exit code +
  home-path-scrubbed stderr tail via the existing scrubText), so the next
  report arrives WITH the evidence.

Tests: cargo --lib 57 pass (marker rotation write-4-keep-3, ack
semantics, store IO, ExitStatus decomposition, 3-in-10min policy);
vitest 909 pass incl. crash-notice branch, client crash-message branch,
bug-report enrichment; legacy node:test 41 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): add backend crash forensics under [Unreleased] (#969)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:36:47 +05:30
738d45f1c7 fix(ui): timeline box colors pre-blended in JS — visible on any WebView2 (#963) (#968)
* fix(ui): timeline box colors pre-blended in JS — visible on any WebView2, color-mix dependency removed (#963)

#951 moved the segment-box palette to `color-mix(in srgb, tint 45%,
var(--chrome-bg))` strings applied as inline styles. WebView2/Chromium
< 111 has no color-mix, so the CSSOM rejects the whole `background`
assignment — and since .seg-track__box declares no background of its
own, the boxes rendered fully transparent on pinned/enterprise WebView2
runtimes (the Windows installer never enforces a minimum runtime).

Fix the class, not the instance: no engine-dependent CSS may reach this
lane's inline styles. The 0.45·tint + 0.55·bg blend now happens in JS —
timeline.js keeps the tints as numeric [r,g,b], reads --chrome-bg off
the document root (fallback #0f1011), and emits literal `rgb(r, g, b)`
strings every engine parses. Pixel-identical to what color-mix painted.
Theme-awareness is preserved by re-blending when [data-theme] changes
on <html> (the seam App.jsx switches themes through), observed via
MutationObserver; SegmentTrack subscribes with useSyncExternalStore so
mounted boxes recolor live.

Guards updated: palette entries must match plain opaque rgb() (no
color-mix/var()/alpha), the default-theme blend is asserted against
independently computed literals, theme-change re-blend and rgb()/
garbage --chrome-bg parsing are covered, and SegmentTrack's rendered
inline background is asserted to be a literal rgb() — fails on any
reintroduction of engine-dependent CSS in this lane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): add WebView2 box-color fix under [Unreleased] (#968)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:28:19 +05:30
d959aae41b fix(dub): dialogue starts stop snapping to footsteps — sustained-energy onsets, bounded snap (#963) (#967)
* fix(dub): dialogue starts stop snapping to footsteps — sustained-energy onsets, bounded snap distance (#963)

Field report #963 (point 3): dubbed speakers start seconds early or late.
The reporter's own theory was right on the money — 'when a noise is heard
(a sigh or footsteps), it's interpreted as the start of the conversation.'
The #280 onset snapper took the FIRST 20 ms frame above an adaptive RMS
threshold as the speech onset, so any transient qualified; it also had no
snap-distance bound (a wrong onset could move a start by the whole segment
minus 0.3 s) and ran even when Demucs had failed and the 'vocals' track was
really the raw mix, where every ambient sound is a candidate.

Three layered guards, all pure NumPy (no new deps):

- Sustained energy: an onset only counts when >=160 ms of the following
  300 ms stays above the threshold. Footsteps/door thuds light up one or
  two frames and die; syllables keep the energy up.
- Bounded snap distance: shifts beyond 1.5 s are only trusted when the
  skipped span is (near-)silent — that is exactly the genuine #280
  whisper start-stretch on the vocals track (Demucs removed the music,
  leaving real silence), so long trims over silence still work in full.
  Long jumps over audible content (e.g. quiet speech under the relative
  threshold) are refused instead of playing the dub seconds late; an
  isolated transient in the span (<10% audible frames) doesn't block it.
- Source-aware: snapping now runs only on the separated vocals track.
  dub_core detects the Demucs fallback (vocals_path == audio_path, see
  dub_pipeline) at both call sites and passes separated_vocals=False on
  mixed audio, disabling snapping — whisper's own timestamps beat a
  confidently wrong snap when music/ambience is sustained energy too.

Tests (tests/test_onset_align.py, fail-before/pass-after): transient burst
rejected at detect- and snap-level, transient-only window yields no onset,
long jump over audible content refused, bounded shift over audible lead
still allowed, >1.5 s trim over true silence still snaps (#280 regression
guard), mixed-audio mode is a no-op. 28 pass in the file; full dub-adjacent
suites green.

Credit: theory and repro description by the #963 reporter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): add onset-snap robustness under [Unreleased] (#967)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:15:14 +05:30
de99bc3bd5 fix(net): SOCKS-proxy users can synthesize again — ship socksio, cache-first model resolution (#959) (#966)
* fix(net): SOCKS-proxy users can synthesize again — ship socksio, cache-first model resolution, degrade LLM clients (#959)

Under ALL_PROXY/HTTPS_PROXY=socks5:// without socksio installed, httpx
raises ImportError AT CLIENT CONSTRUCTION ("Using SOCKS proxy, but the
'socksio' package is not installed"). huggingface_hub's get_session()
builds exactly that client inside snapshot_download, so POST /generate
500'd with the bare message even for a fully installed model, and
preload_model's model_info probe hit the same error and silently
skipped warm-up. Latent since v0.3.5 — #947's fresh-process engine
spawning unmasked it in v0.3.10 by handing the user's proxy env
directly to a clean backend process.

Three layers, so the class (any session-construction failure) is dead,
not just the reported instance:

* Ship SOCKS support: socksio>=1.0 in [project] dependencies (pure
  Python, MIT, zero transitive deps) AND in backend.spec hiddenimports
  — httpx imports it lazily in try/except, so PyInstaller's tracer
  misses it and the frozen installers would stay broken without the
  explicit entry. uv.lock regenerated; `uv lock --check` and
  `uv sync --frozen` (the Docker/release bootstrap semantics) verified.

* Cache-first model resolution: from_pretrained's snapshot resolution
  extracted into _resolve_snapshot_dir() — local dir, else
  snapshot_download(local_files_only=True) (a complete cache resolves
  with NO HTTP session constructed), else the original network path.
  preload_model's failed network probe now falls back to a cache-only
  check and warms up anyway instead of silently skipping (honest log
  either way).

* Class guards: resolve_skill_client wraps OpenAI() construction —
  env-shaped construction failures degrade to the existing "LLM
  unavailable" contract instead of 500ing the calling feature; and
  core.failure learns SOCKS_PROXY_SUPPORT_MISSING with an actionable
  hint, appended on the raw-string surfaces (global 500 handler,
  model-install SSE) via the new append_hint().

Fail-before/pass-after verified by reverting the fix: 11 of the 12 new
tests fail pre-fix (the remaining one is the unchanged network-fallback
contract). 165 tests green across the touched suites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): add SOCKS-proxy resilience under [Unreleased] (#966)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:00:08 +05:30
c47633a409 fix(settings): a saved LLM provider survives restart — explicit save activates, stale TRANSLATE_* prefs stop hijacking (#963) (#965)
* fix(settings): a saved LLM provider survives restart — explicit save activates, stale TRANSLATE_* prefs stop hijacking (#963)

"Ollama works until I restart OmniVoice" had three stacked causes:

1. Only "Save & use for translation" persisted the selection. Plain
   "Save" and "Test" sent make_active:false, and on restart
   active_provider_id() deliberately excludes local providers
   (Ollama/LM Studio) from auto-select — so a saved-and-tested Ollama
   was never resolved active again. The PUT handler now also claims the
   active slot on an explicit save when the user has never chosen a
   provider (new llm_providers.stored_active_provider_id(): the stored
   row only — no env pin, no legacy fallback, no auto-detect). An
   explicit prior choice is never stolen; an unconfigured provider
   can't claim the slot; make_active:true still flips.

2. Users of the retired (≤v0.3.7) Translation-LLM panel had
   env.TRANSLATE_* rows in prefs.json, re-imported into os.environ
   every launch — and a live TRANSLATE_BASE_URL resolves the active
   provider to "custom" ahead of auto-select on every restart. New
   startup migration (llm_providers.migrate_legacy_translate_prefs,
   run in main.py BEFORE the prefs→env import) moves those values into
   the custom provider's own settings-store rows (only where the store
   has no value yet) and deletes the prefs rows. Real process env vars
   are never touched; a failed store write keeps the prefs row and
   retries next boot. The legacy endpoint keeps working — via the
   store, without hijacking the active slot.

3. The panel read as "done" after a green Test even when another
   provider stayed active. It now shows a notice after save/Test when
   the edited provider is not the effective active one (suppressed
   while LLM_DEFAULT_PROVIDER pins the choice — the env banner already
   covers that).

Tests (fail-before): 7 new backend tests fail on the old code
(save-activates, never-steals, migration semantics, env untouched,
end-to-end ollama-beats-legacy-env) and the new panel test fails
without the notice; all pass after. Full LLM/settings suites, frontend
vitest (890), typecheck:ci, oxlint, oxfmt and vite build are green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): add LLM-provider persistence fix under [Unreleased] (#965)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:47:54 +05:30
4aa9abe22a docs+scripts: install fixes — desktop-prod tauri resolution, Ubuntu white-screen guidance, honest GPU/prereq docs (#960 #961 #962) (#964)
* docs+scripts: install fixes — desktop-prod tauri resolution, Ubuntu white-screen guidance, honest GPU/prereq docs (#960 #961 #962)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): add the install-fixes batch under [Unreleased] (#964)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:34:01 +05:30
d90cfde1bb feat(dub): per-language translations + per-track caches — switching languages stops destroying work (P1) (#958)
* feat(dub): per-language translations + per-track caches — switching languages stops destroying work (P1)

Multi-language dubbing translated per language (#957) but stored everything
in single-slot state, so tracks silently destroyed each other's work:

P1.2 — per-language translation storage (additive):
- Frontend keeps every translation in s.translations[langCode] alongside the
  legacy s.text slot (still = the shown language). Translate All writes both;
  the new store action switchDubLangCode swaps text through the map on a
  user-driven language switch (non-destructive; restore paths keep the plain
  setter); manual edits / restore-original update the current language's
  entry; merge joins per-language texts, split drops them. Rides project
  save/load inside dubSegments — legacy projects behave exactly as before.
- Backend mirrors it as job["segments_i18n"] = {lang: {segKey: text}}
  (segKey = stable id, index for id-less legacy rows), written by
  _sync_job_segments; job["segments"] stays byte-identical for every existing
  consumer. /dub/srt|vtt?lang= and subtitle burn-in now emit THAT language's
  text when present — ExportModal's "all dubs" batch stops producing N
  identical files. Legacy jobs without the field fall back to today's output.

P1.3 — per-track WAV cache + fingerprints:
- Per-segment WAVs are language-keyed (seg_{lang}_{id}.wav). The partial-regen
  read path falls back to legacy seg_{id}.wav ONLY while the job has no
  other-language track — single-language jobs keep their whole on-disk cache;
  multi-track jobs stop splicing the last-generated language into the current
  track. Read-only endpoints (segment preview, clips zip) gained ?lang= with
  the permissive legacy fallback they always had.
- Fingerprints include the track language (segment_fingerprint(track_lang=…),
  /tools/incremental lang=…) and live in job["seg_hashes_by_lang"]; the flat
  job["seg_hashes"] stays as the current track's mirror so the done event,
  history restore and older frontends read it unchanged. A legacy flat map is
  attributed to the job's last-generated language (dropped when unknown) and
  reads stale once — the safe direction. seg_wav_kind is per-track too.
- The frontend stores fingerprints per language and judges "Regen N changed"
  against the ACTIVE track; project save/load and dub-history restore carry
  all tracks' hashes (segHashesByLang / seg_hashes_by_lang, additive).

Tests: fail-before regression coverage — two-track regen never splices the
other language's audio (sample-level assert on the mixed track), legacy
single-track cache reuse + multi-track gate, per-lang seg_hashes with flat
mirror + migration semantics, /dub/srt|vtt?lang= emitting different text per
track with legacy fallbacks, per-lang burn-in, /tools/incremental lang
scoping, and 14 frontend tests for translations round-trips, per-track
fingerprints and legacy-project behaviour. Full backend + frontend suites,
typecheck, lint and format:check green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): add per-language storage + per-track caches under [Unreleased] (#958)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 03:05:40 +05:30
bd92729623 feat(dub): multi-language generate translates each language first + picks persist (P1) (#957)
* feat(dub): multi-language generate translates each language first + picks persist with the project

P1.1 — the "Generate N dubs" loop never translated: the backend synthesizes
segment text verbatim, so every multi-language track rendered the same words
and at most one was actually in its language. The loop now runs
translate → generate per pick:

- handleTranslateAll(langOverride?) accepts an optional ISO-code override
  (no-arg Translate All behavior unchanged; a click-event first arg is
  guarded). It resolves true only when a translation actually landed, and
  both it and handleDubGenerate snapshot segments from the store at call
  time — the click-time closures went stale the moment the previous pick's
  translate pass rewrote the segments.
- A pick whose translate fails (request error or all-segments-errored) is
  SKIPPED — never a wrong-language track — the batch continues, and the
  skipped languages are reported in a final toast.
- The redundant first translate is skipped only when pick 1 targets the
  language the editor text is already translated into; every later pick
  always translates.
- Honest progress: the pill shows "Translating → {lang} (i/N)…" before each
  generate, and the header CTA is inert while translating so a re-click
  can't start a second batch (belt: a ref guard in the loop).

P1.4 — multiLangMode/multiLangs move from DubTab component state into the
dub store slice and ride the project save/load payload (exportTracks too).
Additive and back-compat: legacy payloads default to off/empty and leave the
in-session exportTracks untouched (utils/projectState.js).

Tests (fail-before verified: 9 failures on the pre-fix code):
- handleTranslateAll override targets + return semantics + call-time
  segment snapshot (dubTranslateAllOverride.test.jsx)
- per-language translate-before-generate call order, skip-on-failure with
  continuation + skip-report toast, first-pick skip heuristic, unchanged
  single-language path (dubMultiLangGenerate.test.jsx)
- slice defaults/setters/reset, payload round-trip, legacy-payload defaults,
  App.jsx wiring guards (dubMultiLangPersist.test.js)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): add multi-lang auto-translate under [Unreleased] (#957)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 01:49:41 +05:30
mergetestandClaude Fable 5 afe819f498 fix(ci): oxfmt the two #956 test files (unbreak main format check)
#956 merged with a red Tests gate — my merge script ran unconditionally
instead of aborting on the gate value; the failure was oxfmt-only on the
two new test files. Whitespace-only fix, tests re-verified green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 01:23:03 +05:30
6b91205036 fix(dub): completed tracks always show their tabs + history keeps its language (P0) (#956)
* fix(dub): completed tracks always show their tabs + history keeps its language (P0)

Root cause chain: the track switcher's visibility expression required
dubLangCode !== 'und' and ended in a tautology (dubTracks?.length > 0 ||
!!dubTracks), so it was effectively keyed to the language dropdown, not
the persisted tracks. History restore always handed the frontend 'und'
because the dub_history language/language_code COLUMNS froze at the
ingest-time "" — the save_job UPSERT never updated them after generation
set them on the job dict (only the job_data JSON carried the real value).
Net effect: a restored project with finished tracks showed no track tabs
until the user re-picked a language.

- DubTab: hasDubbedTrack = done && dubTracks.length > 0 (tracks only;
  also stops the tautology from showing a trackless switcher).
- DubTab auto-jump: membership-guarded — the preview only jumps to a
  language that has a track, else tracks[0]. Kills the preview-404 class
  (restores falling back to 'en' with tracks ['bn'] pointed the player
  at /dub/preview-video?lang=en).
- dub_pipeline.save_job UPSERT: language/language_code now update when
  non-empty (same CASE guard as content_hash), so new saves heal the
  frozen columns and empty re-saves can't clobber them back.
- App.restoreDubHistory: falls back to job_data's language/language_code
  so EXISTING rows in users' DBs restore correctly with no migration.
- P0.2 polish: track pills get duration + timing-strategy tooltips,
  hydrated lazily and failure-silently from the existing
  GET /dub/tracks/{job_id} via new api/dub.dubListTracks; all new
  strings through i18n (en.json).

Tests (fail-before/pass-after): DubTab-level visibility + auto-jump
membership-guard tests (3 of 4 fail pre-fix), pill-tooltip hydration
tests, and save_job language heal/no-clobber tests (heal fails pre-fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): open [Unreleased] with the dub track-tabs fix (#956)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 01:21:23 +05:30
1dfa032be4 feat(dub): project title first in the editor header, pipeline stages below (#955)
Header reordered per owner: row 1 = title (+ duration/segments) with the
action buttons, row 2 = the Upload→Export pipeline spine directly beneath
with a tight 2px gap (was: stepper and title side-by-side on one row).

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 01:13:15 +05:30
fd7d20fe1e release: freeze v0.3.10 — version bump, lockfiles, changelog (#954)
package.json (source of truth) + the three mirrors -> 0.3.10, in lockstep;
Cargo.lock/uv.lock/bun.lock regenerated (one line each; bun --frozen-lockfile
verified). CHANGELOG [Unreleased] -> [0.3.10] — 2026-07-05 with the release
headline; nine fixes since v0.3.9, mostly same-day field-report turnarounds.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 00:57:17 +05:30
27ff5846c2 fix(audiobook): chapter render no longer crashes on mixed 1-D/2-D audio chunks (#897) (#953)
* fix(audiobook): chapter render no longer crashes on mixed 1-D/2-D audio chunks (#897)

Root cause: synthesize_chapter (backend/services/audiobook.py) built
inter-span pause silence as bare 1-D torch.zeros(n) while every real
engine's synth returns (1, samples) per the TTSBackend.generate contract
(OmniVoice's model.generate(...)[0] included) — so the chapter's final
hard concat in chunked_tts.concatenate_audio_chunks hit
torch.cat with mixed ranks and died with
'RuntimeError: Tensors must have same number of dimensions: got 1 and 2'.
Any chapter containing a [pause] span (Stories/audiobook longform)
crashed; existing tests missed it because their stub synth returned 1-D.
The crossfade branch had the same latent bug for mixed-rank chunks.

Fix, both layers:
- concatenate_audio_chunks now normalizes chunk shapes before any cat
  (_normalize_chunk_shapes): lower-rank chunks gain leading singleton
  dims to the highest rank present, then singleton channel dims
  broadcast to the widest channel count (mono follows stereo). Covers
  both the hard-cut and crossfade branches; homogeneous input passes
  through untouched, so all-1-D / all-2-D callers keep their exact
  output shapes. No future backend's output rank can re-break the join.
- synthesize_chapter materializes silence AFTER the loop, matching the
  rendered audio's channel dims / dtype / device — the same pattern
  generation.py's _render_with_pauses already uses for the single-shot
  path — so the data is rank-consistent at the source too. A
  silence-only chapter stays 1-D float32 as before.

Regression tests: mixed-rank hard-cut (both orders), mixed-rank
crossfade, mono->stereo broadcast, all-1-D/all-2-D shape stability, a
2-D-engine + [pause] chapter through synthesize_chapter (the exact #897
scenario), and a spy asserting the parts reaching the concat are
rank-homogeneous. All fail before the fix with the reported error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): add the audiobook pause-span concat fix under [Unreleased] (#953)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 00:40:35 +05:30
d14b37fab2 fix(dub): the speaker-count hint is honored on every diarization path + clone-purity guard (#952)
* fix(dub): the speaker-count hint is honored on every diarization path + clone-purity guard

The dub "Speakers" count reached _diarize() and then died on 3 of its 4
branches, so setting it changed nothing, speakers blended, and auto-clones
were cut from mixed-speaker audio ("made up" voices):

- FunASR inline-turns shortcut returned before the hint was ever consulted
  → now an explicit num_speakers routes the job through pyannote (the one
  engine that honors an exact count); turns stay the fast path only when no
  hint is set, and remain the fallback (with an honest "hint ignored"
  warning) when pyannote can't load or crashes mid-run.
- pyannote-unavailable fallback used a hardcoded 2-speaker silence-gap
  heuristic → assign_speakers_heuristic now takes num_speakers and cycles N
  labels on gap boundaries (1 → single speaker; None → legacy alternation),
  and the existing diarization warning says the hint is only approximately
  honored.
- pyannote-crash fallback dropped the hint the same way → same treatment.

No branch drops the hint silently anymore: every degraded path extends the
existing `warning` SSE payload (detail + a machine-readable speaker_hint
field) that the frontend already renders.

Parity + purity:
- POST /dub/transcribe/{job_id} (the CLI's endpoint) gains the same clamped
  num_speakers query param, forwarded to pyannote and the heuristic; the
  omnivoice-dub CLI gains --speakers N.
- Clone-purity guard: _pick_reference_slices rejects sub-1.5s slices, prefers
  slices not temporally adjacent (<0.3s) to another speaker's turn (scoring
  preference, not a hard filter), and extract_speaker_clones skips extraction
  entirely when labels came from the heuristic (labels_source kwarg threaded
  from _diarize; missing kwarg keeps the old behavior) — with a user-facing
  warning pointing at Settings → Models → pyannote.

Tests: fail-before/pass-after coverage in tests/test_speaker_hint.py (all
four _diarize branches driven through the real SSE stream), clone-purity
guards in tests/test_speaker_clone_purity.py, heuristic hint semantics in
tests/test_segmentation.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): add the speaker-hint + clone-purity fix under [Unreleased] (#952)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 22:53:47 +05:30
edf86c1800 fix(ui): dub timeline boxes can no longer flash invisible during playback (#373 class, completes #381) (#951)
* fix(ui): dub timeline boxes can no longer flash invisible during playback (#373 class, completes #381)

Root cause: the segment lane animated an inline
`transform: translateX(-scrollLeft)` on every playback tick, so Chromium
promoted it to a compositor layer — and on some Windows GPU/WebView2
driver combos, composited semi-transparent paints (the 0.45-alpha box
fills) flash invisible/visible while the layer moves, settling only when
paused. PR #381 removed `will-change` and raised the alpha, which only
dampened the symptom; the animated transform kept the lane composited.

Fix the class — no composited translucent paints on the lane, ever:

- Position boxes in pure layout: the lane transform is gone; each box's
  `left` is start·pxPerSec − scrollLeft (viewport coordinates). The
  virtualization window already derives from the same scrollLeft, so
  windowing stays consistent by construction. The selfScroll WebKit
  fallback keeps lane coordinates (its viewport is a real scroll
  container), unchanged.
- Belt-and-braces: REGION_COLORS are now fully opaque — each entry
  pre-blends the old 45% tint against the surface behind the lane
  (`--chrome-bg`, the .studio-panel background) via color-mix, which is
  pixel-identical to the previous alpha compositing (0.45·tint + 0.55·bg)
  in every theme, with zero alpha.

Regression tests (fail on pre-fix code): lane carries no transform at
rest and after a scroll update, box lefts are viewport-relative for a
scrolled view, no double-shift in the selfScroll fallback, and every
REGION_COLORS entry is alpha-free with the 45% ratio preserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): add the timeline-box compositor fix under [Unreleased] (#951)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 22:41:16 +05:30
437a995a0f fix(translate): Cinematic/Autofit can no longer invent dialogue — divergence guard + pinned temperature (#950)
Root cause (v0.3.9 field report): the refine paths had no-op output guards.
Cinematic's ADAPT step only checked _looks_like_target_script, which returns
True unconditionally for every Latin-script target (no _SCRIPT_RANGES entry)
— so any non-empty LLM reply (hallucinated dialogue, refusals, commentary,
or the REFLECT critique itself) shipped as the dub line. Autofit's
adjust_for_slot accepted ANY non-empty reply, and its best-candidate tracker
(closest rate_ratio to 1.0) actively selected the most-padded output, while
_EXPAND_PROMPT invited invention with no ceiling. Both call paths also ran
at the provider-default temperature 1.0, unlike the working Fast path which
pins 0.2.

The fix, class-level:
- Shared divergence guard translator.refine_output_ok (length window
  0.4–2.5x, env-tunable via OMNIVOICE_REFINE_RATIO_MIN/MAX, with an
  absolute cap for short references; target-script check; critique-echo
  detection). Rejected ADAPT output degrades to the literal with
  error="adapt-diverged" (wrong-script keeps its adapt-wrong-script:<lang>
  marker), riding the existing degradation machinery unchanged.
- Autofit validates every reply against the ORIGINAL input text (divergence
  compounds across attempts otherwise); rejected candidates are discarded
  (attempt burned, graceful degradation to the input preserved) with
  error="fit-diverged"; lines under 15% of their slot skip LLM expansion
  entirely (fit-skip-short) — they could only "fill" the slot with
  fabricated dialogue.
- temperature=0.2 pinned on the cinematic (_chat) and fit (llm.chat) calls;
  chat/chat_messages gained an optional temperature param that is only sent
  when set, so refinement/director/glossary callers keep provider defaults.
- Prompts hardened: ADAPT forbids introducing facts/names/dialogue not in
  the source line; EXPAND forbids inventing information and more than
  doubling the line.
- speech_rate strict-mode docstring made honest: strict changes only the
  upper tolerance bound; expansion still runs (now guard-bounded).

Fail-before/pass-after regression tests for the reported bugs (10x runaway
ADAPT on an es target, critique echo, hallucinated slot-fill expansion,
refusal replies, tiny-line expansion skip, pinned temperature) plus the
previously-untested wrong-script fallback and legit-output acceptance.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 22:28:37 +05:30
03eb8f7d04 docs(readme): community table lists the Discord server's real channels (#949)
The table described aspirational channels (#showcase/#help/#feature-requests/#dev)
that don't exist on the server; it now matches reality (#announcements,
#releases+#changelog, #issues and #ideas forums, #discuss-ideas, #general).

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:39:12 +05:30
beca8cfea8 fix(launcher): replace a stale old-version backend instead of attaching to it (#947)
* fix(launcher): replace a stale old-version backend instead of attaching to it

After an update, an orphaned backend from the PREVIOUS version can survive
holding the port. It still answers /system/info, so both attach paths
(lib.rs launch bootstrap + bootstrap.rs retry) treated it as "already
serving OmniVoice — attaching" and the new UI silently ran OLD backend
code: every fix in the update appeared to change nothing. Reported on
Discord as "a bound port which blocked the newer versions"; the app
already knew how to kill_orphan_on_port on both Unix (lsof) and Windows
(netstat) — it just never applied it to a healthy-but-stale backend.

The attach decision now compares versions: running_backend_version()
reads app_version from /system/info (string-sniff, no new deps), and
same_app_version() compares BASE versions (pre-release -N suffix
stripped, so a preview build 0.3.10-4 still attaches to its 0.3.10
backend). Same version → attach exactly as before. Different or missing
version → the orphan is killed and the bundled backend spawns. Foreign
processes keep the existing port_in_use take-ownership path; the
post-spawn health polls are untouched (we spawned that backend
ourselves).

Rust unit tests cover the /system/info parse shape and the
match/preview/stale/unversioned decisions; 51 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): add the stale-backend port-reclaim fix under [Unreleased] (#947)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:40:42 +05:30
32103ad7b7 docs(readme): charm + organization overhaul (Opal-style) — collapsibles + OpenAI-compatible API section (#945)
* docs(readme): charm + organization overhaul (Opal-style)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(readme): restore inventory-exact feature names (docs-drift guard)

The charm pass sentence-cased five bold leads in the collapsed feature
list; scripts/check-docs-drift.py greps for the inventory's exact
title-case names. Restored: Vocal Isolation, Speaker Diarization,
Batch Queue, AI Watermark, GPU Auto-Detect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(readme): dubbing screenshot shows a real completed dub (37 segs, EN→BN)

Replaces the empty drop-zone shot with the populated editor — video +
waveform + cast, 37 Bengali segment rows, DUB COMPLETE banner — captured
live from the v0.3.9 app; caption updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:36:34 +05:30
46c4445258 fix(ui): relative timestamps no longer render as "20617d ago" (seconds-vs-ms class) (#946)
Backend rows store timestamps as Unix SECONDS (time.time() REAL columns:
generation_history, dub_history, exports, longform jobs, projects), while
frontend-local records carry milliseconds (Date.now() story projects) or ISO
strings (transcriptions). Projects/OmniDrive fed the seconds straight into a
millisecond-based diff (fmtTime), so every generation-history card rendered
as ~1970 ("20617d ago") — and, because the same raw value drove the recency
sort, history items also sank to the bottom of the drive.

Fix the class, not the label: a single shared, unit-tolerant normalizer
(frontend/src/utils/relativeTime.js) now backs every relative-time call site.

- toMillis(ts): numbers < 1e12 are seconds (×1000), >= 1e12 already ms; ISO
  and numeric strings parse; Date instances pass through; null/0/undefined/
  garbage -> null. Backend storage format is untouched (backward compat).
- timeAgo(ts): "—" for missing stamps (never an epoch age), "just now" for
  future stamps within 1 min of clock skew, s/m/h/d buckets, short absolute
  date beyond 7 days.
- absoluteTime(ts): unit-safe tooltip text, '' when missing (no more
  "Jan 1 1970" titles on null rows).

Converted call sites: pages/Projects.jsx (drop local fmtTime + per-source
*1000 juggling; normalize ts once so sort and label agree), components/
Sidebar.jsx + components/WorkspaceProjects.jsx (drop duplicated local
timeAgo copies and caller-side *1000), pages/BatchQueue.jsx (drop local
formatAge; missing created_at used to render an epoch date), pages/
Transcriptions.jsx + components/TranscriptionPicker.jsx (parse via
toMillis, keep their i18n labels; unparseable stamps no longer render
"Invalid Date").

Tests (fail-before/pass-after): utils/relativeTime.test.js covers seconds/
ms/ISO/numeric-string/Date inputs, null/0 -> "—", clock-skew "just now",
and the 1970 regression (a seconds stamp from today must not render as
thousands of days ago); test/ProjectsRelativeTime.test.jsx guards the
OmniDrive wiring end-to-end (seconds created_at renders "2h ago", null
renders "—", mixed-unit sort orders by real recency). Full frontend suite:
106 files / 843 tests green; oxlint, oxfmt, typecheck:ci, node:test green.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:03:41 +05:30
8bbab3fcc3 fix(translate): Dub LLM engine runs on the configured LLM provider (new dub_translation skill) (#944)
* fix(translate): the Dub LLM engine now runs on the configured LLM provider

Picking "LLM (OpenAI-compatible)" in the Dub tab read only the raw
TRANSLATE_* env vars — completely bypassing Settings → LLM Providers, so a
provider the user had configured AND tested in-app silently didn't power
the engine (empty key → raw 401 per segment). The Cinematic refiner was
already rewired through LLM Skills (#910/#912); this closes the gap for
direct LLM translation:

* new "dub_translation" LLM skill (Settings → LLM Skills) — per-skill
  provider override → global active provider, same resolution as every
  other skill; disabled == unconfigured, no new degradation modes
* the provider=openai branch resolves through resolve_skill_client();
  TRANSLATE_BASE_URL/TRANSLATE_API_KEY/TRANSLATE_MODEL stay working as
  the power-user override (env-only setups see zero behavior change,
  except the stale gpt-3.5-turbo default is now gpt-4o-mini, matching
  the cinematic path)
* per-segment calls are now bounded by the LLM timeout (45s default via
  OMNIVOICE_LLM_TIMEOUT) instead of the SDK's 600s default
* fully unconfigured → an up-front actionable 400 naming Settings → LLM
  Providers / LLM Skills instead of a per-segment 401
* provider-store keys are resolved into the error scrubber so a provider
  echoing the key can't leak it (parity with the env-key scrub)
* translation_engines registry: honest notes + a configured/configured_via
  stamp on LLM entries so the Engine dropdown can show ready-vs-needs-setup
  before the user clicks Translate

Tests: 4 new (skills-resolved client wins with its model+timeout; 400s
name the right settings page for no_provider vs disabled; env fallback
keeps working incl. TRANSLATE_MODEL); skills registry coverage updated;
existing openai-branch tests routed deterministically through the env
branch via the shared fake helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): add the dub-translation provider wiring under [Unreleased] (#944)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:22:12 +05:30
9ff5f38cb3 docs(readme): cross-promote the maker's other local-first projects (Opal, memxt) (#943)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:09:46 +05:30
359c70bc21 docs(readme): use the Launchpad screenshot as the hero image (#942)
Swap the static social-preview banner for the live v0.3.9 Launchpad shot
and drop the now-duplicate Launchpad row from the gallery (shown once).

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:56:21 +05:30
0579ec91ad docs(readme): Opal-style restyle + fresh v0.3.9 screenshots (#937)
- Emoji section headers with explicit <a id> anchors. Emoji breaks
  GitHub's auto-generated heading slugs, so every in-page nav target keeps
  a stable explicit anchor (verified all href="#..." resolve).
- Refresh the screenshot gallery. The prior set was from April, predating
  the launchpad / settings / dictation UI overhaul, so it misrepresented
  the app. Captured fresh at retina from the live v0.3.9 UI and led the
  gallery with the new Launchpad home: launchpad, studio, voice design,
  voice gallery, dubbing, engine-compatibility matrix, model store,
  embedded API reference (Scalar), and the in-app changelog reader.
- Fix the stale engine count (11 -> 14 TTS engines) in the comparison
  table, FAQ, and roadmap to match the engine table + backend registry.
- Use <kbd> keycaps for the dictation shortcut (Opal detail).

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:38:47 +05:30
b6ec4e23f3 fix(engines): snapshot lazy registry keys so /engines can't 500 under concurrency (#940)
* fix(engines): snapshot lazy registry keys so /engines can't 500 under concurrency

`list_backends()` runs in a FastAPI threadpool and iterates the lazy TTS/ASR
registries via `items()` → `__iter__`, which held a *live* `dict.__iter__(self)`
open across each engine's slow `is_available()` probe. Meanwhile the lazy
`__getitem__` resolves a deferred entry by mutating the dict (`self[key] = cls`).
A second concurrent `/engines` request (or any ASR op) materializing the lazy
`faster-whisper-isolated` entry therefore changed the dict size mid-iteration:

    RuntimeError: dictionary changed size during iteration
      asr_backend.py:1729 list_backends → _REGISTRY.items()
      asr_backend.py:1665 __iter__ → for k in dict.__iter__(self)

Both `_LazyRegistry` (TTS) and `_LazyASRRegistry` (ASR) now snapshot their live
keys up front with `list(dict.__iter__(self))` — consumed atomically under the
GIL — so a concurrent lazy insert can no longer trip the iteration. The slow
per-engine probes then run over the snapshot, not the live iterator.

Deterministic fail-before/pass-after regression for both registries:
tests/backend/services/test_lazy_registry_concurrency.py.

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

* docs(changelog): add the /engines concurrency fix under [Unreleased] (#940)

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

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:53:57 +05:30
eb188931b5 fix(dub): classify EINVAL transcribe failures so they stop dead-ending (#763) (#936)
A per-chunk temp-WAV write that fails with OSError EINVAL ("[Errno 22]
Invalid argument") — a missing/read-only/full temp dir, a removed drive,
or antivirus — collapsed into "Transcription produced no segments.
[Errno 22] Invalid argument" with no next step. classify() now names the
class (OS_INVALID_ARGUMENT) so build_failure attaches an actionable
temp-dir/disk/AV hint at the exact surface the streaming dub path already
feeds it (dub_core.py:672) — same treatment the ffmpeg and compute-type
classes get. Fail-before/pass-after regression added; the errno-22 token
keeps it from colliding with the errno-2 transformers-import class.

Also stamps the [0.3.9] CHANGELOG section with today's release date
(2026-07-04) ahead of tagging.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 05:35:51 +05:30
86f23326ff docs(changelog): add sherpa config-error fix (#919) to [0.3.9] (#935)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 21:27:42 +05:30
ba4f64240a fix(engines): classify sherpa "model not set" as a config error, gate the engine on its model dir (#919) (#934)
A user selected the sherpa-onnx TTS engine and got a 500 that read "TTS
engine stopped mid-generation. This usually means it ran out of memory.
Try the Flush button…" — when the real cause was a pure setup problem:
"OMNIVOICE_SHERPA_MODEL not set. Point it to a sherpa-onnx TTS model
directory (containing model.onnx + tokens.txt)." Same misclassification
class as #880/#893, which tightened the OOM catch-all on the generation
path — but the engine-not-configured case still fell through to memory.

Two layers, fixing the whole class:

1. Error classification (backend/api/routers/generation.py): a new
   `_is_config_failure()` recognizes "required engine model path / env
   var not set" over the whole exception chain (OMNIVOICE_* named with
   "not set"/"point it to"/"set omnivoice_…", sherpa's "no model.onnx
   found in", "not configured", "venv not found. set" for the dedicated-
   venv opt-ins). `_oom_friendly_reraise` checks it BEFORE the OOM branch
   and re-raises actionable setup guidance that names the variable, points
   at Settings → Engines, and never mentions memory or Flush. Generalizes
   to sherpa/Confucius4/dots/MOSS and any future env-gated engine.

2. Engine gating (backend/services/tts_backend.py): SherpaOnnxBackend
   ships no bundled model, so is_available() now gates on
   OMNIVOICE_SHERPA_MODEL (set + contains model.onnx) — like the other
   path-configured opt-in engines — returning False with an actionable
   reason instead of "ready", so the picker marks it unavailable-with-a-
   reason rather than selectable-but-broken. Added the copy-paste setup
   snippet for the Compat Matrix. Backward-compatible: a correctly
   configured OMNIVOICE_SHERPA_MODEL keeps the engine available.

Tests (fail-before/pass-after): config-classification of the sherpa
"model not set" error and the wider not-configured class (no "out of
memory"/"Flush"); is_available gating on the env var + model.onnx and the
setup-snippet registration.

Fixes #919

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 21:26:58 +05:30
8ca7de89eb fix(test): close the reload-induced test-isolation leak at its source (#932 follow-up) (#933)
Two sides of the same class the router-smoke leak (#932) traced to
test_pronunciation_api's importlib.reload teardown:
- test_pronunciation_api now re-runs init_db() on the restored data dir so
  the reloaded core.db/main.app is never left on a schema-less DB.
- test_db_migration_safety catches db_module.MigrationError dynamically
  instead of the collection-bound name, so a reload that rebinds the class
  can't make pytest.raises miss it.
Both orderings (real + reversed) now pass; no product change.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:07:44 +05:30
ed0b0b63cf fix(test): router-smoke tests leak-proof against full-suite order (#932)
tests/test_router_smoke.py showed ~10 `sqlite3.OperationalError: no such
table: jobs` failures in the full suite (and in isolation on a clean data
dir), but passed when a schema-creating module ran first.

Root cause: the `client` fixture builds a bare `TestClient(app)` with no
`with` block, so the FastAPI lifespan never runs — and `init_db()` (the
only place the schema is created) lives in that lifespan (main.py). The
smoke tests therefore free-rode on whatever schema an earlier module left
on the active DB. A module that reloads `core.config`/`core.db` and leaves
`core.db.DB_PATH` pointed at a fresh, schema-less DB (test_pronunciation_api's
`importlib.reload` teardown restores the env var but never re-runs init_db
on the restored data dir) strands router-smoke on a DB with no tables ->
every DB-backed route 500s. Same class as #878 / #917.

Fix (test-only, zero blast radius): the `client` fixture now calls
`core.db.init_db()` against whatever DB is active at run time before serving
requests — the same `init_db()` pattern test_api.py / test_personas_api.py
use. Because it targets the live `core.db.DB_PATH`, it re-creates the schema
regardless of which path any prior module left active, making the suite
self-sufficient and order-independent.

Verify:
- `pytest tests/test_router_smoke.py` alone: 10 failed -> 24 passed
- `pytest tests/test_pronunciation_api.py tests/test_router_smoke.py`
  (deterministic reproducer): 10 failed -> 38 passed
- `pytest tests/` full suite: 2215 passed, 20 skipped, 10 xfailed,
  4 xpassed, 0 failed / 0 errors

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:47:45 +05:30
0d80ab2cb0 docs: backfill [0.3.9] batch bullets + add OSS sponsorship playbook (#931)
Bullets for #922 (release titles), #923+#924 (sponsors), #925 (contact),
#927 (models), #928 (openapi), #930 (engines) — the agents kept off
CHANGELOG.md during the merge chain. Plus a portable how-we-set-up-
sponsorship playbook for reuse on other projects.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:30:43 +05:30
6e3e014826 feat(settings): OpenAPI reference page — embedded Scalar (bundled, CDN-free) + footer button (#928)
Add a Settings → OpenAPI page that renders an interactive Scalar API
reference for OmniVoice's own local backend, plus a compact footer button
that opens it.

- New OpenApiPanel fetches the live spec from the resolved backend base
  (getApiBase()+"/openapi.json", via apiFetch so it follows remote-backend /
  LAN-share overrides), owns loading + unreachable-backend fallback (with
  Retry), and hands the parsed spec inline to Scalar.
- Scalar is bundled via the @scalar/api-reference-react npm package — NO CDN
  script tag. It is lazy-loaded (ScalarApiReference.jsx) so its ~heavy Vue
  bundle stays out of the initial load and only downloads when the page opens.
- CDN-free hardening: withDefaultFonts:false (drops the fonts.scalar.com
  @font-face rules), proxyUrl:'' (Test Request client goes direct to the local
  backend, not proxy.scalar.com), spec passed as inline content (no external
  spec fetch). The Tauri CSP is the hard backstop. Verified the built dist:
  external hosts appear only as inert/gated strings inside the on-demand Scalar
  chunks and are absent from the initial-load chunks.
- settingsCategories: new 'openapi' category (Braces icon, api/openapi/scalar/
  rest/swagger/docs keywords) in the System group; Settings render case wired.
- LogsFooter: compact Braces icon button (openSettingsTab('openapi')) next to
  the discord/mail cluster, chrome-muted → accent on hover, uniform 14px icon.
- i18n: all strings via t() with English defaultValue fallbacks (openapi.*,
  logs.open_api*, settings.openapi); keys added to en.json.
- Test: OpenApiPanel.test.jsx (mocks the spec fetch + stubs Scalar) — renders
  the reference container on success, shows the unreachable fallback on failure,
  recovers on Retry.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:27:05 +05:30
5bd8968aea feat(engines): real synthesis "Self-test" + copy-paste setup snippet for opt-in engines (#930)
* feat(engines): real synthesis "Self-test" + copy-paste setup snippet for opt-in engines

Builds on #905's Engines-settings fixes (verified still green: license dialog
mounts, matrix reloads on select, cpu_fallback routing toast, cpu-native →
cpu_only). Two enhancements, no #905 behavior touched.

Real "Self-test" for in-process TTS engines
-------------------------------------------
The existing /engines/{id}/health probe only imports the package and reports
"deps OK" for in-process engines — it never proves the engine can emit audio.
New POST /engines/{id}/selftest runs a *tiny real synthesis* from a fixed short
ASCII phrase and reports ok + duration + sample-rate + sample count, proving the
engine actually produces audio. Guardrails keep it cross-platform-identical and
CPU-cheap: TTS + available + in-process only, bounded wall-clock timeout
(OMNIVOICE_SELFTEST_TIMEOUT_S, default 90s) that returns ok=false/timed_out
instead of hanging the panel, a process-wide lock so a click-storm can't stack
model loads, loopback-gated, and only ever on user click (never on load). The
Compat Matrix gains a "Self-test" button (with cooldown) that renders
"0.82s @ 24 kHz in 820 ms". HF tokens in a synth error are redacted like the
health route. Verified end-to-end: kittentts synthesized 89,200 samples @ 24 kHz.

Copy-paste setup snippet for path-gated opt-in engines
------------------------------------------------------
IndexTTS / MOSS-v1.5 / dots.tts / Confucius4 gate on an OMNIVOICE_*_DIR env var.
list_backends() now emits a single-sourced `setup_snippet` (the exact
`export VAR=/path/...` line) surfaced with a Copy button inside the matrix's
"Why unavailable?" disclosure, so users don't reconstruct it from the docs.

Also tightened the incomplete SelectEngineResponse TS type to include the
routing echo (routing_status/effective_device/routing_reason) the post-select
toast already reads at runtime.

Tests: backend selftest success/subprocess-reject/unavailable/unknown/loopback/
exception-capture/timeout/HF-redaction + setup_snippet shape; frontend self-test
render, timeout marker, subprocess+ASR gating, setup-snippet render. New route
added to the API route snapshot. Full vitest (808) + backend engine/routing/asr/
route-inventory/no-CJK green; lint 0 errors; format + typecheck:ci clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): allow setup_snippet key in list_backends shape assertion

The engine self-test PR added setup_snippet to each backend entry but only
updated the route-shape test; test_list_backends_shape strict-asserts the key
set. Add setup_snippet there too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:02:08 +05:30
48a7154810 feat(models): one canonical HF-token path + surface incomplete cache (#927)
Two Model-management enhancements building on #908 (no re-do of its fixes).

Unify the two HF-token entry points. The Model Store toolbar saved the
token via /system/set-env (env var + HF-CLI file) while Settings →
Credentials saves to the encrypted app store — two stores with an
asymmetric clear path, so a toolbar-set token silently outlived the
Credentials "Clear" (a support-ticket generator). The toolbar now POSTs
the SAME canonical endpoint Credentials uses (/api/settings/hf-token →
encrypted store + huggingface_hub.login()), so there is one store with
one clear path. In-process parity is preserved (login() populates the HF
canonical file, so downloads pick it up immediately).

Surface an incomplete/partial cache. A truncated download (config landed,
weight shard didn't) occupies disk but used to read as a plain "not
installed". The backend already flags it as `incomplete`; the row now
shows an "incomplete · N MB" warn badge, relabels the primary action to
"Repair" (re-runs snapshot_download to finish the missing shard), and
offers a Delete to clear the partial bytes.

Tests: modelStoreTokenPath (toolbar hits /api/settings/hf-token, never
/system/set-env) + modelStoreIncomplete (badge, Repair→onInstall, Delete,
no false positives on normal not-installed/installed rows). Full vitest
green; lint + format clean. i18n keys added to en.json (models.incomplete,
incomplete_title, repair_btn, repair_title).

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:59:43 +05:30
5e135b9655 feat(contact): make "Get in touch" a guided, well-typeset help page (#925)
* feat(contact): make "Get in touch" a guided, well-typeset help page

Replace the flat 4-row link list (Discord / Email / Issues / Website) with
five guidance cards, each an icon + heading + a "use this when…" sentence so
users pick the right channel instead of guessing:

- Report a bug → reuses ReportBugButton (prefilled GitHub issue + scrubbed
  diagnostics; nothing sent until the user reviews & submits)
- Request a feature / ask → GitHub Issues
- Get help & community → Discord (setup help, sharing dubs)
- Support the project → routes to the existing Support page (no Ko-fi
  duplication)
- Report a security issue → GitHub Security Advisories (private, per
  SECURITY.md)

Bigger, friendlier typography ("We'd love to hear from you" header, roomier
measure and spacing) and a container-reflow card grid (CSS grid auto-fit, no
viewport @media, so it stays correct under --ui-scale zoom). Email + website
kept as quieter direct channels. External CTAs are real <a rel="noreferrer">
links, keyboard-focusable, opened via the shared openExternal helper. All
strings go through i18n under contact.* with English defaultValues for locale
fallback.

Adds a ContactPage render test (sections render, each channel targets the
right URL, bug-report affordance present, Support routes to donate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(i18n): prune 4 orphaned contact.* keys from 20 locales (Contact-page rewrite)

The Contact page rewrite renamed its i18n keys; the old keys lingered in the
20 non-English locales as orphans, failing the locale_no_orphan_keys probe.
Pruned; new keys fall back to English per i18n config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:59:30 +05:30
eeb3bf4452 feat(support): sponsor logo slot + "Become a sponsor" affordance (#923)
Adds a way for companies and people to visibly support OmniVoice.

- config/sponsors.js: single source of truth — an (empty) SPONSORS array with
  a documented { name, logoUrl, url, tier } shape + tier order, and a
  SPONSOR_CONTACT object whose githubIssue is a prefilled, zero-token
  "become a sponsor" issue (same pattern as the bug reporter) plus the Ko-fi
  link and a SPONSORS.md docs URL. Logos are added here + in SPONSORS.md.
- LogsFooter: a compact "Sponsors" link next to the donate heart (a link, not
  a logo strip in the 28px bar) that opens the in-app Support/Sponsors view.
- SupportPage: a Sponsors section — logo grid grouped by tier when populated,
  a tasteful outlined "be the first — your logo here" slot while empty, a
  primary "Become a sponsor" button opening the prefilled issue, and a
  one-line explainer linking to SPONSORS.md.
- SPONSORS.md: what sponsors get + how to become one, kept in lockstep with
  the config.
- All strings via i18n (support.sponsors_* / logs.sponsors) with English
  defaultValues so non-English locales fall back cleanly. Logo links are lazy,
  max-height capped, aria-labelled, rel="noreferrer", and open in the system
  browser via the app's external-open helper.
- Test: SupportPageSponsors renders the empty placeholder + asserts the
  become-a-sponsor CTA targets the contact URL, and (with injected sponsors)
  that each renders as an external logo link.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:46:03 +05:30
126f23fd3e docs(sponsors): add SPONSORS.md, README sponsors section, sponsor issue form + FUNDING link (#924)
Add a sponsorship home (SPONSORS.md) with Backer/Bronze/Silver/Gold tiers —
described as placements/benefits, with $ amounts left as `<!-- OWNER: set
amounts -->` placeholders (no invented prices). Primary "become a sponsor"
path is a prefilled GitHub issue form (.github/ISSUE_TEMPLATE/sponsor.yml:
name/org, logo URL, tier, contact), with Ko-fi/PayPal as direct paths and an
OWNER placeholder for a public contact email.

README gains a Sponsors subsection (logo-slot placeholder + SPONSORS.md link),
a Sponsors nav entry, and a note about GitHub's native Sponsor button.
FUNDING.yml adds the SPONSORS.md link alongside the existing ko_fi/PayPal.

Keeps the honest "agent bills" framing; sponsorship is a thank-you, not a
paywall — OmniVoice stays fully free and AGPL-3.0. Docs-only; no fabricated
sponsors, prices, or testimonials.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:33:11 +05:30
85b0db65bc ci(release): version-first release titles so the tag shows in GitHub's truncated release list (#922)
GitHub's release-list sidebar clips the title mid-string, hiding the version
when it trails 'OmniVoice Studio'. Name stable releases 'vX.Y.Z — OmniVoice
Studio' and the preview 'Preview — OmniVoice Studio'. Existing releases were
renamed to match.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:24:27 +05:30
45a5a5ce21 docs(changelog): add Launchpad full-width (#915) + migration-logging fix (#917) to [0.3.9] (#918)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 01:13:53 +05:30
d57ecea804 fix(test): DB migration-safety tests leak-proof against full-suite order (#909 follow-up) (#917)
The PR #909 data-safe-update tests passed in isolation but failed only in
full-suite CI order. Two independent, order-dependent leaks were at play:

1. Module-identity leak (the #878/#894 class). The `isolated_db`/`fresh_app`/
   `fresh_resolver` fixtures in tests/backend/** purge `core.*`/`services.*`
   from `sys.modules` and never restore them, so `sys.modules["core.db"]`
   afterward is a DIFFERENT object than the one the migration-safety tests
   imported at collection. `monkeypatch.setattr("core.db.DB_PATH", ...)`
   re-resolved the dotted string to the re-imported module, while
   `_run_alembic_upgrade`/`init_db` (bound at collection) kept reading the
   ORIGINAL module's globals — so the patch missed and the upgrade ran against
   the ambient session DB. Result: no backup at the asserted path, and the
   mid-flight-failure injection never hit the expected DB (DID NOT RAISE).
   The same divergence hit the lazy `from core import db_backup` inside
   `_run_alembic_upgrade`, so patching `MAX_BACKUP_DB_BYTES` was silently lost.

2. Logger-disable leak. Alembic's env.py called `fileConfig(...)` with the
   default `disable_existing_loggers=True`, which disabled the already-created
   `omnivoice.db.backup` logger the first time any earlier test ran a real
   `alembic upgrade` — so the oversized-DB "Skipping pre-migration DB backup"
   line was never emitted and the caplog assertion failed. This also silently
   mutes the live app's logging after a real startup migration.

Fixes:
- env.py: `fileConfig(..., disable_existing_loggers=False)` so a migration
  never mutes the app's (or another test's) loggers.
- core/db.py: import `db_backup`/`APP_VERSION` at module level so
  `_run_alembic_upgrade` uses a stable reference immune to a `sys.modules`
  purge, matching what tests patch at collection.
- test_db_migration_safety.py: patch DB_PATH on the imported `core.db` module
  object rather than the re-resolvable dotted string — the correct,
  self-contained seam.

Verified: the four migration-safety tests + the oversized-backup test pass in
full-suite order and in isolation; full `pytest tests/` is green
(2206 passed, 0 failed).

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 01:13:12 +05:30
6feafbd3be fix(launchpad): full-width responsive feature-card grid (retire the fixed ~780px deck) (#915)
PR #904's deck-of-cards fan pinned the seven Launchpad feature cards
(Voice Clone, Voice Design, Video Dubbing, Stories, Audiobook, Voice
Gallery, Transcripts) inside a fixed ~780px box, leaving dead margins on
a maximized display. Replace it with one full-width grid that fills the
content edge-to-edge and reflows its column count from a maximized
~2560px display down to the 900x600 minimum.

- LaunchpadDeck renders a single `.lp-cards` grid at every shell width
  (no deck-vs-fallback split): `repeat(auto-fit, minmax(--lp-card-min,
  1fr))` derives the column count from the grid's OWN width, so columns
  reflow 7->1 with zero viewport @media (which fire at the wrong width
  under the shell's `zoom: --ui-scale` model). Every column stretches
  (1fr) -> no dead margins, no horizontal scroll.
- The only responsive knob is `--lp-card-min`, set inline from
  useShellNarrow (the `.app-container` shell-narrow/shell-mini own-width
  classes): 200px wide, 240px narrow -> fewer, comfier columns on narrow
  shells. No viewport media queries.
- Cards keep #904's character: animated waveform faces, cursor
  spotlight + eternal breath ring (phase-offset per card via --lp-i),
  and a hover/focus-forward raise (`lp-action-card--raised`) driven from
  React state so pointer and keyboard share one path. Reduced-motion
  freezes the waveform. All 7 navigation targets and i18n keys preserved.
- Removed the old `.lp-deck*` fan CSS, the ActionCard narrow fallback,
  and the launchpad viewport @media overrides. Rewrote the regression
  suite to assert full-width grid layout, the narrow-vs-wide floor, and
  the raise interaction for pointer AND focus.

Verified in a real browser (chromium): 7 cards fill the full width in
one row at 2472px content, reflow to 3 columns at 920/876px, and the
grid width equals the container at every size (no overflow).

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 00:35:00 +05:30
8f71c90f20 feat(settings): LLM Skills — per-feature enable/route control for every LLM call (#912)
New Settings → System → LLM Skills area: every LLM-powered capability
(Cinematic & Autofit translation, speech-rate slot fitting, glossary
auto-extract, direction parsing, dictation cleanup) becomes a "skill" the
user can toggle or route to a specific provider (local Ollama/LM Studio vs
a remote key) instead of everything riding the one global active provider.

Backend:
- services/llm_skills.py — skill registry + settings_store persistence
  (llm_skill.<id>.enabled / .provider), resolution precedence
  override > active > none, resolve_skill_client() (OpenAI-compat client
  bound to the effective provider; None when disabled/unconfigured) and
  skill_backend() (OffBackend when disabled — the exact no-LLM object every
  caller already degrades on).
- All five consumption points wired through the registry; a disabled skill
  degrades exactly like "no LLM configured" today (Fast translation
  fallback, refinement pass-through, heuristic direction parse, no-llm slot
  fit, 503 on glossary auto-extract). No new degradation modes; defaults
  (enabled + no override) keep existing setups byte-identical.
- OpenAICompatBackend gains an optional bound provider (None = active, the
  historical behavior).
- GET /api/settings/llm-skills + PUT /api/settings/llm-skills/{skill_id}
  (404 unknown skill/provider); route snapshot updated.

Frontend:
- LLMSkillsPanel (Sparkles, next to LLM Providers): one row per skill —
  i18n name/description, enable toggle, provider Select ("Use active
  provider" + configured providers, local ones tagged), ready /
  needs-setup badge linking to LLM Providers. All strings via t()
  (settings.llmskills_*).

Tests: 30 backend (precedence, per-consumption-point disabled semantics,
endpoint round-trips, validation) + 4 panel render/PUT tests. Docs:
translation-engines.md gains an LLM Skills section.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 00:08:30 +05:30
a481d459bd docs(changelog): backfill the settings/features wave into [0.3.9] (#913)
Nine PRs (#904-912) shipped without their changelog bullets (agents were
kept off CHANGELOG.md to avoid merge conflicts across the wave); this
backfills them per the changelog hard rule.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 00:07:39 +05:30
af6690840e fix(translate): run Cinematic/Autofit on every engine (incl. default Argos), bound the fit pass, scrub provider errors (#910)
P0 — Cinematic/Autofit silently no-op'd on argos/nllb/openai. Those three
branches returned BEFORE _maybe_cinematic, so only the deep_translator
fall-through reached the refine/fit pass. A user on the DEFAULT Argos engine
who picked Cinematic/Autofit got plain Fast output with a success toast and
no quality_used/cinematic_skipped/rate_ratio. All three now route through
_maybe_cinematic. provider=openai is already an LLM translation, so it skips
the reflect/adapt re-refine (new already_llm flag) but still stamps
rate-ratio badges and runs the Autofit fit pass; the dialect it baked into
its translate prompt is now reported applied.

P1 — the Autofit fit pass ran one blocking adjust_for_slot per segment in the
merge loop, OUTSIDE any budget (a 50-seg dub vs a slow provider spun
~50×timeout unbounded). New speech_rate.adjust_for_slot_many fans it out
concurrently under a wall-clock deadline SHARED with the cinematic refine;
segments still running at the deadline degrade to their literal with
rate_error='fit-budget'. Also set max_retries=0 on the OpenAI clients used
for translate/refine/fit so a 429 + Retry-After can't sleep through the budget.

P2 — glossary auto-extract's no-LLM message now points at Settings → LLM
Providers (was the stale TRANSLATE_BASE_URL/TRANSLATE_API_KEY). Provider error
bodies on the glossary auto-extract, the OpenAI translate-segment path, and the
DeepL/Microsoft translate-segment path are now scrubbed
(core.scrub.scrub_provider_error) — they could echo the API key / a user_id.
DubTab re-polls LLM availability on window focus / visibility so configuring a
provider in Settings lifts the Cinematic gate without a remount. Documented
LLM_DEFAULT_PROVIDER in docs/dubbing/translation-engines.md.

Tests: fail-before/pass-after for argos+cinematic (refine runs), argos+cinematic
no-LLM (cinematic_skipped), argos Fast (rate_ratio stamped), openai+autofit
budget bound, and provider-error scrubbing on the translate + glossary paths.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:53:11 +05:30
75864a597f fix(dictation): refinement never stalls a final (~51s→≤4s), REST polish parity, real ASR preload reuse (#911)
P0 — Refinement blocked every dictation final with no timeout. With refinement
auto:true and a slow/dead LLM endpoint, maybe_refine ran unbounded and blocked
the final send in all three capture_ws handlers (~51s measured; the pill hung
"Transcribing…" until the widget's 15s fallback fired). Fix the class: a hard,
env-tunable budget (OMNIVOICE_REFINE_TIMEOUT_S, default 4s) via a new
maybe_refine_async — a slow/dead endpoint now falls back to the unrefined (but
polished) text within the budget and can NEVER delay the final beyond it. The
LLM HTTP call is bounded to the same budget so the orphaned worker unwinds
instead of holding a connection for the client's full 45s. Refinement is now
also fully best-effort in the legacy handler (it can't turn a good final into
an error frame).

P1 — REST /transcribe lacked polish parity. capture.py never applied
polish_text, so REST returned raw "…test" while the WS returned "…test."
Apply text_polish.polish_text to `text` and `refined_text` (segments stay raw),
so the widget POST fallback and MCP/CLI callers match the live socket.

P1 — The #888 "instant first dictation" preload was a no-op. The preload called
warmup() only `if hasattr`, but SherpaDictationBackend had none, and the WS
handlers built a FRESH backend per session so a warm singleton wasn't reused.
Add SherpaDictationBackend.warmup() (builds the recognizer) and share one warm
recognizer per model id across sessions (get_sherpa_dictation_backend, same
invalidation + a shared lock as the capture singleton); each session keeps its
own decode stream. First dictation no longer pays the 1.3–2.5s load.

P1 — llm_ready is a lie (feeds the P0). It only means "an endpoint is
configured", so a placeholder key reads as ready. The P0 timeout makes a dead
endpoint harmless; add last_refine_status so RefinementPanel flags a
configured-but-failing LLM and links to LLM Providers → Test.

Regression tests (fail-before/pass-after): slow-LLM WS final arrives < budget;
maybe_refine_async hard timeout + status; REST polish parity + refined_text
polish; warmup builds the recognizer and a second session reuses it; the panel
honesty note. Backend refinement/capture_ws/capture/sherpa suites, CJK + route
inventory gates, full vitest (733), lint (0 errors) and format all green.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:52:45 +05:30
16294fed44 feat(updates): data-safe updates — pre-migration DB backups, guarded venv heal, release notes + changelog reader (#909)
Backend:
- core/db_backup.py: WAL-safe SQLite snapshot to omnivoice.db.backup-<version>-<n>
  before pending alembic migrations run; keep newest 3, prune older; skip >500MB
  with a log line. Restore is never automatic.
- core/db.py: _run_alembic_upgrade now plans the run (up_to_date / pending /
  unknown_revision), snapshots first when migrations will execute, and raises
  MigrationError on a mid-flight failure — startup stops with the backup path
  named instead of continuing on a half-migrated DB. The #552/#547
  unknown-revision class stays non-fatal (warn + additive reconcile).
- core/changelog.py + GET /api/settings/changelog: parse the shipped
  CHANGELOG.md (single-line and wrapped bullet styles) into structured releases.
- GET /api/settings/db-backup: newest pre-migration backup for the panel.

Rust (bootstrap.rs):
- #314 heal guard: an exit-signature match alone can no longer delete the venv —
  venv_rebuild_justified requires a structural problem or a failed direct
  interpreter probe; a venv that probes healthy is kept and the real error
  surfaced. Drift/repair remains in-place `uv sync` (non-destructive).
- CHANGELOG.md now ships as a bundle resource and is copied/refreshed into the
  project dir so the changelog endpoint works in packaged installs.

Frontend (Settings → Updates):
- Available update shows its actual release notes (updater metadata body)
  through a safe markdown-lite renderer (text nodes only, refs stay plain).
- "Your data is backed up before every update" line with the latest backup
  timestamp from the new endpoint.
- "What's new" changelog reader (accordion, newest expanded) over the shipped
  CHANGELOG.md; GitHub releases list reuses the same renderer.
- One-time, non-blocking "What's new" footer pill after an update
  (persisted last-seen version; fresh installs baseline silently).
- All strings via t() with en keys (other locales fall back to English).

Tests: db backup/rotation/failure-path units, migration-safety units, changelog
parser (both bullet styles + real CHANGELOG.md), endpoint tests, route
inventory regenerated, Rust decision-logic + probe tests, vitest suites for
renderer/viewer/panel/pill logic.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:52:17 +05:30
e2c4ea93b0 fix(models-settings): surface async install errors, disk-space guard, cancel wiring, honest restart (#908)
Live-audit fixes for the Models settings surface — the P1s were cases where
the feature silently didn't work for the user.

P1-A — Async install errors were invisible. The `install_error` SSE event
carries excellent mirror-aware text (#890 core/failure.py), but the Model
Store auto-purged the errored row ~800ms later (same as a success) and the
first-run WizardLibrary DELETED the row without ever reading `ev.error`. The
SSE→rowState reduction is now a pure, tested reducer (downloadReducer.js /
reduceWizardDownloadEvent); only SUCCESS terminals auto-purge
(isAutoPurgeTerminal), an error persists on the row with inline text + Retry +
Dismiss (Model Store) / a Retry (wizard).

P1-B — No disk-space check on install. `POST /models/install` now compares the
FDL-05 plan's exact `to_download_bytes` (+ MIN_FREE_GB headroom) against
`shutil.disk_usage(cache).free` BEFORE downloading and emits an actionable
install_error naming the sizes (needs X, headroom Y, have Z) instead of failing
mid-download. `/models` also surfaces `disk_free_gb` in the header. MIN_FREE_GB
+ disk_free_bytes are single-sourced in setup/models.py (wizard delegates).

P2-A — Wired the orphaned cancel. `POST /models/install/cancel` (FDL-11) had
zero frontend refs; the in-progress row now shows a Cancel button that calls it
and transitions the row to install_cancelled.

P2-B — Honest restart_required. The HF-mirror PUT returned restart_required:true
unconditionally; it now returns true only when the persisted value actually
changed, with accurate copy (Model Store downloads use the new mirror
immediately — resolved per-call; only transformers model loads need a restart).

P3 — i18n the un-localized panels (HFMirrorPanel, ApiKeysPanel source
labels/help/status, MODEL_ROLE_LABEL) via new en.json keys; other locales fall
back to en.

Tests: new tests/test_install_disk_space.py (reject-when-over-budget incl. the
worker wiring; allow-when-fits; degrade on unknown size/unprobeable volume),
updated tests/test_hf_mirror_settings.py (change-only restart_required), and new
frontend reducer + column-render tests for install_error persistence, Retry,
Dismiss, and Cancel.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:40:15 +05:30
b3c18db33f feat(settings): Storage panel — real disk usage, category breakdown, and low-space warnings (#906)
Settings → Storage now opens with a Disk usage panel backed by a new
loopback-gated GET /api/settings/storage endpoint:

- Per-volume totals (grouped by st_dev) + du-style sizes for everything
  the app owns: the HF model cache (with its ~10 largest models), the
  app data dir broken into voices/outputs/dub_jobs/batch/preview/
  database/logs/other subtotals, engine venvs (backend/engines/*/.venv
  + the app venv), and omnivoice* entries in the OS temp dir.
- Bounded scanning: per-category 10 s deadline → partial totals with an
  "unreadable" warning instead of a hung request; results cached
  in-process for 5 minutes, ?refresh=1 forces a rescan; the walk runs
  in a worker thread so the event loop never blocks.
- Server-side warnings reuse the setup wizard's MIN_FREE_GB: free <
  min → critical, free < 2×min → low, volume holding the cache/data
  >90% full → volume_pressure, unreadable/timed-out paths → unreadable.

The panel renders severity-colored banners, a data-volume gauge,
proportion bars per category, Open-folder buttons (existing
/export/reveal pattern), a Model Store jump for reclaiming model
space, and the existing clear-logs action on the logs row. A critical
warning is also surfaced outside Settings via the app-wide toast —
once per session. All strings via i18n (en fallback).

Tests: tests/test_storage_report.py (sizes, thresholds, cache/refresh,
timeout partials, endpoint wiring) + StorageUsagePanel.test.jsx
(categories, banners, once-per-session toast, refresh=1, error state);
route added to tests/fixtures/api_routes.txt via the dump script.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:39:49 +05:30
e7fc37d438 fix(settings): retire legacy LLM endpoint panel, surface env overrides, fix Cloudflare account + fast-fail probes (#907)
Live-audit fixes for Settings → LLM Providers / Translation.

Retire the legacy LLMEndpointPanel from the UI (backend endpoint kept).
TranslationTab no longer embeds the inline endpoint panel — it now points to
Settings → LLM Providers (openSettingsTab('llm-providers')), which fully covers
it via the `custom` provider (a lone TRANSLATE_BASE_URL still resolves to
`custom`). Kills the panel's lying "reachable" badge, its hardcoded-English
strings, and one of three duplicate TRANSLATE_* surfaces. The third duplicate —
TranslationTab's "Provider keys" collapsible — drops the TRANSLATE_* trio
(now owned by LLM Providers) and keeps only the DeepL/Microsoft translator
keys; its toast no longer claims "saved for session" (these are in
PERSISTENT_KEYS, restored at startup). GET/PUT /api/settings/llm-endpoint is
untouched (DubTab gates Cinematic off it; tests + route inventory cover it).

Surface env overrides. describe() now reports base_url_from_env / model_from_env
/ active_from_env (mirroring key_from_env). The panel disables env-pinned
base_url/model/account fields with an explainer, and — when
LLM_DEFAULT_PROVIDER pins the active provider — disables make-active and shows a
banner, instead of silently reverting the user's edit / no-oping the button.

Fix the Cloudflare account-id flow (broken two ways): describe() now returns the
stored account_id (the field no longer resets to empty) and shows the RAW
base_url template ({account_id} kept literal) instead of the substituted value;
save_overrides drops a base_url override equal to the built-in default, so the
UI posting the shown value back can't freeze the URL — later account-id changes
take effect again (also self-heals if a default URL changes in a release).

Fast-fail the Test / Fetch-models probes. Pass max_retries=0 to the probe
OpenAI clients so a 429/timeout returns in seconds instead of ~34s on the SDK's
default retry ladder. /models now returns truncated:true when capped at 200 and
the UI hint reads "first 200 shown".

Tests: registry env-flag + Cloudflare round-trip/no-freeze regressions; router
truncation + max_retries=0 assertions; panel disabled+explained + banner;
new TranslationTab test (pointer wired, legacy panel gone, TRANSLATE_* dropped).

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:36:50 +05:30
b825d99337 fix(engines): revive dead license dialog, refresh matrix on select, surface routing verdict (#905)
Six live-audit fixes for the Engines settings surface:

- P1-A: the Supertonic license dialog was dead since #101 — `useState`
  threw away the state value (`const [, setLicenseDialogFor]`) and the
  imported dialog was never mounted, so "Accept license" did nothing.
  Keep the value and render LICENSE_DIALOGS[selected] with open/onClose/
  onAccepted (accept → matrix reload).
- P1-B: the matrix went stale after "Use" — active badge, Use buttons and
  family-tab captions stayed old until a manual Refresh. Await onSelect,
  then reload() so the picked engine reflects immediately.
- P2-A: consume the /engines/select routing echo. A `cpu_fallback` pick now
  shows a warn-tone toast naming the reason ("running on CPU — …"); the
  plain success toast stays for accelerated/cpu_only. Shared helper used by
  both Settings→Engines and the first-run WizardLibrary.
- P2-B: a CPU-native engine (gpu_compat == ("cpu",)) has nothing to fall
  back FROM, yet on a GPU/MPS host it was mis-classed cpu_fallback (warn).
  New routing rule classifies ("cpu",) as cpu_only (neutral) on any
  accelerator host; multi-target engines that could accelerate elsewhere
  are untouched.
- P3-A: the routing reason was only a badge `title` (unreachable on
  keyboard/touch) — surface it as small visible text under the badge.
- P3-B: an in-process "Test engine" pass is an import/liveness check, not a
  synthesis test — label it "deps OK" instead of a misleading "0 ms"
  latency; subprocess rows keep their real ping latency.

Adds RTL + unit regression tests for all six and updates the routing unit
tests to the corrected cpu-native intent. i18n keys added to en.json.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:22:49 +05:30
b6a3eba1ac feat(launchpad): deck-of-cards redesign — fanned feature cards with waveform faces (#904)
The seven launchpad feature cards now render as an overlapping deck fanned
left-to-right: each card sits in a fan slot with a subtle tilt (±4°) and
vertical stagger (≤14px), peeking ~33% out from under its right neighbor.
Every card face carries its lucide icon + name on the always-visible peek
edge, a one-line description, and a decorative CSS-only animated waveform
strip in the card's accent color (stagger-delayed scaleY bars, aria-hidden,
static under prefers-reduced-motion).

Hovering OR keyboard-focusing any card brings it fully forward — it
straightens, scales up and takes the top of the stack while every other
card slides toward it and tucks underneath (dimmed, scaled down, overlap
increased). The raise/tuck classes are React-state-driven so pointer and
focus share one code path and tests can assert it. Fixed deck height —
zero layout jump.

Navigation targets, i18n keys, per-feature accent hues and profile/project
counts are unchanged; both renderings share a single feature list so they
can't drift. On shell-narrow/shell-mini (the app-container's own width
classes — not viewport @media, per the UI-scale rationale in App.jsx) the
deck degrades to the pre-existing flat ActionCard grid, tracked live via
MutationObserver (new useShellNarrow hook), keeping 900×600 usable.

New LaunchpadDeck.test.jsx covers: 7 cards in canonical order, every
navigation target (incl. clone/design → studio + defineMethod), raise/tuck
partitioning for hover and focus, waveform decorativeness, the narrow
fallback under both shell classes, and the runtime class flip.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:22:35 +05:30
bef688e9dd release: freeze v0.3.9 — version bump, lockfiles, changelog (#899)
* release: freeze v0.3.9 — version bump + lockfiles + changelog

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* release: unwrap the [0.3.9] section — release bodies hard-break single newlines

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:35:39 +05:30
958a79ef7f fix(generate): device-aware timeout guidance — stop telling CPU hosts to switch to CPU (#896) (#902)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:26:24 +05:30
72d137e1f3 fix(bootstrap): port cuDNN 8 (NVIDIA CUDA GPU) + VC++ redist to packaged installs (#869)
* fix(bootstrap): port cuDNN 8 (NVIDIA CUDA GPU) + VC++ redist install into ensure_venv_ready()

* fix(bootstrap): address #869 review — drop dead VC++ half, cache negative CUDA probe, gate on ROCm, sync docs

Per maintainer review on #869:

1. Drop the VC++ Redistributable half: LoadLibraryA("vcruntime140.dll")
   from the running Tauri exe is a tautology (the exe itself links the
   MSVC CRT, so the process wouldn't be running without it), and torch's
   real failure mode is msvcp140.dll inside the venv python process.
   Dead code removed; a comment records why for future readers.

2. Stop taxing every non-CUDA launch: a negative torch probe (CPU /
   Intel / AMD — most installs) is now cached in a
   .venv/.cudnn8_probe_negative marker, so the synchronous `import
   torch` runs at most once per venv lifetime. Invalidated on every
   path that can change the torch build (drift sync #307, repair sync,
   first-run sync, ROCm reinstall) and implicitly by a venv rebuild.
   A probe that fails to run cleanly is skipped WITHOUT caching so a
   transient error can't wedge a real CUDA machine.

3. Rewrite docs/install/troubleshooting.md §10 to the actual root
   cause: packaged installs never had the cudnn8_compat libs (so
   reinstalling never restored them); the bootstrap now installs them
   automatically on CUDA machines, with the manual uv pip command as
   the offline fallback and PyTorch Whisper as the sidestep.

4. Gate the ~700 MB nvidia-cudnn-cu12 download on the venv torch being
   a real CUDA build: the probe now reports 'hip' before checking
   cuda.is_available() (which HIP spoofs), so opt-in ROCm installs
   (#124) never fetch the CUDA wheel.

Also reflow the CHANGELOG entry to house style (bold one-line lead,
1-3 lines of why, (#827, #869) refs) and extend the bootstrap unit
tests: classify_cuda_probe verdict mapping and the marker
write/invalidate round-trip (6 cuDNN tests total, 43 lib tests green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:23:06 +05:30
e8fdf0e244 feat(footer): Logs icon + uniform icon sizes + value-moment donate popover (Clippy-style, strictly throttled) (#898)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:23:36 +05:30
83e71c5689 fix(asr): close the #730 residuals — chunked dub wedge shares the guarded reset; repeated timeouts recommend the crash-isolated engine (#895)
Residual A — the chunked dub-stream had a PARALLEL wedge mechanism (its own
ping-loop timeout, its own _reset_pool_on_wedge, a dead-end "Try restarting
the server" message). A wedged chunk now routes through the SAME
run_transcribe_guarded bound+reset as the whole-file paths (#731/#851): the
guard resets the poisoned pool once per wedged attempt (no double-reset on
retry) and the user sees the actionable ASRTimeoutError. The reset logic is
extracted to asr_backend.reset_pool_after_wedge — one shared mechanism, so
the semantics can't drift again. run_transcribe_guarded also gains a
timeout_env param so chunk errors name OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S
instead of the whole-file knob.

Residual B — the crash-isolated ASR sidecar (#393, faster-whisper-isolated)
is wired as an explicit ESCAPE HATCH, not a default:
- selectable end-to-end: Settings engine list gets an explanatory
  install_hint; honest gpu_compat ("cuda","cpu" — it wraps the same
  CTranslate2 engine as faster-whisper); get_active_asr_backend now hands
  back a process-wide singleton for subprocess-isolated backends (a fresh
  instance per request would leak atexit hooks and respawn the sidecar —
  reloading its model — on every transcribe).
- on the SECOND consecutive guarded timeout-with-reset in one session
  (resets aren't recovering the hang; the wedged thread keeps its VRAM),
  the error the user sees + the log recommend switching to the isolated
  engine in Settings → Engines. Never auto-switched (owner rule: no silent
  behavior divergence); a completed transcribe resets the streak.

Tests (fail-before/pass-after verified against origin/main): wedged-chunk
SSE integration (reset count + actionable error + recommendation surfaces),
consecutive-timeout streak (fires at 2, resets on success, suppressed when
already on the isolated engine), timeout_env parametrization, shared-reset
helper, isolated backend in list_backends with hint + honest availability,
singleton caching, gpu_compat matrix entry. Docs: troubleshooting §14 gains
the chunk knob + escape-hatch guidance.

Closes the residuals tracked on #730.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:34:28 +05:30
3bb401f4e5 test: make LLM-provider state leaks between tests impossible (#878) (#894)
Root cause: LLM provider selection reads three process-global surfaces —
env vars (LLM_DEFAULT_PROVIDER, per-provider *_API_KEY/*_BASE_URL,
TRANSLATE_*), the SQLite settings store (llm.active_provider & co.), and
prefs.json (llm_backend). Importing `main` (TestClient fixtures do)
dotenv-loads the developer's .env and ~/.config/omnivoice/env straight
into os.environ, and several tests/endpoints mutate these surfaces
without teardown — so whichever test imported the app first flipped what
later tests' active_backend_id()/active_provider_id() resolved to
(order-dependent failures in test_engines.py,
test_llm_endpoint_settings.py, test_llm_providers.py).

Fix the class, not the instances:
- tests/conftest.py: redirect OMNIVOICE_DATA_DIR to a per-session tmp dir
  and OMNIVOICE_ENV_FILE into it (before collection freezes
  core.config.DATA_DIR), so tests never read or write the developer's
  real app state and local runs behave like clean CI.
- tests/conftest.py: autouse `_isolate_llm_provider_state` fixture
  snapshots env (derived from llm_providers._PROVIDERS, so new providers
  are guarded automatically), llm.* / secret.llm_key.* settings rows, and
  the prefs llm_backend/env.TRANSLATE* keys before every test and
  restores them exactly afterwards.
- shared `clean_llm_env` fixture clears the FULL provider env surface;
  the four LLM test modules' hand-picked partial delenv lists (which left
  e.g. LLM_DEFAULT_PROVIDER / OPENROUTER_API_KEY standing) now use it.
- tests/test_llm_state_isolation.py: deterministic fail-before/pass-after
  regression pair — pollutes all three surfaces without cleanup, then
  asserts the guard restored them.

Verified: the issue's two-test repro passes; the five LLM-related test
files pass in order; full suite green (2046 passed, 20 skipped,
10 xfailed, 4 xpassed).

Fixes #878

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:06:14 +05:30
86f5213055 fix(splash): IPC-independent watchdog + recovery panel for dead Tauri IPC (#879) (#892)
After an unclean shutdown (Windows BSOD), the WebView2 profile cache
(%LOCALAPPDATA%\com.debpalash.omnivoice-studio\EBWebView) can corrupt:
Tauri's IPC custom protocol fails AND the postMessage fallback breaks,
so invoke() hangs forever. useBootstrapStage's poll loop rode entirely
on that IPC — a hung bootstrap_status call silently killed the loop and
the splash sat at "preparing" forever, even with a fully healthy
backend answering over plain HTTP.

Class fix, three parts:

- splashWatchdog.js: IPC-independent escape hatch. If no IPC signal
  arrives within 10s, poll GET /health over plain HTTP; healthy →
  proceed to the app as if 'ready' was received (console.warn
  breadcrumb so diagnostic bundles carry it). Any successful IPC
  response disarms it for good.
- Recovery panel (stage 'ipc_lost'): if neither IPC nor HTTP succeed
  within 45s, show an actionable panel instead of the infinite
  spinner — "Open logs" (with an inline path fallback when IPC is
  dead) and, Windows-only and only in this error state, "Repair and
  restart". Health polling continues behind the panel so a slow
  first-run install with broken IPC still reaches the app.
- clear_webview_cache_and_relaunch (Rust): writes a marker and
  relaunches; the fresh process deletes EBWebView at the top of run()
  before any webview exists (WebView2 holds locks while running),
  with a bounded retry while the old instance exits. Runtime cfg!
  guards keep the whole path compiling on every platform.

Tauri 2 exposes no reliable flag for the postMessage-fallback mode
(closure-local in its injected ipc.js), so the logged detector is the
observable combination: zero IPC signals + working plain HTTP.

Fail-before/pass-after regression tests: hung invoke + healthy HTTP →
ready; hung invoke + dead backend → recovery panel, then auto-continue;
working IPC → normal path untouched, zero HTTP polling. Plus watchdog
state-machine unit tests and recovery-panel render/interaction tests
(6/7 fail on the pre-fix component). Troubleshooting doc gains the
matching section (docs-sync).

Fixes #879

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:52:44 +05:30
6e600c48cb fix(generation): classify network/download failures — stop mislabeling every unknown error as OOM (#880) (#893)
A kittentts first-use HuggingFace download died with httpx's "Cannot send
a request, as the client has been closed", and the generation error
classifier's catch-all fallback told the user (CPU-only ~80 MB ONNX engine,
12 GB-VRAM box) they were OUT OF MEMORY and to press Flush — the wrong
remedy for a network failure.

Three-part class fix:

- generation.py: new #880 branch (before the OOM hint) classifies
  httpx/requests transport failures — matched over the whole exception
  chain (type names like ConnectError/ReadTimeout plus stringified
  signatures like "client has been closed") — as a download/network
  problem with a retry/check-connection remedy.
- generation.py (the real class bug): the OOM hint is no longer the
  catch-all. It now requires an actual OOM signature (typed
  OutOfMemoryError/MemoryError anywhere in the chain, or CUDA/MPS/CPU
  allocator wording); genuinely unknown errors surface as unrecognized
  with the underlying detail instead of a false "ran out of memory".
- tts_backend.py: KittenTTS's first-use load retries exactly once with a
  fresh HF Hub client (huggingface_hub.utils.close_session()) on the
  specific closed-client failure — hub ≥1.x shares one global httpx
  client, and a closed one is recoverable, so the download self-heals
  instead of failing the generation.

Fail-before/pass-after tests: classifier (closed-client message, wrapped
httpx type names, unknown error, real OOM signatures incl. typed
OutOfMemoryError, WinError 1455) + the retry helper (recovers once,
walks the chain, no retry on unrelated errors, single-shot).

Fixes #880

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:38:02 +05:30
14f1257d1f fix(errors): name the configured HF mirror when a model download fails (#874) (#890)
When a non-default HF_ENDPOINT (Settings → Models → Hugging Face mirror,
e.g. hf-mirror.com) is configured and a model load/download fails with a
connectivity error, the raw transformers message ("We couldn't connect to
'https://hf-mirror.com' to load the files…") leaked to the UI as a bare 500
with no next step.

Class fix — one shared classifier in core/failure.py covers every surface:

- classify()/build_failure(): new HF_MIRROR_UNREACHABLE class with a dynamic
  hint that names the configured mirror, says it may be down, points at
  Settings → Models → Hugging Face mirror, suggests the official endpoint
  when the model isn't cached, and notes the restart requirement (HF reads
  HF_ENDPOINT at backend start). Checked before the video-download network
  class so a model download's "timed out" no longer gets the "video server"
  hint. Feeds /model/status and every build_failure event (dub, tasks).
- main.py global 500 handler: appends the hint to the surfaced detail, so
  ALL routes that can leak a model-load error benefit (generate, dub,
  archetypes, …), not just TTS generate.
- setup/download.py install SSE: the install_error event gets the same hint.
- error_journal: "couldn't connect to" / "max retries exceeded" now classify
  as NETWORK_ERROR (was UNKNOWN) for auto-attached bug reports.
- model_manager (#886 family): the "cache incomplete and could not be
  auto-repaired" message now names WHY the auto-repair failed (mirror
  outage, offline mode, full disk no longer read identically), which also
  lets the mirror hint fire on that surface when applicable.

Fail-before/pass-after regression tests in tests/test_hf_mirror_error_class.py
(12 of 13 fail on main).

Fixes #874

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:23:47 +05:30
be1ec3ade0 fix(platform): declare Intel-Mac local backend unsupported — honest first-run gate + docs (#889); Windows portable-install docs (#766 follow-up) (#891)
torch >=2.3 ships no macOS x86_64 wheels (transformers 5.x needs torch >=2.6),
so `uv sync` can never resolve on an Intel Mac — per the platform-parity rule
the honest option is declaring the platform unsupported, not letting first
launch die in a raw resolver error:

- bootstrap.rs: pre-check on macOS x86_64 before any venv create / uv sync
  (first-run AND repair paths) fails fast with an actionable message
  (remote-backend escape hatch + docs link); healthy pre-torch-bump venvs are
  deliberately untouched. Unit test pins the message's load-bearing phrases.
- BootstrapSplash: routes the failure to a dedicated localized hint
  (bootstrap.hint_intel_mac, all 21 locales) and suppresses the useless
  Retry-oriented hints for it.
- README + docs/install/macos.md (+ troubleshooting #9): every Intel-Mac
  support claim now says UI-installs-but-backend-cannot-run, including the
  from-source path (also broken); remote backend documented as the only use.
- release.yml: #889 note on the macos-15-intel leg — artifact is UI-only;
  keep-or-drop is an owner call, deliberately not changed here.
- docs/install/windows.md: new "Portable install (Windows)" section promised
  in #766 — custom MSI wizard folder / msiexec INSTALLDIR=..., what lives in
  OmniVoiceStudio-Data next to the exe, and the Program-Files-greyed-out why.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:22:37 +05:30
d58010fe1b feat(dictation): rebuild to Wispr-Flow quality — live waveform, streaming commits, honest insertion, polished text (#888)
* feat(dictation): rebuild to instant-feedback quality — waveform, streaming commits, honest insertion, text polish

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): dictation rebuild entry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(lint): Array.from over new Array(n) — oxlint no-array-constructor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:57:56 +05:30
da9315815d feat(settings): LLM provider testing pass — latency + classified errors, model discovery, full i18n, router tests (#887)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:03:13 +05:30
bb492086c9 fix(desktop): enforce maximize() at startup — macOS can ignore the conf flag with Overlay title bar (#881 follow-up) (#884)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:16:34 +05:30
641e660677 fix(shell): LogsFooter becomes a real grid row — bottom buttons can't clip under it at small window sizes (#882)
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:31:23 +05:30
329 changed files with 29028 additions and 3698 deletions
+3 -1
View File
@@ -2,6 +2,8 @@
# GitHub Sponsors isn't set up for this account — fund via Ko-fi or PayPal.
ko_fi: debpalash
custom: ["https://paypal.me/palashCoder"]
custom:
- "https://paypal.me/palashCoder"
- "https://github.com/debpalash/OmniVoice-Studio/blob/main/SPONSORS.md"
# github: [debpalash] # not available
# open_collective: omnivoice-studio
+78
View File
@@ -0,0 +1,78 @@
name: 🤝 Sponsorship inquiry
description: Support OmniVoice and (optionally) claim a logo slot. Not for bugs or feature requests.
title: "Sponsorship inquiry: "
labels: ["sponsor"]
body:
- type: markdown
attributes:
value: |
Thanks for considering sponsoring **OmniVoice Studio** 💛
OmniVoice is free, local-first, and AGPL-3.0 — sponsorship keeps development going.
See **[SPONSORS.md](https://github.com/debpalash/OmniVoice-Studio/blob/main/SPONSORS.md)** for tiers, placements, and logo guidelines.
Prefer to just donate? [Ko-fi](https://ko-fi.com/debpalash) (recurring) or [PayPal](https://paypal.me/palashCoder) (one-time) — you don't need this form for that.
- type: input
id: name
attributes:
label: Name or organization
description: How you'd like to be credited (person or company).
validations:
required: true
- type: input
id: website
attributes:
label: Website / link
description: The URL your name or logo should link to (homepage, product page, profile…).
placeholder: https://example.com
- type: input
id: logo
attributes:
label: Logo URL (optional)
description: Link to your logo (SVG preferred, else 2× PNG, transparent background). You can also attach it in the description below.
placeholder: https://example.com/logo.svg
- type: dropdown
id: tier
attributes:
label: Tier you're interested in
description: See SPONSORS.md for what each tier includes. Not sure? Pick "Not sure yet".
options:
- Backer
- Bronze
- Silver
- Gold
- Not sure yet — let's talk
- Custom / annual arrangement
validations:
required: true
- type: dropdown
id: method
attributes:
label: How you'd like to support
options:
- Ko-fi (recurring)
- Ko-fi (one-time)
- PayPal (one-time)
- Not sure yet — let's discuss
validations:
required: true
- type: input
id: contact
attributes:
label: How should we reach you?
description: Email or another contact. (GitHub will also notify you on this issue.)
validations:
required: true
- type: textarea
id: notes
attributes:
label: Anything else?
description: Questions, constraints, timeline, or context. Attach your logo here if you didn't link it above.
- type: checkboxes
id: ack
attributes:
label: Acknowledgements
options:
- label: I understand sponsorship is a thank-you, not a paywall — OmniVoice stays fully free and AGPL-3.0, and sponsors don't get gated features.
required: true
- label: If I provide a logo, I have the right to use it and grant OmniVoice permission to display it in the README, the app, and the project website.
required: false
+5 -2
View File
@@ -118,9 +118,12 @@ jobs:
working-directory: frontend
run: bun run format:check
# `bun run test` (frontend/package.json), not `bunx vitest` — bunx
# resolves by npm package name and can miss workspace-hoisted bins,
# then falls back to fetching from npm (#962 class).
- name: Run Vitest (frontend)
working-directory: frontend
run: bunx vitest run
run: bun run test
# Legacy node:test runner for tests/frontend/*.test.mjs
- name: Run frontend node:test (legacy)
@@ -146,7 +149,7 @@ jobs:
- os: windows-2022
label: Windows
rust_target: x86_64-pc-windows-msvc
- os: ubuntu-22.04
- os: ubuntu-24.04
label: Linux
rust_target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.os }}
+24 -3
View File
@@ -190,6 +190,14 @@ jobs:
# backlog that motivated the original drop is contained by
# fail-fast:false — a slow Intel leg can delay the release run but
# can't fail the other targets.
#
# #889 (2026-07): Intel macOS is now UNSUPPORTED for the local
# backend — torch ≥2.3 ships no macOS x86_64 wheels, so the venv
# bootstrap can never succeed on Intel. The shipped x64 artifact is
# effectively UI-only (usable with a remote backend); the app now
# pre-fails first-run bootstrap with an honest message on Intel.
# Whether to keep shipping this x64 leg (UI-only) or drop it is an
# OWNER CALL — deliberately not changed in the #889 PR.
- os: macos-15-intel
arch: x86_64-apple-darwin
label: "macOS Intel"
@@ -205,13 +213,24 @@ jobs:
bundles: "msi,updater"
# Linux: ship .AppImage only. AppImage is universal (no distro
# package-manager dep), runs on any glibc-2.31+ host, and is the
# package-manager dep), runs on any glibc-2.39+ host, and is the
# Linux auto-update target. The .deb target was dropped: tauri-bundler
# fails it with "Failed to create control scripts: No such file or
# directory" (no custom deb config of ours is at fault) — revisit on a
# tauri-cli bump. FUSE unavailability on GH runners is handled via
# APPIMAGE_EXTRACT_AND_RUN=1.
- os: ubuntu-22.04
#
# Bumped from ubuntu-22.04 → ubuntu-24.04 (#961): the AppImage
# bundles whatever `libwebkit2gtk-4.1-dev` the build runner's apt
# repos resolve (see the "Linux system deps" step below) — 22.04's
# was meaningfully stale relative to what current Ubuntu/Fedora
# ship, and AppRun's LD_LIBRARY_PATH makes that bundled, stale copy
# take priority over a healthy system WebKitGTK at runtime. Raises
# the AppImage's glibc floor from 2.35 to 2.39 — pre-2022 distros
# (Ubuntu <22.04, Debian <12) lose support; no report of anyone on
# something that old has come in, and the project's own install
# docs already assume Debian 12 / Ubuntu 22.04+.
- os: ubuntu-24.04
arch: x86_64-unknown-linux-gnu
label: "Linux x64"
rust_target: x86_64-unknown-linux-gnu
@@ -516,7 +535,9 @@ jobs:
# Every other invocation — crucially the `v*` tag-push stable release
# — evaluates these expressions to exactly their prior values.
tagName: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
releaseName: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'OmniVoice Studio (Preview)' || format('OmniVoice Studio {0}', github.ref_name) }}
# Version-first so the tag is readable in GitHub's truncated
# release-list sidebar (which clips the title mid-string).
releaseName: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'Preview — OmniVoice Studio' || format('{0} — OmniVoice Studio', github.ref_name) }}
releaseBody: ${{ steps.changelog.outputs.body }}
releaseDraft: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'false' || (inputs.draft || 'true') }}
prerelease: ${{ needs.preview-gate.outputs.is_preview == 'true' }}
-570
View File
@@ -1,570 +0,0 @@
# Test-install pipeline — CI-only verification that the app INSTALLS and
# FIRST-RUNS (including the required default TTS model) on every supported
# platform, producing throwaway installer artifacts.
#
# This workflow NEVER releases anything:
# - no tag, no GitHub Release, no updater manifest, no publishing
# - unsigned builds (updater artifacts disabled via a --config overlay, so
# no TAURI_SIGNING_PRIVATE_KEY / APPLE_* secrets are needed or read)
# - no version stamping/bumping — bundles carry whatever version is in git
# - installers land as short-lived workflow ARTIFACTS (retention: 7 days)
#
# Two independent matrices per platform:
# build — mirrors release.yml's bundle steps (uv + ffmpeg sidecars,
# same `tauri build --target --bundles` invocation) minus
# every tag/sign/publish part, then re-runs release.yml's
# installer structural smoke (DMG mount / MSI quiet install /
# AppImage extract) and uploads the installers.
# first-run-smoke— sets up the backend venv exactly like the app's own first
# launch (`uv sync --frozen --no-dev`, the command
# lib.rs::ensure_venv_ready runs), boots the backend
# headless, waits for the REQUIRED default model
# (k2-fsa/OmniVoice, ~2.4 GB) to download + load, then runs
# one real POST /generate synthesis and validates the WAV.
#
# Triggers: manual dispatch, or a push to the ci/test-install working branch
# (so the run starts straight from the branch without merging to main).
name: Test Install (no release)
on:
workflow_dispatch:
push:
branches: ["ci/test-install"]
# Read-only token — this workflow must be structurally incapable of creating
# tags/releases or pushing version bumps.
permissions:
contents: read
concurrency:
group: test-install-${{ github.ref }}
cancel-in-progress: true
env:
# Run all JavaScript actions on Node 24 (mirrors ci.yml / release.yml).
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Installer builds (unsigned, artifacts only) ──────────────────────────
build:
strategy:
fail-fast: false
matrix:
include:
# Same platform set as release.yml. Note: the Intel-mac leg uses
# macos-15-intel — macos-13 retired in Dec 2025; macos-15-intel is
# GitHub's designated x86_64 migration target (see release.yml).
- os: macos-14
slug: macos-arm64
label: "macOS Apple Silicon"
rust_target: aarch64-apple-darwin
bundles: "app,dmg"
- os: macos-15-intel
slug: macos-x64
label: "macOS Intel"
rust_target: x86_64-apple-darwin
bundles: "app,dmg"
# Windows: MSI only — NSIS fails at makensis near its ~2 GB stub
# limit (see release.yml).
- os: windows-2022
slug: windows-x64
label: "Windows x64"
rust_target: x86_64-pc-windows-msvc
bundles: "msi"
# Linux: AppImage only — tauri-bundler's .deb target currently fails
# with "Failed to create control scripts" (see release.yml).
- os: ubuntu-22.04
slug: linux-x64
label: "Linux x64"
rust_target: x86_64-unknown-linux-gnu
bundles: "appimage"
runs-on: ${{ matrix.os }}
name: Build (${{ matrix.label }})
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
# ── Language runtimes (mirrors release.yml) ────────────────────────
- name: Setup Rust (stable)
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_target }}
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: frontend/src-tauri -> target
key: ${{ matrix.rust_target }}-testinstall
- name: Setup Bun
uses: oven-sh/setup-bun@v1
# ── Platform deps (Tauri host requirements only — no Python here) ─
- name: macOS system deps
if: runner.os == 'macOS'
run: |
brew install ffmpeg || true
- name: Linux system deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
build-essential curl wget file libxdo-dev libssl-dev \
libayatana-appindicator3-dev librsvg2-dev \
libasound2-dev ffmpeg
# ── Frontend build ─────────────────────────────────────────────────
- name: Cache bun deps
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock', 'bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install frontend deps
working-directory: frontend
run: bun install
# ── Sidecars (verbatim from release.yml) ───────────────────────────
# Pinned uv version mirrors the `UV_VERSION` constant in lib.rs; bump
# both together when refreshing.
- name: Bundle uv (${{ matrix.rust_target }})
shell: bash
env:
UV_VERSION: "0.11.7"
TRIPLE: ${{ matrix.rust_target }}
run: |
set -euo pipefail
mkdir -p frontend/src-tauri/binaries
case "$TRIPLE" in
aarch64-apple-darwin|x86_64-apple-darwin|x86_64-unknown-linux-gnu)
ARCHIVE="tar.gz"
;;
x86_64-pc-windows-msvc)
ARCHIVE="zip"
;;
*)
echo "Unsupported target for uv bundling: $TRIPLE"
exit 1
;;
esac
URL="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${TRIPLE}.${ARCHIVE}"
echo "Fetching $URL"
WORK=$(mktemp -d)
if [ "$ARCHIVE" = "zip" ]; then
curl -fsSL "$URL" -o "$WORK/uv.zip"
unzip -j -o "$WORK/uv.zip" -d "$WORK"
mv "$WORK/uv.exe" "frontend/src-tauri/binaries/uv-${TRIPLE}.exe"
else
curl -fsSL "$URL" | tar -xz -C "$WORK"
mv "$WORK/uv-${TRIPLE}/uv" "frontend/src-tauri/binaries/uv-${TRIPLE}"
chmod +x "frontend/src-tauri/binaries/uv-${TRIPLE}"
fi
ls -la "frontend/src-tauri/binaries/"
# Same constant lives in frontend/src-tauri/src/tools.rs:
# FFMPEG_BTBN_VERSION — bump together.
- name: Bundle ffmpeg + ffprobe (${{ matrix.rust_target }})
shell: bash
env:
TRIPLE: ${{ matrix.rust_target }}
FFMPEG_BTBN_VERSION: "latest"
run: |
set -euo pipefail
BINDIR="frontend/src-tauri/binaries"
mkdir -p "$BINDIR"
WORK=$(mktemp -d)
case "$TRIPLE" in
aarch64-apple-darwin|x86_64-apple-darwin)
for TOOL in ffmpeg ffprobe; do
if [ "$TOOL" = "ffmpeg" ]; then
URL="https://evermeet.cx/ffmpeg/getrelease/zip"
else
URL="https://evermeet.cx/ffmpeg/getrelease/${TOOL}/zip"
fi
echo "Fetching $TOOL from evermeet.cx"
curl -fsSL "$URL" -o "$WORK/${TOOL}.zip"
unzip -o -j "$WORK/${TOOL}.zip" -d "$WORK"
mv "$WORK/${TOOL}" "$BINDIR/${TOOL}-${TRIPLE}"
chmod +x "$BINDIR/${TOOL}-${TRIPLE}"
done
;;
x86_64-unknown-linux-gnu)
URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/${FFMPEG_BTBN_VERSION}/ffmpeg-master-${FFMPEG_BTBN_VERSION}-linux64-gpl.tar.xz"
echo "Fetching ffmpeg from BtbN (linux64) — version=${FFMPEG_BTBN_VERSION}"
curl -fsSL "$URL" -o "$WORK/ffmpeg.tar.xz"
tar -xJf "$WORK/ffmpeg.tar.xz" -C "$WORK"
EXTRACTED=$(find "$WORK" -type d -name "bin" | head -1)
mv "$EXTRACTED/ffmpeg" "$BINDIR/ffmpeg-${TRIPLE}"
mv "$EXTRACTED/ffprobe" "$BINDIR/ffprobe-${TRIPLE}"
chmod +x "$BINDIR/ffmpeg-${TRIPLE}" "$BINDIR/ffprobe-${TRIPLE}"
;;
x86_64-pc-windows-msvc)
URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/${FFMPEG_BTBN_VERSION}/ffmpeg-master-${FFMPEG_BTBN_VERSION}-win64-gpl.zip"
echo "Fetching ffmpeg from BtbN (win64) — version=${FFMPEG_BTBN_VERSION}"
curl -fsSL "$URL" -o "$WORK/ffmpeg.zip"
unzip -o "$WORK/ffmpeg.zip" -d "$WORK"
EXTRACTED=$(find "$WORK" -type f -name "ffmpeg.exe" | head -1)
EXTRACTED_DIR=$(dirname "$EXTRACTED")
mv "$EXTRACTED_DIR/ffmpeg.exe" "$BINDIR/ffmpeg-${TRIPLE}.exe"
mv "$EXTRACTED_DIR/ffprobe.exe" "$BINDIR/ffprobe-${TRIPLE}.exe"
;;
*)
echo "⚠ No ffmpeg bundling for target: $TRIPLE (will download at first run)"
;;
esac
ls -la "$BINDIR/"
# ── Tauri build — UNSIGNED, NO PUBLISH ─────────────────────────────
# Invokes the tauri CLI directly (not tauri-action) so there is no
# release codepath at all. A --config overlay turns off
# createUpdaterArtifacts (tauri.conf.json has it on for release.yml),
# because updater payload signing requires TAURI_SIGNING_PRIVATE_KEY —
# deliberately absent here. macOS bundles still get the valid ad-hoc
# seal from tauri.conf.json (bundle.macOS.signingIdentity = "-").
- name: Tauri build (unsigned)
working-directory: frontend
shell: bash
env:
# GH runners have no FUSE; linuxdeploy must extract-and-run.
APPIMAGE_EXTRACT_AND_RUN: 1
run: |
set -euo pipefail
printf '%s\n' '{"bundle": {"createUpdaterArtifacts": false}}' > test-install-overlay.json
bunx tauri build --target ${{ matrix.rust_target }} --bundles ${{ matrix.bundles }} --config test-install-overlay.json
# ── Installer smoke (mirrors release.yml's structural checks) ──────
- name: Installer smoke (macOS)
if: runner.os == 'macOS'
timeout-minutes: 5
shell: bash
run: |
set -euo pipefail
DMG=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/dmg -name "*.dmg" | head -1)
echo "Smoke-testing DMG: $DMG"
MOUNT=$(hdiutil attach -nobrowse -readonly "$DMG" | tail -1 | grep -oE '/Volumes/.*$')
APP=$(find "$MOUNT" -maxdepth 2 -name "*.app" | head -1)
fail() { echo "FAIL — $1"; find "$APP/Contents" -maxdepth 4 -type f 2>/dev/null | head -40; hdiutil detach "$MOUNT" || true; exit 1; }
[ -n "$APP" ] || { echo "FAIL — no .app inside DMG"; hdiutil detach "$MOUNT" || true; exit 1; }
ls "$APP/Contents/MacOS"/* >/dev/null 2>&1 || fail "no shell binary in Contents/MacOS"
find "$APP/Contents" -type f -name 'uv' | grep -q . || fail "bundled uv sidecar missing"
find "$APP/Contents" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
find "$APP/Contents" -type f -path '*/backend/main.py' | grep -q . || fail "backend source backend/main.py missing"
echo "OK — bundle has shell + uv + backend resources"
hdiutil detach "$MOUNT" || true
# Report-only signing verification (same script release.yml runs on
# unsigned/preview paths) — asserts the ad-hoc seal is valid.
- name: Verify macOS signing (report-only)
if: runner.os == 'macOS'
shell: bash
run: |
set -uo pipefail
APP=$(find "frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/macos" -maxdepth 1 -name '*.app' | head -1)
[ -n "$APP" ] || { echo "FAIL — no .app found to verify"; exit 1; }
echo "Unsigned test build → report-only verification."
bash scripts/verify-macos-signing.sh "$APP"
- name: Installer smoke (Windows)
if: runner.os == 'Windows'
timeout-minutes: 5
shell: bash
run: |
set -euo pipefail
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name "*.msi" | head -1)
echo "Smoke-testing MSI: $MSI"
msiexec.exe //i "$(cygpath -w "$MSI")" //quiet //norestart
INSTALL="/c/Program Files/OmniVoice Studio"
fail() { echo "FAIL — $1. Contents:"; find "$INSTALL" -maxdepth 4 -type f 2>/dev/null | head -40; exit 1; }
test -f "$INSTALL/omnivoice-studio.exe" || fail "shell exe missing"
test -f "$INSTALL/uv.exe" || fail "bundled uv missing"
find "$INSTALL" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
find "$INSTALL" -type f -path '*backend*main.py' | grep -q . || fail "backend source main.py missing"
echo "OK — MSI installed shell + uv + backend resources"
- name: Installer smoke (Linux)
if: runner.os == 'Linux'
timeout-minutes: 5
shell: bash
run: |
set -euo pipefail
APPIMAGE=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/appimage -name "*.AppImage" | head -1)
APPIMAGE=$(realpath "$APPIMAGE")
echo "Smoke-testing AppImage: $APPIMAGE"
chmod +x "$APPIMAGE"
EXTRACT_DIR="$(mktemp -d)"
cd "$EXTRACT_DIR"
"$APPIMAGE" --appimage-extract >/dev/null
ROOT="$EXTRACT_DIR/squashfs-root"
fail() { echo "FAIL — $1"; find "$ROOT" -maxdepth 5 -type f 2>/dev/null | head -40; exit 1; }
{ [ -f "$ROOT/AppRun" ] || find "$ROOT" -type f \( -name "OmniVoice Studio" -o -name "omnivoice-studio" \) | grep -q .; } || fail "shell binary / AppRun missing"
find "$ROOT" -type f -name 'uv' | grep -q . || fail "bundled uv sidecar missing"
find "$ROOT" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
find "$ROOT" -type f -path '*/backend/main.py' | grep -q . || fail "backend source backend/main.py missing"
echo "OK — AppImage has shell + uv + backend resources"
# ── Collect + upload installers as short-lived artifacts ───────────
- name: Collect installers
shell: bash
run: |
set -euo pipefail
BUNDLE_DIR="frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle"
mkdir -p test-install-artifacts
find "$BUNDLE_DIR" -type f \
\( -name "*.dmg" -o -name "*.msi" -o -name "*.AppImage" -o -name "*.deb" \) \
-exec cp {} test-install-artifacts/ \;
echo "Installers built:"
ls -la test-install-artifacts/
- name: Upload installers (artifact only — NOT a release)
uses: actions/upload-artifact@v4
with:
name: test-install-${{ matrix.slug }}
path: test-install-artifacts/*
retention-days: 7
if-no-files-found: error
# ── First-run with required models (headless backend, per OS) ───────────
# Replicates what the installed app does on first launch, without the GUI:
# the same venv sync the Tauri shell runs, then backend boot → default
# model download (k2-fsa/OmniVoice, ~2.4 GB) → one real synthesis.
# CPU-only runners: device auto-detect resolves to cpu (or mps on the M1
# runner) exactly as it would on a user's machine.
first-run-smoke:
strategy:
fail-fast: false
matrix:
include:
- os: macos-14
slug: macos-arm64
label: "macOS Apple Silicon"
- os: macos-15-intel
slug: macos-x64
label: "macOS Intel"
- os: windows-2022
slug: windows-x64
label: "Windows x64"
- os: ubuntu-22.04
slug: linux-x64
label: "Linux x64"
runs-on: ${{ matrix.os }}
name: First-run smoke (${{ matrix.label }})
timeout-minutes: 75
env:
# Restricted-network resilience (mirrors ci.yml smoke-matrix).
UV_HTTP_TIMEOUT: "120"
UV_HTTP_RETRIES: "5"
steps:
- uses: actions/checkout@v4
# The Linux venv pulls CUDA-enabled torch (+ nvidia libs); reclaim the
# runner space the preinstalled toolchains occupy so venv + ~2.4 GB
# model fit comfortably.
- name: Free disk space (Linux)
if: runner.os == 'Linux'
run: |
sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/share/boost || true
df -h /
# Graceful skip when a runner genuinely lacks disk: log clearly what
# was (not) covered instead of failing the whole run on ENOSPC.
- name: Disk space gate
id: disk
shell: bash
run: |
set -euo pipefail
df -Pk . "$HOME" || true
FREE_WS=$(df -Pk . | awk 'NR==2 {print int($4/1048576)}')
FREE_HOME=$(df -Pk "$HOME" | awk 'NR==2 {print int($4/1048576)}')
FREE=$(( FREE_WS < FREE_HOME ? FREE_WS : FREE_HOME ))
echo "Free disk: workspace=${FREE_WS}G home=${FREE_HOME}G -> min=${FREE}G"
if [ "$FREE" -lt 12 ]; then
echo "::warning::First-run model smoke SKIPPED on ${{ matrix.label }} — only ${FREE} GB free (< 12 GB needed for venv + ~2.4 GB default model). Installer build coverage is unaffected."
echo "proceed=false" >> "$GITHUB_OUTPUT"
else
echo "proceed=true" >> "$GITHUB_OUTPUT"
fi
- name: Setup Python 3.11
if: steps.disk.outputs.proceed == 'true'
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install uv
if: steps.disk.outputs.proceed == 'true'
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: System deps (macOS)
if: steps.disk.outputs.proceed == 'true' && runner.os == 'macOS'
run: brew install ffmpeg libsndfile || true
- name: System deps (Windows)
if: steps.disk.outputs.proceed == 'true' && runner.os == 'Windows'
shell: bash
run: |
choco install ffmpeg -y --no-progress
ffmpeg -version
- name: System deps (Linux)
if: steps.disk.outputs.proceed == 'true' && runner.os == 'Linux'
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: ffmpeg libsndfile1
version: 1.0
# Exactly the command the installed app's first launch runs
# (lib.rs::ensure_venv_ready → `uv sync --frozen --no-dev`).
#
# Known platform gap surfaced by this smoke (2026-07-02): torch is locked
# to 2.8.0, and PyTorch ships no macOS x86_64 wheels past 2.2.x — so the
# locked dependency set cannot install on Intel Macs AT ALL. A real
# Intel-Mac user's first launch hits the exact same wall. That is a
# product bug, not a harness bug: surface it as a loud warning and skip
# the rest of the smoke instead of failing a leg that can never pass
# until the dependency gap is fixed.
- name: Install backend venv (first-launch parity)
if: steps.disk.outputs.proceed == 'true'
id: venv
shell: bash
run: |
set -uo pipefail
if uv sync --frozen --no-dev 2>&1 | tee uv-sync.log; then
echo "proceed=true" >> "$GITHUB_OUTPUT"
elif grep -q "doesn't have a source distribution or wheel for the current platform" uv-sync.log; then
echo "::warning::First-run smoke SKIPPED on ${{ matrix.label }} — the LOCKED dependency set cannot install on this platform (e.g. torch 2.8.0 has no macOS x86_64 wheels; PyTorch dropped Intel-mac support after 2.2.x). An end-user first launch on this platform fails the same way — this is a product-level dependency gap, not a CI harness issue."
echo "proceed=false" >> "$GITHUB_OUTPUT"
else
exit 1
fi
- name: First-run smoke — backend boot, required-model download, real synthesis
if: steps.disk.outputs.proceed == 'true' && steps.venv.outputs.proceed == 'true'
shell: bash
timeout-minutes: 60
env:
# Generous cold-load budget for CPU runners on a fresh HF cache.
OMNIVOICE_MODEL_LOAD_TIMEOUT: "1800"
run: |
set -uo pipefail
BASE="http://127.0.0.1:3900"
# GH macOS Apple Silicon runners ADVERTISE torch MPS, but the
# virtualized Metal shared pool cannot actually allocate (even a
# 256-byte alloc fails with "MPS backend out of memory") — a runner
# limitation, not a product bug; real M1 machines run MPS fine.
# Hide MPS via a CI-only sitecustomize so device auto-detect
# resolves to CPU, keeping the smoke CPU-only as on the other legs.
if [ "${RUNNER_OS:-}" = "macOS" ]; then
mkdir -p ci-sitecustomize
cat > ci-sitecustomize/sitecustomize.py <<'PY'
# CI-only shim (lives ONLY inside the test-install workflow job):
# GitHub's Apple Silicon runners expose torch.backends.mps as
# available, but Metal allocations fail in the VM. Report MPS as
# unavailable so the backend's device auto-detect picks CPU.
try:
import torch
torch.backends.mps.is_available = lambda: False # type: ignore[assignment]
except Exception:
pass
PY
export PYTHONPATH="$PWD/ci-sitecustomize${PYTHONPATH:+:$PYTHONPATH}"
echo "MPS hidden for this smoke (CI runner limitation) — forcing CPU."
fi
uv run --no-sync python backend/main.py > backend.log 2>&1 &
SERVER_PID=$!
trap 'kill $SERVER_PID 2>/dev/null || true' EXIT
echo "backend pid: $SERVER_PID"
fail() {
echo "::error::${{ matrix.label }}: $1"
echo "── backend.log (last 120 lines) ──"
tail -120 backend.log || true
exit 1
}
# Phase 1: liveness — /health (torch import makes cold boot slow).
UP=0
for i in $(seq 1 60); do
if curl -sf "$BASE/health" >/dev/null 2>&1; then
echo "Phase 1 OK — /health up after ~$((i*5))s"
UP=1
break
fi
kill -0 $SERVER_PID 2>/dev/null || fail "backend process died during boot"
sleep 5
done
[ "$UP" = "1" ] || fail "backend /health not responding after 300s"
curl -sf "$BASE/system/info" | python -c "import sys,json; d=json.load(sys.stdin); print('device:', d.get('device'), '| platform:', d.get('platform'), '| python:', d.get('python'), '| version:', d.get('version'))" || true
# Phase 2: required-model bootstrap — the lifespan preload downloads
# and loads the default checkpoint (k2-fsa/OmniVoice) on first run.
echo "Phase 2 — waiting for required-model download + load (fresh HF cache)…"
ELAPSED=0
READY=0
LAST=""
while [ $ELAPSED -lt 1800 ]; do
LAST=$(curl -sf "$BASE/model/status" 2>/dev/null || echo '{}')
STATUS=$(printf '%s' "$LAST" | python -c "import sys,json; d=json.load(sys.stdin); print(d.get('status','?'))" 2>/dev/null || echo '?')
DETAIL=$(printf '%s' "$LAST" | python -c "import sys,json; d=json.load(sys.stdin); print(d.get('sub_stage',''), d.get('progress',''), d.get('error',''))" 2>/dev/null || echo '')
echo " [${ELAPSED}s] model status: $STATUS $DETAIL"
if [ "$STATUS" = "ready" ]; then READY=1; break; fi
kill -0 $SERVER_PID 2>/dev/null || fail "backend died during model load"
sleep 15
ELAPSED=$((ELAPSED+15))
done
[ "$READY" = "1" ] || fail "required model not ready after 1800s (last status: $LAST)"
echo "Phase 2 OK — required model downloaded + loaded in ~${ELAPSED}s"
# Phase 3: one REAL synthesis through the default engine — the
# end-to-end proof that a fresh install can produce audio.
echo "Phase 3 — POST /generate (real synthesis)…"
HTTP_CODE=$(curl -sS -o smoke_out.wav -w "%{http_code}" --max-time 1200 \
-F "text=OmniVoice Studio first run smoke test. This sentence validates a fresh installation with the required model." \
-F "num_step=4" \
"$BASE/generate") || fail "generate request failed (curl transport error)"
if [ "$HTTP_CODE" != "200" ]; then
echo "response body (first 2000 bytes):"; head -c 2000 smoke_out.wav || true; echo
fail "generate returned HTTP $HTTP_CODE"
fi
SIZE=$(wc -c < smoke_out.wav | tr -d ' ')
HEAD4=$(head -c 4 smoke_out.wav)
[ "$HEAD4" = "RIFF" ] || fail "output is not a RIFF/WAV file (got: $HEAD4)"
[ "$SIZE" -gt 40000 ] || fail "output WAV suspiciously small (${SIZE} bytes)"
echo "Phase 3 OK — real synthesis produced a ${SIZE}-byte WAV on a fresh install"
# Phase 4: report what was downloaded (model cache inventory).
echo "Phase 4 — downloaded model inventory:"
for C in "$HOME/.cache/huggingface" "${LOCALAPPDATA:-}/OmniVoice/hf_cache" "${HF_HOME:-}"; do
if [ -n "$C" ] && [ -d "$C" ]; then
du -sh "$C" 2>/dev/null || true
find "$C" -maxdepth 3 -type d -name "models--*" 2>/dev/null | sed 's/^/ /' || true
fi
done
echo "FIRST-RUN SMOKE PASSED on ${{ matrix.label }}"
- name: Upload smoke evidence (log + WAV)
if: always() && steps.disk.outputs.proceed == 'true'
uses: actions/upload-artifact@v4
with:
name: first-run-smoke-${{ matrix.slug }}
path: |
backend.log
smoke_out.wav
retention-days: 7
if-no-files-found: ignore
+159 -32
View File
@@ -8,44 +8,171 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
## [Unreleased]
### Changed
## [0.3.14] — 2026-07-09
- **The app now always opens maximized (not fullscreen).** Window size and
position are no longer carried over from the previous session — one manual
resize used to make every later launch reopen at that smaller size,
overriding the intended maximized default. Same behavior on macOS
(zoomed window, not a fullscreen Space), Windows, and Linux.
A fast follow to v0.3.13: **every engine family now has a visible picker.** Settings → Engines showed only a TTS table, with the ASR and LLM pickers hidden behind a low-discoverability tab — so the 10 transcription engines (including the new OpenAI-compatible backend) looked unswitchable without env vars. Now all three families get their own table. Also in: the Linux AppImage's white-screen auto-workaround now checks the WebKitGTK it actually ships (not whatever your system reports), and installing to a different drive on Windows is properly documented.
### Added
- **ASR engines get the same Settings picker TTS has.** Settings → Engines now shows a visible picker table per family — TTS, ASR, and LLM — instead of a single TTS-titled table with the other families tucked behind a tab (README even promised a Settings ASR picker that didn't exist). The OpenAI-compatible backend and the 9 local ASR engines become selectable with one click, no env vars needed; an explicit `OMNIVOICE_ASR_BACKEND` still wins over the Settings pick, so pinned setups behave exactly as before. (no issue — UX gap found during #877)
### Fixed
- **Confucius4-TTS is now validated end-to-end — and actually loads.** The
opt-in engine's first live run (Apple Silicon, CPU) caught three
scaffold-era faults: the sidecar could never import `confuciustts` (upstream
ships no packaging, so the documented `pip install -e` fails — the sidecar
and bootstrap probe now put the clone on `sys.path`, like upstream's own
example), the assumed 24 kHz sample rate was wrong (confirmed **22 050 Hz**,
now regression-tested), and the docs demanded an Amphion/MaskGCT install
that doesn't exist (all weights auto-download from HuggingFace). CPU is
~17× realtime, so CUDA stays the recommended path; `gpu_compat` now
advertises `("cuda", "cpu")`. (#590)
- **The Linux AppImage's white-screen auto-workaround now checks the right WebKitGTK.** The launcher decided whether to apply the compositing workaround by asking the *system's* `pkg-config` — but the version that actually runs is the *bundled* one, which the AppImage prioritizes. On any machine where the two diverge (e.g. building from source with newer dev packages installed), the detection read the wrong number and could skip a workaround the running library needed. The build now stamps the bundled version into the AppImage at package time, and the launcher reads that stamp — correct by construction. The launcher's shell tests also now run in CI, which they previously never did. (#961 follow-up)
- **Parakeet TDT transcription now works without an NVIDIA GPU.** The
`nemo-parakeet` ASR engine (parakeet-tdt-0.6b-v3, 25 languages, word
timestamps) was hard-gated behind CUDA — but a live measurement on an Apple
Silicon M2 shows it transcribing at ~10× realtime *on CPU*, roughly 20×
faster than the default whisper-large-v3 on the same machine at equal
accuracy. The false GPU gate is removed, so Mac and CPU-only users can now
pick the dramatically faster engine in Settings → Engines.
### Docs
- **8 GB GPUs: voice-clone/dub transcription no longer kills the backend.**
On cards where the TTS model already held most of the VRAM (e.g. RTX
4060 Ti 8 GB), loading whisper `large-v3` in float16 for a reference-clip
or dub transcription died as a *native* CUDA out-of-memory abort — the
whole backend process vanished with no error logged, and the app showed
"Can't reach the local OmniVoice backend." A new VRAM preflight re-checks
free GPU memory right before the ASR load and steps down float16 →
int8 → CPU instead of attempting a load that can't fit (opt-out:
`OMNIVOICE_ASR_VRAM_PREFLIGHT=0`). (#723)
- **Windows: installing to a different drive is documented** — the wizard's directory picker works for any local drive; mapped network drives are a Windows Installer limitation (not installable-to by design); and the big data (models/voices) moves independently via Settings → Storage or Portable mode. (#938)
## [0.3.13] — 2026-07-09
The community-fixes release. Two contributors didn't just report bugs — they diagnosed them to the exact line and submitted the fixes that shipped: **voice cloning on mlx-audio's CSM model works for the first time**, and **macOS live recording finally gets its microphone permission prompt** (both @MahdiHedhli). A third reporter's A/B analysis fixed **cross-language dubs speaking the wrong language**. On top of that: a backend shutdown race that produced confusing crash-on-quit reports is fixed, the Linux AppImage stops shipping a stale WebKitGTK that white-screened current distros, and a new OpenAI-compatible transcription backend opens a path to Qwen3-ASR today. Thank you to everyone who filed, diagnosed, and contributed — this release is mostly yours.
### Added
- **A path to Qwen3-ASR today: generic OpenAI-compatible transcription.** The direct integration is still blocked on `transformers>=5.13` stabilizing upstream, but a community member proposed splitting the work — add a backend that talks to any OpenAI-compatible transcription server right now. Point OmniVoice at a self-hosted Qwen3-ASR/FunASR/SenseVoice server, or OpenAI's own API, configured in Settings → Models. No install; audio does leave your machine to whichever server you configure, unlike every other ASR engine. (#877)
### Fixed
- **The Linux AppImage no longer white-screens on current distros with a healthy system WebKitGTK.** The release build ran on an older CI base image, and the resulting AppImage bundles whatever `libwebkit2gtk` that image's apt repos resolve — which the AppImage's own `LD_LIBRARY_PATH` then prioritizes over your system's newer, healthy copy at runtime. A from-source build (which links straight against your system library) worked fine on the exact same machine where the shipped AppImage didn't — that split was the tell. Bumped the release build to a current Ubuntu LTS. Raises the AppImage's minimum host to glibc 2.39 (Ubuntu 24.04+); no reports from anyone on an older distro. (#961)
- **Backend shutdown no longer races a still-loading model, surfacing a confusing crash on restart.** Quitting the app while a model was still loading in the background let shutdown report itself "done" while a background thread was still mid-import; tearing the process down under that thread produced a misleading error (a generic transformers import-failure message, unrelated to the real cause) that looked like a real crash rather than a timing issue. All background tasks are now properly cancelled and awaited before shutdown proceeds. (#1000, likely the same class behind #941 and #979)
- **Cross-language dub no longer speaks the source-language reference line verbatim.** Auto-generated speaker clones pair an audio slice with the ASR segment's own text field, assuming the two agree — but ASR segment text and its timestamps routinely drift (a trailing word audible in the clip but missing from the text, or vice versa). A mismatched (reference audio, reference text) pair breaks zero-shot TTS prompt priming badly enough that the clone can emit the reference text itself instead of the target-language line it was asked to speak. Each reference clip is now re-transcribed after it's written, so the pair matches by construction — reported with an exceptionally clear root-cause diagnosis and a working A/B repro. (#1004)
- **Voice Gallery errors now say what actually went wrong.** "Use voice", "Preview", search, upload, save, delete, and trim in the Gallery all showed the same hardcoded guess ("the engine may be loading") on ANY failure — a 500, a validation error, a genuinely unrelated bug — discarding the real, already-clean backend error message in the process. Every one of those now shows the actual error.
- **Voice cloning on mlx-audio's CSM model no longer crashes with an opaque "list index out of range".** `MLXAudioBackend.generate()` read `voice`/`ref_audio`/`language`/`speed` from its kwargs but silently dropped `ref_text` — CSM only builds its cloning context when both `ref_audio` and `ref_text` are present, so cloning on this engine could never have worked as shipped. Reported with the exact root cause and a working fix. (#1012, #1013)
- **A dub segment's free-text style tags no longer 400 the segment preview.** A validator-safe instruct builder already keeps Studio and Clone generation from round-tripping a 400 on unsupported free-text (a preset's raw attrs, an old profile's stray descriptive phrase) — but the Dub tab's segment preview, and saving a profile from a clone or from history, built their instruct strings directly and skipped it. Same guard now applies everywhere an instruct string is sent. (#1010)
- **The dub editor's play button no longer sticks permanently disabled after an audio-decode hiccup.** When the initial WaveSurfer decode fails, the timeline falls back to loading pre-computed peaks — the waveform draws fine, but the button's enabled state only relied on the `ready` event firing again for that recovery load, which it didn't reliably do. Each fallback path now confirms readiness explicitly once it settles.
- **macOS: live recording finally works — the microphone permission prompt now actually appears.** The app never showed up in System Settings → Privacy & Security → Microphone because macOS never saw a legitimate request: Tauri enables Hardened Runtime by default, which blocks microphone hardware access unless the matching entitlement is in the signed bundle — and it wasn't. Diagnosed to the exact mechanism and fixed by a community contributor (@MahdiHedhli), who also corrected our initial mis-read of this as an upstream WebKit limitation. (#1013, #1016)
- **Quitting during a slow model load waits longer before giving up.** A post-merge code review of the shutdown-race fix flagged that its 3-second wait could still be outrun by a cold model import on a slow disk, reproducing the original confusing-crash-on-quit in rare cases. The wait is now 20 seconds — imperceptible on a normal quit (tasks finish or cancel in milliseconds), only felt in the exact case it protects. (#1020)
### Changed
- **Removed the donate heart from the nav rail.** Support OmniVoice is still one click away from Settings and the Contact page.
### CI
- **The "flaky trio" is root-caused and neutralized.** Three tests failed intermittently on CI — never locally — across unrelated PRs, costing a re-run each time. Cause: a leaked half-precision torch default from some earlier test in CI's ordering (the giveaway: a failing assertion's observed value was exactly float16(0.1)). An autouse test-suite guard now resets the leak between tests and names the offending test in CI output when it fires. (#1021)
## [0.3.12] — 2026-07-08
A community-issue sweep — nineteen open reports triaged in one pass, most fixed same-day. The through-line: **your active engine selection is now honored everywhere** (dubbing, batch, and — new in this release — MLX-Audio's own curated models are finally selectable instead of always silently defaulting to Kokoro), **first-run stops dead-ending users on restricted networks or behind corporate TLS proxies**, and a run of sharp community diagnoses (a one-line ROCm index fix, a Windows-only focus-stealing bug, a genuine crash regression) got fixed largely because reporters did the hard diagnostic work themselves. Thank you.
### Added
- **MLX-Audio's other 6 curated models are finally selectable.** The engine multiplexes Kokoro, CSM, Qwen3-TTS, Dia, Chatterbox, MeloTTS, and OuteTTS, but there was no way anywhere in the UI or API to pick which one loads — downloading a model via Settings → Models did nothing, since the backend always defaulted to Kokoro regardless. Settings → Engines now shows a model picker on the mlx-audio row; switching takes effect immediately, no restart needed. (#981)
### Fixed
- **First-run no longer dead-ends behind restricted networks (e.g. China).** The system check probed hardcoded huggingface.co, and any failure locked the Continue button — users behind the Great Firewall were stuck on the very first screen, even when they had already configured a working mirror. The check now probes the Hugging Face endpoint actually in effect, an unreachable endpoint is a warning instead of a blocker (models already on disk keep working offline), and when huggingface.co is blocked but the hf-mirror.com community mirror answers, the wizard says so and offers a one-click mirror switch right on the check screen — no restart needed. (#984)
- **Installs behind a corporate or antivirus TLS-inspecting proxy no longer fail with a raw SSL error.** `SSLV3_ALERT_HANDSHAKE_FAILURE` happens when a proxy re-signs HTTPS traffic with a root CA your OS trusts but Python's bundled certificate list doesn't — a different failure mode from the network-blocking case above. OmniVoice now trusts your OS's certificate store directly, which should resolve the handshake outright rather than just explain it better. (#976)
- **The loaded-models panel now says when a resident model is not your active engine.** Switching TTS engines keeps the previous model in VRAM (so switching back is instant) — but the panel showed it with no context, so "OmniVoice TTS — 1.9 GB" after selecting VoxCPM2 looked like the selection was ignored. A field report confirmed the confusion. Resident-but-inactive models are now tagged "not active — safe to unload", and the API self-describes each entry's engine. (#985)
- **Voices no longer ship with a hidden echo.** Every non-raw synthesis was getting a small room reverb baked in by the mastering pre-stage — on top of whatever effect preset you chose, so even "Podcast" (which promises *no reverb*) had some, and Cinematic/Warm got it twice. A field report ("a lot of echo/reverb on some of the voices") led straight to it. The mastering stage is now highpass + compressor only; reverb happens only when a preset explicitly declares it. Also documented: cloned voices reproduce the reference clip's room acoustics — dry, close-mic references clone cleanest. (#986)
- **Your engine selection now actually applies to Dubbing and Batch TTS.** Both hardcoded OmniVoice regardless of what was picked in Settings → Engines — pick VoxCPM2, dub anyway with OmniVoice, no error. Both now resolve the active engine up front; an engine that can't clone from reference audio (KittenTTS, Sherpa-ONNX, Supertonic 3 — fixed preset voices only) fails the job immediately with a clear message naming which engines do support it, instead of silently substituting OmniVoice or mis-cloning every speaker into one voice. Batch only requires cloning when a specific voice is pinned — an unpinned batch job runs on any engine. (#987)
- **AMD ROCm torch install no longer silently falls back to CPU.** A community member (Kaihui-AMD) diagnosed it precisely: the ROCm wheel index we pointed at tops out at PyTorch 2.5.1, but the app pins `torch==2.8.0` — the reinstall was unsatisfiable and silently kept the default CUDA build, which runs on CPU on an AMD GPU. Bumped the default index to one that actually carries the pinned version. (#972)
- **mlx-audio no longer crashes on unsupported languages.** Selecting a language like Dutch, Spanish, or Portuguese with mlx-audio's Kokoro model crashed with a raw, unreadable internal-details dump instead of a real error — the code was guessing an ISO language code by truncating the language name, which only worked by coincidence for a few languages. Unsupported languages now fail cleanly with a message naming what's actually supported, and no engine can leak a raw crash-internals dump into an error message again. (#977)
- **The voice-design panel no longer crashes on certain saved voice profiles.** A genuine regression: an earlier translation fix accidentally introduced a crash when a saved design profile's data was incomplete (possible from an older app version or a partial save). Fixed at every layer — the render no longer crashes, both places that restore saved data complete it first, and profiles can no longer be *saved* with incomplete data in the first place. (#983)
- **Windows: the dictation pill no longer steals focus.** Pressing the dictation shortcut activated the pill window, which meant the auto-paste landed back in OmniVoice instead of whatever app you were dictating into, and the pill would get stuck on screen. Precisely diagnosed by a community reporter; fixed to match how this already worked on macOS. (#982)
- **The nemo-parakeet ASR engine's install hint no longer breaks your backend.** Following the in-app "pip install nemo_toolkit[asr]" instruction silently downgraded core packages your backend needs to start — the install reported success, and the breakage only showed up on the next restart. The hint now says plainly that this isn't safe to install into the shared environment. (#974)
- **A stuck generate now tells you the actual fix.** When a job times out from GPU/VRAM contention, the error explained why but never mentioned Flush/Unload — the one action that actually resolves it, and one the sibling ASR-timeout error already recommended. (#939)
### Changed
- **README and Linux docs no longer advertise a `.deb` package that isn't published.** `.deb` bundling is disabled in the release pipeline pending a tauri-cli fix; the docs now say so honestly instead of pointing at a file that was never in any release. (#961 investigation, #990)
- **Linux install docs mention `yt-dlp` as an optional prerequisite** — previously only surfaced via an in-app warning after the fact. (#973)
- **A benign Tauri startup warning no longer looks like an app problem.** On some Windows configurations, Tauri's own internal IPC fallback logs a warning that's fully harmless (it silently and successfully falls back to another transport) — it was spuriously flipping the Settings → Logs footer to show "1 warning" on every launch. Filtered out of the diagnostic capture. (#975)
## [0.3.11] — 2026-07-05
The multi-language release — dubbing into several languages at once is finally a mature, honest workflow: **"Generate N dubs" now translates each language before rendering it** (with visible per-language progress), **switching languages never destroys your work** (every track keeps its own text, subtitles, and audio cache), completed tracks always show their tabs, and dialogue stops starting seconds early because of footsteps — a community reporter's theory, confirmed exactly. Around it, a reliability sweep driven by same-day field reports: your LLM provider finally survives a restart, SOCKS-proxy users can synthesize again (installed models now load without touching the network at all), timeline boxes are visible on every WebView2 runtime, running from source works again — and when the backend crashes, **it now tells you the exit code and attaches the evidence to your bug report automatically**.
### Added
- **Backend crashes are now self-documenting.** When the local backend process dies (a native GPU abort, an out-of-memory kill), the app used to show only "Can't reach the backend" — undiagnosable without logs nobody sends. The launcher now records every unexpected backend death (exit code, how long it ran, the last 40 log lines), tells you honestly that it *crashed* and is restarting, offers a "View crash details" panel, attaches the evidence to in-app bug reports automatically (paths scrubbed), and stops silent crash-loops after 3 deaths in 10 minutes with the details on screen. Intentional shutdowns, restarts, and app quits are never misreported as crashes. (#969)
- **"Generate N dubs" now actually translates each language first.** Multi-language generation used to synthesize every track from whatever text was in the editor — so at most one of your N dubs was really in its language. The batch now runs translate → generate per language with a visible "Translating → Bengali (2/3)…" phase, skips (and reports) any language whose translation fails instead of rendering a wrong-language track, and your multi-language picks and export-track selection are saved with the project instead of vanishing on tab switch. (#957)
- **Switching dub languages no longer destroys your work — every track keeps its own text and audio.** Translations are now stored per language (switching the target swaps the editor text non-destructively; manual edits stay with their language), subtitles export each track's own text instead of N identical files, burned-in subs match their track, and the per-segment audio cache is keyed by language — "Regen changed" can no longer splice another language's audio into the track you're rebuilding, and staleness is tracked per track. Fully backward-compatible: existing projects and caches keep working; a pre-upgrade project's first "Regen changed" simply regenerates cleanly once. (#958)
### Fixed
- **Timeline segment boxes are visible on every WebView2 runtime.** The v0.3.10 flicker fix switched box colors to a newer CSS feature (`color-mix`) applied as an inline style — on WebView2 runtimes older than ~March 2023 (pinned enterprise/offline installs) that renders as *fully transparent*, turning "flickering boxes" into "no boxes at all" while looking perfect on up-to-date machines. Colors are now pre-blended in plain JavaScript to universally-supported `rgb()` values — pixel-identical on modern runtimes, theme-aware, and guarded by a test that fails if an engine-dependent color ever reaches the timeline again. (#968)
- **Dubbed dialogue stops starting seconds early because of footsteps.** Dialogue starts are snapped to the first detected sound — and a single 20 ms burst (footsteps, a door, a sigh) counted as "speech", with no limit on how far a start could jump, and the snap even ran on the raw mix when vocal separation had failed. Onsets now require sustained speech-like energy, long jumps are only allowed across genuinely silent spans (so the original fix for whisper's stretched starts keeps working), and snapping turns off entirely when vocals weren't separated. Credit to the community reporter whose "footsteps theory" was exactly right. (#967)
- **Completed dub tracks always show their video tabs.** Opening a project with a finished dubbed track hid the Original/track switcher until you re-selected the language — visibility was keyed to the language dropdown instead of the project's tracks, and restored projects couldn't set the language because the history database froze it at empty forever. Tabs now render from the tracks themselves, history keeps its language (existing projects heal without migration), restoring a project can no longer 404 the video preview, and track pills gained duration/timing tooltips plus an accurate now-playing indicator. (#956)
- **Running from source works again, and the install docs stop lying.** `bun run desktop-prod` broke when the frontend became a workspace (`bunx` could fetch the wrong "tauri" package from npm — fixed everywhere including CI); the Linux white-screen guidance now leads with the variable that actually fixes modern Ubuntu (`WEBKIT_DISABLE_DMABUF_RENDERER=1`, with the exact `EGL_BAD_PARAMETER` error quoted); Windows docs now state plainly that GPU acceleration is NVIDIA-only there; the Linux docs document the ROCm support that already shipped (the "planned follow-up" note was stale); and prerequisites are split installer-vs-source with git and curl included. (#964)
- **Your LLM provider now survives a restart.** Setting up Ollama (or any provider), testing it, and saving looked like it worked — then a restart forgot the selection: only the separate "Save & use for translation" button ever persisted it, and a leftover setting from the retired (≤0.3.7) translation panel could silently steal the choice back to "Custom" on every launch. An explicit save now activates the provider when none was chosen yet, the leftover legacy settings are migrated into the Custom provider once and removed, and the panel says "Saved — not yet used for translation" instead of staying silent when your edit isn't the active provider. (#965)
- **SOCKS-proxy users can synthesize again — and an installed model can never again be blocked by a broken network stack.** With a system-wide SOCKS proxy set, clicking Synthesize 500'd with a raw "socksio not installed" error: loading an already-downloaded model still constructed a network session first, which failed at creation. The app now ships SOCKS support (including in the packaged installers), resolves installed models **cache-first** (no network session when the files are already on disk — the local-first guarantee at the loader level), warms up at startup even when the online check fails, degrades LLM extras instead of crashing on proxy errors, and classifies the error with an actionable hint if it ever does surface. (#966)
## [0.3.10] — 2026-07-05
The listening release — nine fixes in twenty-four hours, almost all driven by your v0.3.9 field reports (several with same-day turnaround). The dubbing pipeline stops lying: **Cinematic and Autofit can no longer invent dialogue**, the **speaker count you set is honored on every path** (and auto-cloning stops fabricating voices from guessed labels), and the timeline stops flashing invisible on Windows. Audiobook chapters with pauses render again. And one fix everyone should want: **updating can no longer leave you secretly running the old version** — a leftover backend from a previous install holding the port is now detected and replaced at launch. Plus: the Dub tab's LLM engine finally runs on the provider you configured in Settings, history timestamps stop reading "20617d ago", and the Engines page can't crash under concurrent load.
### Fixed
- **Audiobook/Stories chapters with a `[pause]` no longer fail to render.** Pause spans were built as 1-D silence while every TTS engine returns 2-D audio, so the chapter concatenation crashed with `Tensors must have same number of dimensions` — any chapter containing a pause failed on every attempt (reported with a precise trace in #897). Silence now matches the rendered audio's shape at the source, and the chunk concatenator defensively normalizes mixed ranks (including honest mono→stereo broadcast) so no engine can re-trigger the class. (#953)
- **Cinematic and Autofit dubbing can no longer invent dialogue.** The refine and slot-fit passes accepted any non-empty LLM reply for Latin-script languages — hallucinated lines, refusals, or the critique itself could ship as the dub. Every reply is now checked against the original line (length window, target script, critique echo — tunable via `OMNIVOICE_REFINE_RATIO_MIN/MAX`), rejected output falls back to the literal translation with an `adapt-diverged`/`fit-diverged` marker, lines too short to honestly fill their slot skip LLM expansion entirely, and both passes pin `temperature=0.2` like the Fast path. (#950)
- **Dub timeline boxes can no longer flash invisible during playback.** On some Windows GPU/WebView2 driver combos the segment boxes under the video vanished and reappeared while playing (first reported in #373; the earlier fix was incomplete) — the timeline lane still animated a CSS transform every playback tick, keeping the translucent boxes on a composited layer that the driver mis-painted. Boxes are now positioned in pure layout with fully opaque theme-aware fills (pixel-identical colors), removing the glitch class on every platform. (#951)
- **The dub "Speakers" count now actually does something — on every path.** The hint only reached pyannote; the common fallbacks silently ignored it (the no-diarization heuristic was hardcoded to alternate two speakers, and the FunASR shortcut never consulted it). The heuristic now cycles the requested count, an explicit count routes through pyannote when available, every path that can only approximate (or must ignore) the setting says so in a visible warning, and the legacy endpoint + a new CLI `--speakers` flag accept it too. Auto voice-cloning also stops fabricating voices from guessed labels: reference slices under 1.5s are rejected, slices bordering another speaker's turn are avoided, and cloning is skipped with an honest warning when speaker labels came from the gap heuristic instead of real diarization. (#952)
- **Settings → Engines can no longer 500 under concurrent loads.** The lazy TTS/ASR engine registries held a *live* dictionary iterator open across each engine's `is_available()` probe while `list_backends()` ran in a FastAPI threadpool — so a second concurrent `/engines` request materializing a lazy engine entry (`self[key] = cls`) mutated the dict mid-iteration and crashed the request with `RuntimeError: dictionary changed size during iteration`. Both registries now snapshot their keys before iterating (atomic under the GIL), immune to a concurrent insert; regression-tested for TTS and ASR. (#940)
- **The Dub tab's LLM translation engine now runs on your configured LLM provider.** Picking "LLM (OpenAI-compatible)" silently required three hand-set environment variables even when a provider was already configured and tested in Settings → LLM Providers; it now resolves through a new "Dub translation" LLM skill (route it to any provider — remote or local — in Settings → LLM Skills, independently of Cinematic refinement), keeps the `TRANSLATE_*` env vars as a power-user override, bounds every call with the LLM timeout instead of the SDK's 600-second default, tells the Engine dropdown whether the engine is actually ready (and via which provider), and — when nothing is configured — returns a clear pointer to Settings → LLM Providers instead of a raw 401 per segment. (#944)
- **Timestamps no longer show "20617d ago" in OmniDrive/Projects.** The backend stores record times in Unix seconds while some views assumed milliseconds, so generation-history cards rendered as ~1970 ("20617d ago") and sorted last; every relative-time label (OmniDrive, sidebar history, dub projects, batch queue, transcriptions) now goes through one unit-tolerant formatter, and records missing a timestamp show "—" instead of an epoch age.
- **Updating can no longer leave you secretly running the old version.** If a backend from a previous version was still holding the port (an orphan that survived an update), the new app "attached" to it because it answered health checks — so every fix in the update appeared to change nothing (the reported "bound port blocked the newer version"). The launcher now compares the running backend's version against the app before attaching: same version attaches as before, a stale one is killed and the bundled backend is started in its place — on macOS, Windows, and Linux. (#947)
## [0.3.9] — 2026-07-04
The dictation release — and a deep reliability pass driven by live-testing the entire app. **Dictation is rebuilt end-to-end**: instant feedback with a live waveform, words that commit about half a second after you stop speaking, clean punctuation, and text insertion that never lies about success. **LLM providers get one-click connection testing** with real diagnostics and model discovery, in all 21 languages. The app now **always opens maximized**, bottom buttons **can't hide under the footer** at small window sizes, and a wave of "out of memory / can't reach the backend / stuck at preparing" reports were traced to their real causes and fixed — including the silent VRAM crash on 8 GB cards, dead-IPC startup hangs after a Windows BSOD, and misleading error labels. Intel-Mac support status is now stated honestly, Confucius4-TTS is validated end-to-end, and Parakeet — roughly 20× faster than the default transcriber on CPU — is unlocked for every machine.
### Added
- **Sponsor OmniVoice.** A new `SPONSORS.md` (tiers, logo guidelines, how to sponsor), a README Sponsors section, and an in-app Sponsors area (Support page + a footer link) let people back the project — with a one-click "Become a sponsor" that opens a structured GitHub issue form, no account or token needed. Sponsorship is a thank-you, not a paywall: OmniVoice stays free and AGPL-3.0. (#923, #924)
- **OpenAPI reference in Settings.** A new Settings → OpenAPI page embeds an interactive Scalar reference for OmniVoice's local backend API, with a one-click footer button. Fully local — Scalar is bundled, not loaded from a CDN, and phones home to nothing. (#928)
- **Engine Self-test.** The Engines matrix gains a "Self-test" button for in-process TTS engines that runs a tiny real synthesis and reports duration + sample rate — proving an engine actually makes audio, not just imports — plus a copy-paste `export OMNIVOICE_*_DIR=…` setup line for opt-in engines right in the "Why unavailable?" panel. (#930)
- **One canonical HuggingFace-token store + incomplete-download visibility.** The Model Store token field now saves to and is cleared from the same encrypted store as Settings → Credentials (no more two-stores split), and a truncated model cache shows an "incomplete · N MB" state with one-click Repair and Delete instead of masquerading as "not installed". (#927)
- **Launchpad, reimagined as a deck of cards.** The seven feature cards now fan out with animated waveform faces in each card's accent color; hover or keyboard-focus any card and it comes forward while the rest tuck underneath, and the layout stays usable down to the minimum window size. (#904)
- **See exactly what OmniVoice keeps on disk — and get warned before space runs out.** Settings → Storage shows real usage for the model cache (with your largest models), app data, engine environments and temp files, plus a free-space gauge and low-disk / near-full-volume warnings with one-click paths to open folders or reclaim space. (#906)
- **A "What's new" changelog reader in Settings → Updates.** The available update's real release notes now render in-app, alongside an offline changelog viewer and a one-time "what's new" note after each update. (#909)
- **Route each AI feature to its own LLM — or switch it off.** A new Settings → LLM Skills panel lists every LLM-powered capability (Cinematic/Autofit translation, slot fitting, glossary auto-extract, direction parsing, dictation cleanup) with a per-skill toggle and provider picker, so sensitive work can stay on a local model while heavier jobs use a remote one. Disabled skills fall back to the exact non-LLM behavior. (#912)
- **A small thank-you moment, done right.** After a successful export, dub, audiobook, or batch run, OmniVoice may — rarely — show a friendly, dismissible note by the footer heart about supporting development: never more than once a session, at most every 7 days, never for brand-new users, with a permanent "don't ask again". The logs bar also gained an icon and the footer icons now share one size. (#898)
- **Dictation, rebuilt.** The dictation pill now shows a live waveform the moment the mic opens, streams words as you speak with real download/loading progress on first use, and finishes what you say in about half a second of silence instead of two-and-a-half. Transcripts come out properly capitalized and punctuated. Text insertion is now honest and safe: your clipboard is preserved and restored, failures show what to do (including a one-click jump to macOS Accessibility settings when permission is missing) instead of a false "Pasted", and Esc cancels cleanly at any point. The dictation model also pre-warms in the background after launch, so the first press of the hotkey no longer sits on a cold model load.
- **LLM Providers: one-click connection testing with real diagnostics.** The Test button in Settings → LLM Providers now measures round-trip latency and turns failures into plain-language guidance — bad key (401/403), wrong model or URL (404), rate-limited (429), or unreachable server — instead of a raw exception dump. A new "Fetch models" button lists every model your key can access so you pick from real names instead of guessing. The whole panel is now translated into all 21 languages, provider error messages never echo your API key, and the settings API gained full test coverage.
### Changed
- **A "Get in touch" page that actually guides you.** The Contact page is now clearly-labelled cards (report a bug, request a feature, get community help, support the project, report a security issue) with a sentence each on when to use them, instead of a flat link list. (#925)
- **Release titles are version-first.** GitHub's release-list sidebar truncates the title, so "OmniVoice Studio v0.3.8" hid the version; releases are now named "vX.Y.Z — OmniVoice Studio" so the version is always visible. (#922)
- **Launchpad feature cards now fill the window.** The seven cards (Voice Clone, Voice Design, Video Dubbing, Stories, Audiobook, Voice Gallery, Transcripts) span the full content width on a maximized display instead of a fixed ~780px fan, and reflow responsively (7→3→1 columns) down to the 900×600 minimum — driven by the shell's own width, keeping the animated card faces, hover/keyboard-focus raise, and reduced-motion fallback. (#915)
- **LLM Providers settings, de-confused.** The old inline "LLM endpoint" box in Translation is gone — LLM Providers is now the one place that owns it. Fields pinned by an environment variable are shown disabled with an explainer instead of silently reverting, the make-active button explains when a provider is env-pinned, and the Cloudflare Account ID is remembered and editable. (#907)
- **Intel Macs: honestly unsupported for the local backend.** PyTorch no longer ships Intel-Mac builds, so the backend cannot run there; instead of a cryptic dependency error, Intel users now get a clear explanation up front (with the remote-backend option), and the README/docs say so plainly. (#889, #891)
- **The app now always opens maximized (not fullscreen).** Window size and position are no longer carried over from the previous session — one manual resize used to make every later launch reopen at that smaller size, overriding the intended maximized default. Same behavior on macOS (zoomed window, not a fullscreen Space), Windows, and Linux.
### Fixed
- **Sherpa-ONNX "model not set" now reads as a setup problem, not out-of-memory.** Selecting the sherpa-onnx engine without `OMNIVOICE_SHERPA_MODEL` configured used to fail with a misleading "ran out of memory — press Flush" 500; it now names the exact variable, points at Settings → Engines, and the engine is marked unavailable-with-a-reason in the picker (with a copy-paste setup line) instead of selectable-but-broken. Generalized so any env-gated engine surfaces actionable setup guidance. (#919)
- **Cinematic & Autofit now actually run on every translation engine.** Picking Cinematic or Autofit on the default Argos engine (or NLLB) used to silently fall back to Fast with a success toast; it now runs the full LLM refine + fit pass, the Autofit fit pass is bounded by the same wall-clock budget as Cinematic, and provider errors are scrubbed of keys/user-ids. (#910)
- **Dictation no longer freezes on a slow or dead LLM.** Transcript refinement is now hard-bounded (default 4s): a placeholder key or unreachable endpoint falls back to clean unrefined text instead of stalling the paste ~51 seconds. The dictation model is genuinely pre-warmed and reused across sessions, REST transcription is polished like live dictation, and Settings flags a configured-but-failing LLM. (#911)
- **Model installs fail loudly, not silently.** Failed downloads keep their mirror-aware reason on the row with Retry/Dismiss instead of vanishing after a moment; installs check free disk space up front before overrunning it; in-progress installs get a Cancel button; and the HF-mirror setting only asks for a restart when it actually changed. (#908)
- **Engines settings, sharper and honest.** The Supertonic license "Accept" button works again (it was inert since it shipped), the engine matrix refreshes the instant you pick an engine, picking a GPU engine that lands on CPU now warns you with the reason, CPU-only engines stop being mislabelled "CPU fallback", and an in-process "Test engine" pass reads as a dependency check instead of a fake "0 ms" latency. (#905)
- **Updates can no longer cost you data.** Before any database migration runs on first launch of a new version, the database is snapshotted next to itself (newest three kept), and a failed migration stops with the backup path named instead of silently running on a half-upgraded database; the environment self-heal now verifies it's actually broken before rebuilding. (#909)
- **CUDA transcription now works on packaged NVIDIA installs — the cuDNN 8 compat libraries install automatically at launch.** The install step only existed in the dev-loop `scripts/setup.py`, which isn't bundled into the packaged app, so real installs never got the libs and WhisperX / faster-whisper failed with `Could not locate cudnn_ops_infer64_8.dll`. The Rust bootstrap now side-loads them on CUDA machines; CPU/AMD/ROCm boxes skip the download and cache the result so their launches stay instant. (#827, #869)
- **`scripts/setup.py` no longer fails with `No module named pip` when installing the cuDNN 8 libs in the dev loop.** `uv venv` doesn't seed pip into the venv, so `python -m pip install` always broke; the script now uses `uv pip install --python` instead. (#869)
- **Generation timeouts now give device-honest advice.** A CPU-only machine is no longer told the GPU is "VRAM-starved" or to "set the engine to CPU" — CPU hosts get compute-bound guidance (shorter text, the CPU-tuned GGUF/Supertonic-3 engines, the OMNIVOICE_GENERATE_TIMEOUT_S knob) while GPU hosts keep the VRAM-contention explanation. (#896)
- **Model-download failures now name the mirror that failed.** When a Hugging Face mirror is configured and unreachable, every affected surface (generate, dub, Model Store installs) names the mirror and points at the exact setting instead of leaking a raw network error; auto-repair failures now say *why* the repair failed. (#874, #890)
- **No more infinite "preparing" after an unclean shutdown.** If Windows corrupts the WebView cache (e.g. after a BSOD), the splash detects the dead IPC channel, proceeds via a direct backend health check, and — if truly stuck — offers a one-click "Repair and restart". (#879, #892)
- **"Out of memory" is no longer the default excuse.** A failed model download mid-generation was mislabeled as OOM with useless "flush VRAM" advice; network failures are now classified honestly, only real OOM signatures get the OOM treatment, and first-use engine downloads retry once with a fresh connection. (#880, #893)
- **Hung transcriptions recover the same way everywhere.** Chunked dub transcription now shares the same guarded-timeout + GPU-pool reset as the rest of the app, and repeated timeouts recommend the crash-isolated ASR engine — now properly selectable in Settings. (#730, #895)
- **A raw `[Errno 22]` transcribe error now tells you what to fix.** When the OS rejects the temporary WAV write during dub transcription (a missing, read-only, or full temp directory, or antivirus interference), the stream used to dead-end as *"Transcription produced no segments. [Errno 22] Invalid argument"* with no next step; it now classifies the EINVAL and appends an actionable temp-dir/disk/AV hint — the same treatment the ffmpeg and compute-type failure classes already get. (#763)
- **Buttons can no longer hide under the logs footer on small windows.** The bottom status/logs bar was a fixed overlay that pages had to compensate for with padding — any view that missed it (voice-card grids in Gallery and Community, bottom action rows) clipped under the bar at small window sizes, a class previously patched one page at a time (#476, #504). The footer is now a real row of the app shell, so content physically ends at its top edge at every window size, collapsed or expanded — guarded by a new layout test plus a 900×600 Playwright check at the app's minimum window size.
- **Confucius4-TTS is now validated end-to-end — and actually loads.** The opt-in engine's first live run (Apple Silicon, CPU) caught three scaffold-era faults: the sidecar could never import `confuciustts` (upstream ships no packaging, so the documented `pip install -e` fails — the sidecar and bootstrap probe now put the clone on `sys.path`, like upstream's own example), the assumed 24 kHz sample rate was wrong (confirmed **22 050 Hz**, now regression-tested), and the docs demanded an Amphion/MaskGCT install that doesn't exist (all weights auto-download from HuggingFace). CPU is ~17× realtime, so CUDA stays the recommended path; `gpu_compat` now advertises `("cuda", "cpu")`. (#590)
- **Parakeet TDT transcription now works without an NVIDIA GPU.** The `nemo-parakeet` ASR engine (parakeet-tdt-0.6b-v3, 25 languages, word timestamps) was hard-gated behind CUDA — but a live measurement on an Apple Silicon M2 shows it transcribing at ~10× realtime *on CPU*, roughly 20× faster than the default whisper-large-v3 on the same machine at equal accuracy. The false GPU gate is removed, so Mac and CPU-only users can now pick the dramatically faster engine in Settings → Engines.
- **8 GB GPUs: voice-clone/dub transcription no longer kills the backend.** On cards where the TTS model already held most of the VRAM (e.g. RTX 4060 Ti 8 GB), loading whisper `large-v3` in float16 for a reference-clip or dub transcription died as a *native* CUDA out-of-memory abort — the whole backend process vanished with no error logged, and the app showed "Can't reach the local OmniVoice backend." A new VRAM preflight re-checks free GPU memory right before the ASR load and steps down float16 → int8 → CPU instead of attempting a load that can't fit (opt-out: `OMNIVOICE_ASR_VRAM_PREFLIGHT=0`). (#723)
### CI
- **A migration can no longer silence the app's logs.** Alembic's startup config was disabling every existing logger process-wide (a latent bug the new pre-migration backup logging exposed); fixed, and the migration-safety tests are now immune to full-suite ordering. (#909, #917)
- **Deterministically green tests + real install proof.** Tests can no longer read the developer's real `.env` or app data (the order-dependent flake class, #878, #894), and a new cross-platform install-test workflow builds all four installers and proves a real first run — model download plus verified synthesis — on macOS, Windows, and Linux runners.
## [0.3.8] — 2026-07-01
+13 -174
View File
@@ -3,7 +3,7 @@
**OmniVoice Studio**
OmniVoice Studio is an open-source, fully-local ElevenLabs alternative — a desktop app for voice cloning, voice design, video dubbing, and real-time dictation across 646 languages. It runs entirely on the user's machine (CUDA/MPS/ROCm/CPU auto-detect), with no API keys, no accounts, and no cloud dependencies. It's an active beta with a growing user base who hit it with real workloads (50-video batches, multi-engine setups, edge-OS platforms) and report friction in GitHub Issues and Discord. The latest stable release is **v0.3.5**; `main` rolls ahead at **v0.3.6** (latest release + 1 patch — see the Versioning rule below).
OmniVoice Studio is an open-source, fully-local ElevenLabs alternative — a desktop app for voice cloning, voice design, video dubbing, and real-time dictation across 646 languages. It runs entirely on the user's machine (CUDA/MPS/ROCm/CPU auto-detect), with no API keys, no accounts, and no cloud dependencies. It's an active beta with a growing user base who hit it with real workloads (50-video batches, multi-engine setups, edge-OS platforms) and report friction in GitHub Issues and Discord. The current version lives in `frontend/package.json` (the single source of truth — see Versioning); the latest stable tag is on the [Releases page](https://github.com/debpalash/OmniVoice-Studio/releases/latest). With `AUTO_VERSION_BUMP` off (the current owner setting), `main` holds at the released version between releases.
**Core Value:** **A first-run that actually works.** A user who downloads the installer (or clones the repo) should reach a working voice-cloning or dubbing output without hitting a wall — and when something does go wrong, the error or docs should tell them exactly what to do.
@@ -16,175 +16,21 @@ Everything else (new engines, fancy features) is downstream of "the thing instal
- **Default features must work on every platform (strict rule, 2026-05-20):** A feature that ships in default mode — out-of-the-box, no user customization, no opt-in toggle — must behave identically on macOS, Windows, and Linux. Platform-specific *implementation code* is allowed for OS APIs / shells / packaging, but the user-visible *default behavior* cannot diverge. Platform-only features (e.g., a macOS-only global shortcut, a Windows-only path picker) must go behind explicit user opt-in: Settings toggle, env var, or CLI flag. When a default doesn't work on a platform, that's a P0 bug — either fix it on the missing platform or move it behind opt-in. No third option.
- **Backward-compatible project data**: Existing `omnivoice_data/` (user voices, projects, settings) must keep working without manual migration. Any DB schema change goes through alembic with a tested upgrade path.
- **Local-first guarantee preserved**: Auto bug reporting (new addition) must be **opt-in**, must submit only to GitHub Issues (no third-party telemetry endpoint), and the app must remain fully functional with reporting disabled. No required cloud calls, accounts, or API keys.
- **Beta release cadence (no RC, no ceremony — strict rule, 2026-05-20):** the v0.3.x line has **no release candidates, no 48h soak, no formal release ceremony**. Every fix goes continuous-to-main; the owner tags a patch (`v0.3.Z`) from main whenever the current state is worth cutting. No `-rc` tags. No phased release. No `v0.4` deferrals while the v0.3.x line is open — every open issue and every open community PR gets absorbed into the v0.3.x line or explicitly declined. Users follow `main` for previews; users wanting stable stay on the latest tagged release (currently **v0.3.5**). ROADMAP.md's Phase 6 "Release/Verify/Retro" entries are obsolete unless the user revives them.
- **Beta release cadence (no RC, no ceremony — strict rule, 2026-05-20):** the v0.3.x line has **no release candidates, no 48h soak, no formal release ceremony**. Every fix goes continuous-to-main; the owner tags a patch (`v0.3.Z`) from main whenever the current state is worth cutting. No `-rc` tags. No phased release. No `v0.4` deferrals while the v0.3.x line is open — every open issue and every open community PR gets absorbed into the v0.3.x line or explicitly declined. Users follow `main` for previews; users wanting stable stay on the latest tagged release. ROADMAP.md's Phase 6 "Release/Verify/Retro" entries are obsolete unless the user revives them.
<!-- GSD:project-end -->
<!-- GSD:stack-start source:research/STACK.md -->
## Technology Stack
## Recommended Stack — Per Capability
### Capability 1 — HuggingFace Token Persistence (issue #35)
| Technology | Version | Purpose | Why Recommended |
|------------|---------|---------|-----------------|
| `huggingface_hub` (already pinned transitively by `transformers>=5.3.0`) | `≥1.12.x` (latest 2026) | Auth + cache + token storage | Canonical, used by every HF library already in the stack. `HfFolder` is **superseded** in v1.x by the higher-level `login()` / `auth_list()` / `auth_switch()` API. |
| `keyring` (Python) | `≥25.x` | Optional OS-keychain backing | Only adopt if a future hardening pass wants Keychain/Credential-Manager/SecretService. **Not recommended for this milestone** — adds a native dep (`dbus`, `pywin32`) per platform with no real security win over `0600` file storage in `HF_HOME`. |
| Shell | One-liner to persist `HF_TOKEN` |
|-------|---------------------------------|
| macOS zsh (default since 10.15) | `echo 'export HF_TOKEN=hf_xxx' >> ~/.zshrc && source ~/.zshrc` |
| Linux bash | `echo 'export HF_TOKEN=hf_xxx' >> ~/.bashrc && source ~/.bashrc` |
| Windows PowerShell (user scope) | `[Environment]::SetEnvironmentVariable("HF_TOKEN","hf_xxx","User")` (new shells only) |
| Windows cmd | `setx HF_TOKEN "hf_xxx"` (user scope, new shells only) |
- [HF environment variables docs](https://huggingface.co/docs/huggingface_hub/en/package_reference/environment_variables) — HIGH confidence (official, current)
- [HF authentication API docs](https://huggingface.co/docs/huggingface_hub/en/package_reference/authentication) — HIGH confidence
- [Microsoft `setx` docs](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/setx) — HIGH confidence
### Capability 2 — In-App Structured Bug Reporting (opt-in, GitHub Issues)
| Technology | Version | Purpose | Why Recommended |
|------------|---------|---------|-----------------|
| GitHub REST API `POST /repos/{owner}/{repo}/issues` | `2026-03-10` API version | Server-side issue creation | Official, stable. Requires auth. |
| **Prefilled-URL pattern** (`github.com/{owner}/{repo}/issues/new?title=…&body=…&labels=…`) | n/a | Zero-auth fallback | **This is the recommended primary path for v0.3.x.** No token needed, no GitHub App registration needed, user's browser opens with a prefilled form, they review and click Submit. They own the issue, the OSS project gets the report, and OmniVoice never holds a credential. |
| `gh-app-jwt` + GitHub App (Rust crate `octocrab` or Python `pygithub`) | only if we later want fully-automated submission | Programmatic posting under an app identity | **Defer to a later milestone.** Requires registering a public GitHub App, hosting a token-exchange endpoint, and managing rate-limit quotas — disproportionate for stabilization scope. |
| `platform`, `psutil`, `torch.cuda` (already in deps) | already pinned | Capture OS, CPU/GPU/VRAM info | No new deps. |
| `httpx` (already in `dev-dependencies`, promote to runtime if needed) | `≥0.28.1` | HTTP for the API call path (if/when we add auth) | Modern async-first, already used in test suite. |
- ✓ No token storage in OmniVoice → no security surface
- ✓ Opt-in by definition (user has to click Submit on github.com)
- ✓ User owns the issue → can be replied to, edited, closed by them
- ✓ Zero infra cost — no proxy, no app, no rate-limit management
- ✓ Works identically on macOS / Windows / Linux via Tauri's `shell.open`
- ✓ Survives our project being forked (just change the URL)
- OS name + version (`platform.platform()`)
- Python version (`sys.version`)
- OmniVoice version (`pyproject.toml`)
- Backend git SHA (if installed from source) or installer build ID
- CPU model, RAM (`psutil.cpu_count()`, `psutil.virtual_memory()`)
- GPU vendor/model/VRAM (`torch.cuda.get_device_name()`, `torch.cuda.mem_get_info()`, MPS detect)
- Active TTS engine + list of installed engines
- Frontend: bun version, OS shell
- Last error message + stack trace if launched from an error toast
- Audio file contents (privacy — reference samples may contain user's voice)
- File paths containing `/Users/<name>/` (strip home dir → `~/`)
- HF token, OpenAI keys, any env var matching `*TOKEN*|*KEY*|*SECRET*`
- [GitHub URL query parameters for issues](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/creating-an-issue#creating-an-issue-from-a-url-query) — HIGH confidence
- [sindresorhus/new-github-issue-url](https://github.com/sindresorhus/new-github-issue-url) — HIGH (widely used reference impl)
- [GitHub REST API: Create an issue](https://docs.github.com/en/rest/issues/issues#create-an-issue) — HIGH confidence (for the future auto-submit path)
- [sentry-tauri](https://github.com/timfish/sentry-tauri) — reviewed, **rejected for milestone** due to local-first constraint
### Capability 3 — `uv venv` Mirror Fallback for Restricted Networks (issues #57, #60)
| Technology | Version | Purpose | Why Recommended |
|------------|---------|---------|-----------------|
| `uv` (already used) | `≥0.5.x` | Python+venv bootstrap | Existing dep. |
| `UV_PYTHON_INSTALL_MIRROR` env var | uv `0.4.x`+ | Override python-build-standalone download URL | **Official, current.** Replaces `https://github.com/astral-sh/python-build-standalone/releases/download/...` in download URL construction. No built-in fallback if mirror fails. |
| `UV_PYTHON_PREFERENCE=only-system` (or CLI flag `--python-preference only-system`) | uv `0.4.x`+ | Skip the python-build-standalone download entirely; use the user's system Python | **The reliable escape hatch** when no mirror works. Requires a compatible Python `>=3.11` to already be on PATH. |
| `UV_HTTP_TIMEOUT`, `UV_HTTP_CONNECT_TIMEOUT`, `UV_HTTP_RETRIES` | uv `0.4.x`+ | Tune retry behavior for flaky links | Defaults are 30s / 10s / 3 — bump to 120s / 30s / 5 for restricted networks. |
# Pseudocode for the bootstrap
# Final fallback: don't download Python at all
- `UV_DEFAULT_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple` (Tsinghua — fastest in China)
- `UV_DEFAULT_INDEX=https://mirrors.aliyun.com/pypi/simple` (Aliyun fallback)
- Russia: no major government-blessed PyPI mirror; users typically tunnel via VPN. Document this honestly rather than ship a broken default.
- [uv environment variables reference](https://docs.astral.sh/uv/reference/environment/) — HIGH (official)
- [uv issue #5224 — python-build-standalone mirror support](https://github.com/astral-sh/uv/issues/5224) — HIGH (the feature was added)
- [uv issue #14187 — venv on Chinese network](https://github.com/astral-sh/uv/issues/14187) — HIGH (confirms real user pain, no built-in fallback)
- [uv python-versions concepts](https://github.com/astral-sh/uv/blob/main/docs/concepts/python-versions.md) — HIGH (documents `python-preference` semantics)
- [dautovri/mirrors-china](https://github.com/dautovri/mirrors-china) — MEDIUM (community-maintained mirror list; verify each URL still works before shipping)
### Capability 4 — Supertonic-3 TTS Engine
| Technology | Version | Purpose | Why Recommended |
|------------|---------|---------|-----------------|
| `supertonic` (PyPI) | `1.3.1` (latest, May 18 2026 — Phase 3 Wave 1 to verify constructor signature before bump) | Official Supertonic-3 inference SDK | Authoritative wrapper from Supertone Inc. Wraps the ONNX session orchestration so we don't have to. |
| `onnxruntime` | `≥1.17.x` (any recent) | ONNX inference runtime | Already a transitive dep of WhisperX (via CTranslate2 path is separate, but `onnxruntime` itself ships for kittentts and audioseal). Verify with `uv tree` after adding — should resolve cleanly. |
| `huggingface_hub` (already pinned) | `≥1.12.x` | Model weight download (~400 MB on first use) | Reuses existing HF token + cache infrastructure. The user's existing `HF_TOKEN` (Capability 1) works for the Supertonic model download too. |
| `numpy`, `soundfile` (already pinned) | already pinned | Audio I/O + array math | No new deps. |
- `text_encoder.onnx`
- `latent_denoiser.onnx`
- `voice_decoder.onnx`
- 44.1 kHz sample rate, 24-dim latent, 128-dim style
- ~99M parameters total
- Tokenizer: `AutoTokenizer.from_pretrained(model_path)` — loads from `tokenizer.json` shipped with model
- [Supertone/supertonic-3 model card](https://huggingface.co/Supertone/supertonic-3) — HIGH (official)
- [supertone-inc/supertonic GitHub](https://github.com/supertone-inc/supertonic) — HIGH (official)
- [supertonic PyPI page](https://pypi.org/project/supertonic/) — HIGH (`1.3.1` confirmed 2026-05-18; same publisher, MIT, same 4 deps)
- [onnx-community/Supertonic-TTS-ONNX](https://huggingface.co/onnx-community/Supertonic-TTS-ONNX) — HIGH (ONNX file structure details)
### Capability 5 — Cross-Platform Documentation Tooling
| Technology | Version | Purpose | Why Recommended |
|------------|---------|---------|-----------------|
| Plain Markdown in `docs/` + GitHub-rendered (current state) | n/a | Install tutorial, troubleshooting | Zero new infra. Renders inline on GitHub for issue-replies. No build step to break. |
| Existing `scripts/smoke-test.sh` + Playwright `tests/` (already in `package.json`) | already pinned | Verify install paths actually work | **This is the real solution to "docs drift."** If smoke-test exercises the install path described in docs, docs that drift will break CI. |
| **Future** (defer): Astro Starlight | `≥0.30` | Standalone docs site at `docs.omnivoice.studio` | Adopt only when docs exceed ~20 markdown files and need search/versioning. Tauri, the framework OmniVoice already depends on, uses Starlight — well-traveled choice. Material for MkDocs entered maintenance mode in November 2025 per Docsio's 2026 review — **avoid** for new docs. |
| Project | What they do |
|---------|--------------|
| **OBS Studio** | Docs at `obsproject.com/docs` (Sphinx, separate repo). Install paths in README, wiki for community-contributed. CI doesn't gate on docs drift. |
| **Audacity** | Manual at `manual.audacityteam.org` (MediaWiki). README is minimal. Install path = "use the installer." No automated sync. |
| **Tauri** | Docs at `v2.tauri.app` (Astro Starlight, separate repo `tauri-apps/tauri-docs`). README is minimal. Heavy reliance on community contributions and PR review. |
| **VS Code** | Docs at `code.visualstudio.com/docs` (separate repo, Markdown). README is minimal. Manual sync; docs team is staffed. |
- [Tauri docs (Astro Starlight)](https://github.com/tauri-apps/tauri-docs) — HIGH (reference for "if we ever move off README")
- [OBS Studio docs](https://docs.obsproject.com/) — HIGH (Sphinx, separate site reference)
- [Audacity Manual](https://manual.audacityteam.org/) — HIGH (MediaWiki reference)
- [Docsio: Material for MkDocs 2026 review (maintenance mode)](https://docsio.co/blog/mkdocs-material) — MEDIUM (third-party review, but signal aligns with project's own GitHub activity)
- [Docsio: Starlight 2026 review](https://docsio.co/blog/starlight-docs) — MEDIUM
## Installation
# No new Python dependencies needed for Capabilities 1, 2, 3, 5.
# Only Capability 4 adds a runtime dep:
# Verify no regressions:
# Should show single versions of each; no duplicates.
## Alternatives Considered
| Recommended | Alternative | When to Use Alternative |
|-------------|-------------|-------------------------|
| HF token via in-app Settings → `huggingface_hub.login()` | OS keyring via `keyring` package | Only if a security hardening milestone later demands OS-native credential storage. Not worth the cross-platform native-dep cost for v0.3.x. |
| Prefilled-URL GitHub Issues | GitHub App + device flow + authenticated POST | When milestone budget can afford registering a public GitHub App and hosting a token-exchange function. Defer. |
| Prefilled-URL GitHub Issues | Sentry / `sentry-tauri` | Never — violates the "no third-party telemetry endpoint" constraint in PROJECT.md. |
| `UV_PYTHON_INSTALL_MIRROR` chain + `only-system` fallback | Bundle Python in the Tauri installer | Adds ~30 MB to every installer for ~5% of users. Revisit if the bootstrap is still a top complaint in v0.4. |
| In-repo Markdown docs | Astro Starlight standalone site | When docs grow past ~20 pages and need full-text search. Tauri provides a precedent if/when we get there. |
| In-repo Markdown docs | MkDocs / Material for MkDocs | **Avoid** for new sites — Material for MkDocs is in maintenance mode as of Nov 2025. |
## What NOT to Use
| Avoid | Why | Use Instead |
|-------|-----|-------------|
| `HfFolder.save_token()` directly | Older API; v1.x `login()` does the same plus git-credential integration and is the documented path | `huggingface_hub.login(token=val, add_to_git_credential=False)` |
| Setting `HF_TOKEN` via shell rc files as the *only* persistence mechanism | Different per OS, fragile, opaque to the user, breaks in installer-launched processes that don't source shell rc | Write to `$HF_HOME/token` via `login()`. Document env var as override only. |
| `setx` for HF token persistence | Doesn't propagate to current shell; common source of "I set it but it's empty" bug reports | `[Environment]::SetEnvironmentVariable(...,"User")` in PowerShell, or the in-app Settings field |
| PAT-based GitHub Issues posting from OmniVoice | Would require shipping or asking for a token; breaks local-first promise | Prefilled-URL pattern (user submits from their browser) |
| `sentry-tauri` for OmniVoice | Third-party telemetry endpoint — violates PROJECT.md constraint | Local-only `backend.log` rotation + opt-in prefilled-URL reporter |
| `hf_transfer` for downloads | Deprecated in favor of `hf-xet` per HF docs | Default `huggingface_hub` (uses `hf-xet` automatically when available) |
| `--python-preference managed` (default) without mirror config in restricted-network installers | Hits GitHub CDN, times out, user sees raw `uv` error | Configure `UV_PYTHON_INSTALL_MIRROR` + retry chain + `only-system` final fallback |
| Material for MkDocs as a *new* docs choice | Entered maintenance mode November 2025 | If docs site is eventually needed, use Astro Starlight (Tauri precedent) |
## Stack Patterns by Variant
- Set `UV_PYTHON_INSTALL_MIRROR` to one of the gh-proxy URLs at install time
- Set `UV_DEFAULT_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple` (China) or document VPN requirement (Russia)
- Fall back to `UV_PYTHON_PREFERENCE=only-system` if all mirrors fail
- Increase `UV_HTTP_TIMEOUT=120`, `UV_HTTP_RETRIES=5`
- Default path: in-app Settings field → `login()` → file at `$HF_HOME/token`
- Power-user path: `export HF_TOKEN=...` in shell rc (documented but not promoted)
- Both paths are read at HF library import time; env var wins on conflict
- Default path: in-app "Report a bug" → prefilled GitHub Issues URL → user reviews + submits in browser
- All optional capture toggles default ON except "include reproduction file" (privacy)
- No path posts to any URL except `github.com/{owner}/{repo}/issues/new` (rendered locally as a URL, opened via `shell.open`)
- `uv add supertonic` → new TTSBackend subclass in `backend/services/tts_backend.py`
- Auto-detected and added to the engine picker in Settings
- ~400 MB model download on first synthesize call, cached in `$HF_HUB_CACHE`
- Existing IndexTTS/CosyVoice/etc. installs are untouched (no shared model weights)
## Version Compatibility
| Package A | Compatible With | Notes |
|-----------|-----------------|-------|
| `supertonic@1.3.1` | `onnxruntime>=1.17`, `numpy>=1.24`, `huggingface_hub>=0.20` | All deps already satisfied transitively by current `pyproject.toml`. |
| `huggingface_hub>=1.12` | `transformers>=5.3.0` (current pin) | `HfFolder` retained as deprecated alias; `login()`/`get_token()` are the canonical APIs. |
| `uv>=0.5` | `UV_PYTHON_INSTALL_MIRROR`, `UV_PYTHON_PREFERENCE` | Both env vars stable since uv 0.4.x. |
| Tauri v2 + `@tauri-apps/api/shell` | `shell.open()` for the prefilled-URL pattern | Already in the desktop app; no new permission needed beyond what the existing "open external link" plugin grants. |
## Sources
- [Hugging Face Hub environment variables](https://huggingface.co/docs/huggingface_hub/en/package_reference/environment_variables) — HIGH (verified against v1.12.1 docs, current 2026)
- [Hugging Face Hub authentication API](https://huggingface.co/docs/huggingface_hub/en/package_reference/authentication) — HIGH (verified `login()` is the canonical 1.x API)
- [Microsoft `setx` reference](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/setx) — HIGH (confirms "current shell" gotcha)
- [PowerShell `about_Environment_Variables`](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_environment_variables) — HIGH
- [uv environment variables reference](https://docs.astral.sh/uv/reference/environment/) — HIGH (verified all mirror + retry env vars)
- [uv issue #5224 — python-build-standalone mirror](https://github.com/astral-sh/uv/issues/5224) — HIGH (feature shipped)
- [uv issue #14187 — venv on Chinese network](https://github.com/astral-sh/uv/issues/14187) — HIGH (confirms user pain, justifies fallback chain)
- [uv `python-preference` semantics](https://github.com/astral-sh/uv/blob/main/docs/concepts/python-versions.md) — HIGH
- [Supertone/supertonic-3 model card](https://huggingface.co/Supertone/supertonic-3) — HIGH (official, 99M params, 31 languages, OpenRAIL-M)
- [supertone-inc/supertonic GitHub](https://github.com/supertone-inc/supertonic) — HIGH (official inference API)
- [supertonic 1.3.1 on PyPI](https://pypi.org/project/supertonic/) — HIGH (released 2026-05-18, MIT code license; bumped from 1.2.3 after Phase 3 research)
- [onnx-community/Supertonic-TTS-ONNX](https://huggingface.co/onnx-community/Supertonic-TTS-ONNX) — HIGH (ONNX file structure)
- [GitHub Docs: Authenticating to the REST API](https://docs.github.com/en/rest/authentication/authenticating-to-the-rest-api) — HIGH
- [GitHub Docs: Generating a user access token for a GitHub App](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app) — HIGH (device flow reference)
- [sindresorhus/new-github-issue-url](https://github.com/sindresorhus/new-github-issue-url) — HIGH (canonical prefilled-URL reference impl)
- [sentry-tauri](https://github.com/timfish/sentry-tauri) — MEDIUM (reviewed, rejected on PROJECT.md constraint, not on quality)
- [dautovri/mirrors-china](https://github.com/dautovri/mirrors-china) — MEDIUM (community-maintained, verify URLs are still live before pinning in production)
- [Tauri 2 docs (Astro Starlight reference)](https://v2.tauri.app/) — HIGH (precedent for docs framework if we ever migrate)
- [Docsio: Material for MkDocs entered maintenance mode Nov 2025](https://docsio.co/blog/mkdocs-material) — MEDIUM (third-party review, but signal aligns with the project's own GitHub commit activity)
The May-2026 stack research that used to live here served five capabilities that have all since shipped (HF-token Settings panel, prefilled-URL bug reporting, uv mirror fallback for restricted networks, the Supertonic-3 engine, in-repo Markdown docs). Follow the patterns in the code itself; the durable *don'ts* that research established:
- **No third-party telemetry endpoints, ever** (`sentry-tauri` was evaluated and rejected) — bug reporting stays opt-in via prefilled GitHub-issue URLs.
- **No PAT/token-based GitHub posting from the app** — the user submits from their own browser.
- **Don't recommend `setx` for env vars on Windows** (silent truncation, no current-shell propagation) — use the in-app Settings panel or PowerShell `[Environment]::SetEnvironmentVariable`.
- **Don't adopt Material for MkDocs** for any future docs site (maintenance mode since Nov 2025) — Astro Starlight is the precedent if docs ever outgrow the repo.
- **`hf_transfer` is deprecated** — default `huggingface_hub` (hf-xet) handles downloads.
For anything new: prefer what's already pinned in `pyproject.toml` / `frontend/package.json`, and check `uv tree` for conflicts before adding a dependency.
<!-- GSD:stack-end -->
<!-- GSD:conventions-start source:CONVENTIONS.md -->
@@ -222,16 +68,9 @@ No project skills found. Add skills to any of: `.claude/skills/`, `.agents/skill
<!-- GSD:skills-end -->
<!-- GSD:workflow-start source:GSD defaults -->
## GSD Workflow Enforcement
## Workflow
Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.
Use these entry points:
- `/gsd-quick` for small fixes, doc updates, and ad-hoc tasks
- `/gsd-debug` for investigation and bug fixing
- `/gsd-execute-phase` for planned phase work
Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it.
Direct repo edits are authorized (owner decision, 2026-07-08). The GSD command gate that used to live here referenced `/gsd-quick` / `/gsd-debug` / `/gsd-execute-phase` skills that are not installed in this environment; the owner chose to keep working directly rather than restore them. The working conventions that matter are in **Conventions** above — versioning, docs-sync, changelog, localization, fix quality, keep-main-green — plus: gate every merge on the "Tests (backend + frontend)" check passing and the PR being MERGEABLE, and check the open-PR queue before implementing any community-reported fix (contributors may have already submitted one).
<!-- GSD:workflow-end -->
+1
View File
@@ -18,6 +18,7 @@ Thanks for your interest in improving OmniVoice Studio! This guide covers everyt
### Prerequisites
- [Git](https://git-scm.com/)
- `curl` (used by the Bun / uv / rustup install one-liners on macOS and Linux)
- [Bun](https://bun.sh/) (frontend package manager)
- [uv](https://docs.astral.sh/uv/) (Python environment manager)
- [ffmpeg](https://ffmpeg.org/) (audio/video processing)
+257 -152
View File
@@ -10,6 +10,8 @@
<a href="#why-ovs">Why OVS</a> ·
<a href="#tts-engines">TTS Engines</a> ·
<a href="#asr-engines">ASR Engines</a> ·
<a href="#openai-api">API</a> ·
<a href="#sponsors">Sponsors</a> ·
<a href="#sponsor--donate">Donate</a> ·
<a href="#contributing">Contributing</a> ·
<a href="https://discord.gg/bzQavDfVV9">Discord</a> ·
@@ -30,11 +32,21 @@
<br/>
<div align="center">
<img src=".github/assets/social-preview.png" alt="OmniVoice Studio — The open-source ElevenLabs alternative" width="100%"/>
<img src="docs/screenshot-launchpad.png" alt="OmniVoice Studio — Launchpad" width="100%"/>
</div>
> **Your voice is the most personal data you have. So why rent it back from a cloud?** Every mainstream voice tool ships your audio to someone else's server and bills you monthly for the privilege. OmniVoice Studio flips that: clone, design, dub, and dictate on your own hardware — 646 languages, no meter running, nothing leaving your machine.
<div align="center">
| 🔑 No API keys | 🙅 No accounts | ☁️ No cloud | 💳 No subscription |
|:---:|:---:|:---:|:---:|
| nothing to paste in | nothing to sign up for | your audio stays home | it's your computer |
</div>
> [!WARNING]
> **OmniVoice Studio is in active beta.** Things may break between releases. For the latest features and fixes, clone the repo and run from source rather than using pre-built installers. Bug reports and PRs are very welcome [open an issue](https://github.com/debpalash/OmniVoice-Studio/issues) or [join Discord](https://discord.gg/bzQavDfVV9).
> **OmniVoice Studio is in active beta.** Things may break between releases — for the latest features and fixes, clone the repo and run from source rather than the pre-built installers. Bug reports and PRs are very welcome: [open an issue](https://github.com/debpalash/OmniVoice-Studio/issues) or [join Discord](https://discord.gg/bzQavDfVV9).
<div align="center">
<br/>
@@ -46,7 +58,68 @@
<br/>
## Features
<a id="screenshots"></a>
## 📸 See it in action
<table>
<tr>
<td align="center" width="50%">
<img src="docs/screenshot-studio.png" alt="Studio" width="100%"/>
<br/><b>Studio</b><br/>
<sub>Generate &amp; clone in one workspace — a 3-second clip mirrors any voice, 646 languages, zero-shot.</sub>
</td>
<td align="center" width="50%">
<img src="docs/screenshot-design.png" alt="Voice Design" width="100%"/>
<br/><b>Voice Design</b><br/>
<sub>Build new voices from scratch — gender, age, accent, pitch, emotion, dialect.</sub>
</td>
</tr>
<tr>
<td align="center">
<img src="docs/screenshot-gallery.png" alt="Voice Gallery" width="100%"/>
<br/><b>Voice Gallery</b><br/>
<sub>Browse ready-made archetype voices with language filters — or build your own library.</sub>
</td>
<td align="center">
<img src="docs/screenshot-dub.png" alt="Video Dubbing" width="100%"/>
<br/><b>Video Dubbing</b><br/>
<sub>A real dub, end to end: 37 segments transcribed, translated to Bengali, re-voiced, and timed — ready to export as MP4.</sub>
</td>
</tr>
<tr>
<td align="center">
<img src="docs/screenshot-engines.png" alt="Settings — Engines" width="100%"/>
<br/><b>Settings → Engines</b><br/>
<sub>The engine compatibility matrix — 14 TTS engines with per-engine GPU preflight, no silent CPU fallback.</sub>
</td>
<td align="center">
<img src="docs/screenshot-settings.png" alt="Settings — Models" width="100%"/>
<br/><b>Settings → Models</b><br/>
<sub>One-click model store — auto-detects your platform (CUDA / MPS / CPU) and recommends the right models.</sub>
</td>
</tr>
<tr>
<td align="center">
<img src="docs/screenshot-openapi.png" alt="Settings — API Reference" width="100%"/>
<br/><b>API Reference</b><br/>
<sub>The full local REST API, embedded — every endpoint documented with copy-paste client snippets.</sub>
</td>
<td align="center">
<img src="docs/screenshot-updates.png" alt="Settings — What's New" width="100%"/>
<br/><b>What's New</b><br/>
<sub>In-app changelog reader — see exactly what shipped in each release without leaving the app.</sub>
</td>
</tr>
</table>
---
<a id="features"></a>
## ✨ Features
The eight headliners — and twelve more waiting under the fold.
<table>
<tr>
@@ -74,94 +147,68 @@
</td>
<td align="center" valign="top">
<h3>⌨️ Dictation Widget</h3>
<p><code>⌘+⇧+Space</code> from <b>any app</b>.<br/>Transcribes, auto-pastes, disappears.</p>
<p><kbd>⌘</kbd>+<kbd>⇧</kbd>+<kbd>Space</kbd> from <b>any app</b>.<br/>Transcribes, auto-pastes, disappears.</p>
</td>
<td align="center" valign="top">
<h3>🔊 Vocal Isolation</h3>
<p>Demucs-powered. Splits speech<br/>from music, <b>keeps the background</b>.</p>
</td>
<td align="center" valign="top">
<h3>👥 Speaker Diarization</h3>
<p>Pyannote + WhisperX.<br/><b>Auto-identifies</b> who said what.</p>
</td>
</tr>
<tr>
<td align="center" valign="top">
<h3>📦 Batch Queue</h3>
<p>Drop <b>50 videos</b>, walk away.<br/>Progress bars per job.</p>
</td>
<td align="center" valign="top">
<h3>🤖 MCP Server</h3>
<p>Use OmniVoice from <b>Claude</b>,<br/>Cursor, or any MCP client.</p>
</td>
<td align="center" valign="top">
<h3>🛡️ AI Watermark</h3>
<p>AudioSeal (Meta). <b>Invisible</b>,<br/>survives compression.</p>
</td>
<td align="center" valign="top">
<h3>🔬 Diagnostics</h3>
<p>Self-check, error journal,<br/>scrubbed <b>diagnostic bundle</b>.</p>
</td>
</tr>
<tr>
<td align="center" valign="top">
<h3>🔐 100% Local</h3>
<p>No keys, no cloud, no accounts.<br/><b>Your machine only</b>.</p>
</td>
<td align="center" valign="top">
<h3>⚡ GPU Auto-Detect</h3>
<p>CUDA · MPS · ROCm · CPU.<br/>≤8 GB? <b>Auto-offloads</b>.</p>
</td>
<td align="center" valign="top">
<h3>🧩 Extensible</h3>
<p>Subclass <code>TTSbackend</code>,<br/>add any engine in <b>~50 lines</b>.</p>
</td>
<td align="center" valign="top">
<h3>🧭 Engine Routing</h3>
<p>Preflight GPU check per engine.<br/><b>No silent CPU fallback</b>.</p>
</td>
</tr>
<tr>
<td align="center" valign="top">
<h3>🎒 Portable Personas</h3>
<p>Export voices as <code>.ovsvoice</code><br/>bundles — identity + <b>watermark</b>.</p>
</td>
<td align="center" valign="top">
<h3>♾️ Unlimited TTS</h3>
<p>Sentence-chunked generation.<br/><b>No length cap</b>. Streaming via WS.</p>
</td>
<td align="center" valign="top">
<h3>🌐 Remote Backend</h3>
<p>Point UI at a remote server.<br/>Tailscale-friendly. <b>Bearer auth</b>.</p>
</td>
<td align="center" valign="top">
<h3>🧠 Dictation + LLM</h3>
<p>Local LLM cleanup of transcripts.<br/>Optional echo <b>cancellation</b>.</p>
<h3>🤖 MCP Server</h3>
<p>Use OmniVoice from <b>Claude</b>,<br/>Cursor, or any MCP client.</p>
</td>
</tr>
</table>
<details>
<summary><b>…and 12 more</b> — isolation, diarization, batch, watermarking, diagnostics, and friends</summary>
<br/>
- 🔊 **Vocal Isolation** — Demucs-powered: splits speech from music and keeps the background bed.
- 👥 **Speaker Diarization** — Pyannote + WhisperX auto-identify who said what.
- 📦 **Batch Queue** — drop 50 videos, walk away; per-job progress bars.
- 🛡️ **AI Watermark** — AudioSeal (Meta): invisible, survives compression.
- 🔬 **Diagnostics** — self-check suite, error journal, scrubbed diagnostic bundles.
-**GPU Auto-Detect** — CUDA · MPS · ROCm (Linux, opt-in) · CPU; ≤8 GB VRAM auto-offloads.
- 🧭 **Engine routing** — preflight GPU check per engine; no silent CPU fallback.
- 🧩 **Extensible** — subclass `TTSBackend`, add any engine in ~50 lines.
- 🎒 **Portable personas** — export voices as `.ovsvoice` bundles: identity + watermark.
- ♾️ **Unlimited TTS** — sentence-chunked generation, no length cap, streaming via WebSocket.
- 🌐 **Remote backend** — point the UI at a remote server; Tailscale-friendly, bearer auth.
- 🧠 **Dictation + LLM** — local-LLM cleanup of transcripts, optional echo cancellation.
</details>
---
## Quickstart
<a id="quickstart"></a>
## ⚡ Quickstart
<div align="center">
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="Download macOS DMG" /></a>
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Download Windows MSI" /></a>
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download Linux AppImage" /></a>
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/badge/Debian-.deb-A81D33?style=for-the-badge&logo=debian&logoColor=white" alt="Download Debian .deb" /></a>
<br/>
<sub><b>macOS:</b> first launch needs a one-time approval — right-click → <b>Open</b> (or System Settings → Privacy &amp; Security → <b>"Open Anyway"</b> on macOS 15). No Terminal needed. <a href="docs/install/macos.md#gatekeeper-quarantine">Why?</a></sub>
<br/>
<sub><b>Intel Macs are not supported for the local backend:</b> the app UI installs, but the Python backend cannot run because PyTorch no longer ships Intel-Mac (x86_64) wheels (<a href="https://github.com/debpalash/OmniVoice-Studio/issues/889">#889</a>) — see <a href="docs/install/macos.md">docs/install/macos.md</a>.</sub>
</div>
Per-OS install guides — pick yours and follow it end-to-end:
Pick your OS and follow the guide end-to-end:
- **macOS** — [docs/install/macos.md](docs/install/macos.md)
- **Windows** — [docs/install/windows.md](docs/install/windows.md)
- **Linux** — [docs/install/linux.md](docs/install/linux.md)
- **Docker** — [docs/install/docker.md](docs/install/docker.md) · [Docker Hub: `palashdeb/omnivoice-studio`](https://hub.docker.com/r/palashdeb/omnivoice-studio)
- 🍎 **macOS** — [docs/install/macos.md](docs/install/macos.md)
- 🪟 **Windows** — [docs/install/windows.md](docs/install/windows.md)
- 🐧 **Linux** — [docs/install/linux.md](docs/install/linux.md)
- 🐳 **Docker** — [docs/install/docker.md](docs/install/docker.md) · [Docker Hub: `palashdeb/omnivoice-studio`](https://hub.docker.com/r/palashdeb/omnivoice-studio)
Stuck? Run the built-in self-check first — **Settings → About → "Run
<details>
<summary><b>🧰 Stuck? Self-checks, tokens &amp; restricted networks</b></summary>
<br/>
Run the built-in self-check first — **Settings → About → "Run
self-check"** in the app, or `uv run python backend/main.py --diagnose` from
a checkout (`--deep` also test-loads the active engine). Then see
[docs/install/troubleshooting.md](docs/install/troubleshooting.md) for the
@@ -176,57 +223,13 @@ diarization-specific gating, see
speed, the ⚡ fast-download (Xet) status, and restricted-network / mirror
options, see [docs/downloading-models.md](docs/downloading-models.md).
## Screenshots
<table>
<tr>
<td align="center" width="50%">
<img src="docs/screenshot-clone.png" alt="Voice Clone" width="100%"/>
<br/><b>Voice Clone</b><br/>
<sub>Drop a 3-second clip → mirror any voice. 646 languages, zero-shot.</sub>
</td>
<td align="center" width="50%">
<img src="docs/screenshot-design.png" alt="Voice Design" width="100%"/>
<br/><b>Voice Design</b><br/>
<sub>Build new voices from scratch — gender, age, accent, pitch, style.</sub>
</td>
</tr>
<tr>
<td align="center">
<img src="docs/screenshot-dub.png" alt="Video Dubbing" width="100%"/>
<br/><b>Video Dubbing</b><br/>
<sub>Upload or paste a YouTube URL. Transcribe, translate, re-voice, export.</sub>
</td>
<td align="center">
<img src="docs/screenshot-gallery.png" alt="Voice Gallery" width="100%"/>
<br/><b>Voice Gallery</b><br/>
<sub>Search YouTube, browse categories, download clips, build your library.</sub>
</td>
</tr>
<tr>
<td align="center">
<img src="docs/screenshot-settings.png" alt="Settings — Models" width="100%"/>
<br/><b>Settings → Models</b><br/>
<sub>15 models. One-click install. Auto-detects your platform (CUDA / MPS / CPU).</sub>
</td>
<td align="center">
<img src="docs/screenshot-libraryprojects.png" alt="Projects" width="100%"/>
<br/><b>Projects</b><br/>
<sub>Dub projects, voice profiles, generation history, exports — all searchable.</sub>
</td>
</tr>
<tr>
<td align="center" colspan="2">
<img src="docs/screenshot-logs.png" alt="Settings — Logs" width="100%"/>
<br/><b>Settings → Logs</b><br/>
<sub>Live backend, frontend, and Tauri runtime logs. Filter, refresh, clear.</sub>
</td>
</tr>
</table>
</details>
---
## Why OVS?
<a id="why-ovs"></a>
## 💡 Why OmniVoice?
ElevenLabs charges **$5$330/mo** and processes your audio on their servers. OmniVoice Studio runs **on your hardware, with no usage limits.**
@@ -240,15 +243,15 @@ ElevenLabs charges **$5$330/mo** and processes your audio on their servers. O
| **Video Dubbing** | ✅ Cloud-only | ✅ Fully local |
| **Data Privacy** | Audio sent to cloud | **Nothing leaves your machine** |
| **API Keys** | Required | Not needed |
| **GPU Support** | N/A (cloud) | CUDA · Apple Silicon · ROCm · CPU |
| **GPU Support** | N/A (cloud) | CUDA · Apple Silicon · ROCm (Linux) · CPU |
| **Desktop App** | ❌ | ✅ macOS · Windows · Linux |
| **TTS Engines** | 1 | **11** (OmniVoice, CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX, IndexTTS 2, OmniVoice GGUF, Supertonic 3) |
| **TTS Engines** | 1 | **14** (OmniVoice, CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX, IndexTTS 2, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS) |
| **ASR Engines** | 1 | **9** (WhisperX, Faster-Whisper, MLX Whisper, PyTorch Whisper, Parakeet, Moonshine, FunASR, isolated Faster-Whisper, sherpa-onnx live dictation) |
| **MCP Server** | ❌ | ✅ Use from Claude, Cursor, any MCP client |
| **Self-check** | ❌ | ✅ Diagnostics suite, error journal, scrubbed debug bundles |
| **Customizable** | ❌ Closed | ✅ Fork it, extend it, ship it |
OmniVoice Studio gives you professional-grade AI tools without the subscription or the cloud.
Professional-grade voice AI, minus the subscription and the cloud.
<div align="center">
<br/>
@@ -259,23 +262,36 @@ OmniVoice Studio gives you professional-grade AI tools without the subscription
---
## System Requirements
## 🖥️ System Requirements
| | **Minimum** | **Recommended** |
|---|---|---|
| **OS** | Windows 10, macOS 12+, Ubuntu 20.04+ | Any modern 64-bit OS |
| **OS** | Windows 10, macOS 12+ (Apple Silicon), Ubuntu 24.04+ (glibc 2.39+) | Any modern 64-bit OS |
| **RAM** | 8 GB | 16 GB+ |
| **VRAM (GPU)** | 4 GB (auto-offloads TTS to CPU) | 8 GB+ (NVIDIA RTX 3060+) |
| **Disk** | 10 GB free (models + cache) | 20 GB+ SSD |
| **Python** | 3.10+ (managed by `uv`) | 3.113.12 |
| **GPU** | Optional — CPU works | NVIDIA CUDA · Apple Silicon MPS · AMD ROCm |
| **GPU** | Optional — CPU works | NVIDIA CUDA · Apple Silicon MPS · AMD ROCm (Linux only) |
> [!TIP]
> On GPUs with **≤8 GB VRAM**, OmniVoice automatically offloads TTS to CPU during transcription — no config needed. A dedicated GPU is not required; the entire pipeline runs on CPU (just slower).
### TTS Engines
> [!NOTE]
> **AMD GPUs:** ROCm acceleration is **Linux-only and opt-in** — pick **"AMD GPU (ROCm)"** on the first-run setup screen or set `OMNIVOICE_TORCH_VARIANT=rocm` ([docs/install/linux.md](docs/install/linux.md#amd-gpu-rocm)). **On Windows, AMD GPUs (incl. Ryzen AI iGPUs) run CPU-only**: PyTorch has no Windows ROCm wheels, so Windows GPU acceleration is NVIDIA/CUDA-only ([docs/install/windows.md](docs/install/windows.md#gpu-support)).
OmniVoice ships a multi-engine TTS backend. The default engine (OmniVoice) is always available; additional engines are opt-in and auto-detected. Switch engines in **Settings → TTS Engine** or via the `OMNIVOICE_TTS_BACKEND` env var.
> [!IMPORTANT]
> **macOS Intel (x86_64) is unsupported for the local backend:** the app UI installs, but the Python backend cannot run because PyTorch no longer ships Intel-Mac wheels ([#889](https://github.com/debpalash/OmniVoice-Studio/issues/889)). Intel-Mac users can still point the UI at a remote backend on another machine — see [docs/install/macos.md](docs/install/macos.md).
<a id="tts-engines"></a>
### 🗣️ TTS Engines
**14 engines, one picker.** OmniVoice (default, 600+ languages) is always available; CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, and Sherpa-ONNX are opt-in and auto-detected — plus six lazy-installed heavyweights (IndexTTS 2, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS). Switch in **Settings → TTS Engine** or via the `OMNIVOICE_TTS_BACKEND` env var — the selection applies everywhere synthesis happens: single-clip generation, Voice Cloning, Video Dubbing, and Batch TTS.
<details>
<summary><b>📊 The full matrix</b> — 14 engines × platform × clone/instruct × license</summary>
<br/>
| Engine | Languages | Clone | Instruct | Linux | macOS ARM | Windows | License |
|--------|:---------:|:-----:|:--------:|:-----:|:---------:|:-------:|:-------:|
@@ -296,11 +312,22 @@ OmniVoice ships a multi-engine TTS backend. The default engine (OmniVoice) is al
> **CUDA** = GPU-accelerated · **MPS** = Apple Silicon Metal · **CPU** = runs everywhere, slower for large models · KittenTTS and MOSS-TTS-Nano run realtime on CPU · MLX-Audio is Apple Silicon only · ⚡ = lazy-registered (installed on first use)
>
> **Clone** matters beyond single-clip generation: Video Dubbing (and any Batch job with a pinned voice) needs reference-audio cloning to preserve speaker identity, so picking a Clone-less engine (KittenTTS, Sherpa-ONNX, Supertonic 3) as the active engine fails those jobs up front with an actionable message instead of silently falling back to OmniVoice.
>
> **MOSS-TTS-v1.5** (8B, ~16 GB weights) and **dots.tts** (2B, ~9 GB weights) are heavyweight opt-in engines that run in their own isolated venv from a local clone — see [MOSS-TTS-v1.5](docs/engines/moss-tts-v15.md) and [dots.tts](docs/engines/dots-tts.md). Neither claims Apple-Silicon **MPS** (upstream is CUDA/CPU only; on a Mac they run on CPU). dots.tts upstream is Linux/macOS only — no Windows path. **Confucius4-TTS** (14-language cross-lingual zero-shot cloning) is similar — its own Python 3.10 venv from a clone; CUDA recommended, CPU validated end-to-end (slow, ~17× realtime; no MPS — tested slower than CPU); see [Confucius4-TTS](docs/engines/confucius4-tts.md).
### ASR Engines
</details>
OmniVoice ships a multi-engine ASR (speech-to-text) backend that powers dictation, video dubbing, and subtitle generation — all fully local. **WhisperX** is the cross-platform default; the rest are opt-in and auto-detected. Switch in **Settings → ASR Engine** or via the `OMNIVOICE_ASR_BACKEND` env var.
<a id="asr-engines"></a>
### 🎧 ASR Engines
**10 engines** — they power dictation, video dubbing, and subtitles. **WhisperX** is the cross-platform default (~100 languages, word-level timing); the rest are opt-in and auto-detected. Switch in **Settings → Engines** (the ASR Engines table — same picker TTS has), or pin one with the `OMNIVOICE_ASR_BACKEND` env var (the env var wins over the Settings pick). Nine run fully on-device; one (OpenAI-compatible) is an optional remote client for pointing at Qwen3-ASR or another compatible server — see below.
<details>
<summary><b>📊 The full lineup</b> — 10 engines, what each is best at, and compute-type notes</summary>
<br/>
| Engine | `OMNIVOICE_ASR_BACKEND` | Languages | Best for |
|--------|-------------------------|:---------:|----------|
@@ -313,14 +340,17 @@ OmniVoice ships a multi-engine ASR (speech-to-text) backend that powers dictatio
| **Moonshine** | `moonshine` | English | Edge / low-latency, ONNX |
| **FunASR** | `funasr` | 50+ | All-in-one multilingual — built-in VAD + inline speaker diarization (SenseVoice) |
| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | 25 EU + 90+ | Live, faster-than-real-time dictation — small streaming/offline ONNX models (Parakeet TDT v3/v2, streaming Zipformer & Paraformer, Whisper Tiny), CPU, identical on macOS / Windows / Linux. Picked per-model in **Settings → Voice**. |
| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | A path to **Qwen3-ASR** today (self-hosted server, no transformers wait), any OpenAI-compatible transcription endpoint, or OpenAI's own API — no install, configure in **Settings → Models**. Audio leaves your machine to whatever server you point it at; see [docs/engines/openai-compatible-asr.md](docs/engines/openai-compatible-asr.md). |
> Whisper-family engines cover ~100 languages; **FunASR / SenseVoice** adds an all-in-one multilingual path with built-in voice-activity detection and inline speaker diarization. **sherpa-onnx** powers the live dictation model picker — you talk and text appears as you speak. Every engine runs on-device — no API keys, no cloud.
> **GPU without efficient float16?** On older NVIDIA GPUs (Maxwell/Pascal, GTX 16xx) or after a CTranslate2/cuDNN mismatch, the CTranslate2 ASR engines (WhisperX, Faster-Whisper) can't run `float16` and OmniVoice automatically retries on `int8` — no config needed. If transcription still fails, pin the compute type with the `ASR_COMPUTE_TYPE` env var (escape hatch): `ASR_COMPUTE_TYPE=int8` (or `float32` for CPU). Set it to `int8` and restart the backend.
</details>
---
## Architecture
## 🏗️ Architecture
```
┌─────────────────────────────────────────────────────────────┐
@@ -338,11 +368,50 @@ OmniVoice ships a multi-engine ASR (speech-to-text) backend that powers dictatio
CUDA / MPS / ROCm / CPU (auto-detected + routed)
```
<a id="openai-api"></a>
## 🔌 OpenAI-compatible API
Already have a script, agent, or tool that speaks OpenAI's audio API? Point it at `http://localhost:3900/v1` — no key needed, no code changes. The backend ships a drop-in surface for the audio endpoints, wired to whichever TTS/ASR engine you have active (and yes, `voice` accepts your cloned voice-profile IDs).
| Endpoint | What it does |
|---|---|
| `POST /v1/audio/speech` | TTS — text in; `mp3` / `wav` / `flac` / `opus` / `pcm` out. `tts-1` / `tts-1-hd` map to your active engine; OpenAI voice names (`alloy`, …) are accepted. |
| `POST /v1/audio/transcriptions` | STT — audio file in; `json`, `text`, `verbose_json`, `srt`, or `vtt` out. `whisper-1` maps to your active ASR engine. |
| `GET /v1/audio/voices` | OmniVoice extension — lists every voice profile and engine, so clients can discover your clones. |
```sh
curl http://localhost:3900/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{"model": "tts-1", "voice": "alloy", "input": "Generated on my own hardware.", "response_format": "wav"}' \
--output speech.wav
```
```python
from openai import OpenAI
client = OpenAI(base_url="http://localhost:3900/v1", api_key="none") # any string works — nothing checks it
result = client.audio.transcriptions.create(model="whisper-1", file=open("clip.wav", "rb"))
print(result.text)
```
Want the whole surface (100+ endpoints)? The full REST API reference is embedded in the app — **Settings → OpenAPI Reference** (Scalar-powered), or the `{}` button in the footer.
---
## Roadmap
## 🗺️ Roadmap
### ✅ Shipped
### 🔜 Up Next
- 🎬 **Lip-sync v2** — visual speech timing with wav2lip
- 🌐 **Hosted Demo** — try OmniVoice without installing anything
- 🔌 **Plugin Marketplace** — community-contributed TTS engines and effects
- 🎵 **Real-time Voice Changer** — live microphone transformation during calls
<details>
<summary><b>✅ Everything shipped so far</b> — the receipts, by category</summary>
<br/>
| Category | Features |
|----------|----------|
@@ -353,29 +422,26 @@ OmniVoice ships a multi-engine ASR (speech-to-text) backend that powers dictatio
| **Multi-Lang** | Multi-language batch picker, batch dubbing queue with sequential GPU execution |
| **Diarization** | Pyannote ML diarization, auto speaker clone extraction, per-speaker voice assignment |
| **ASR** | 9 engines (WhisperX, Faster-Whisper, isolated Faster-Whisper, MLX Whisper, PyTorch Whisper, Parakeet TDT, Moonshine, FunASR/SenseVoice, sherpa-onnx live dictation), crash-isolated subprocess backend |
| **TTS** | 11 engines (OmniVoice, CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX, + lazy: IndexTTS 2, OmniVoice GGUF, Supertonic 3), engine routing with GPU preflight |
| **TTS** | 14 engines (OmniVoice, CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX, + lazy: IndexTTS 2, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS), engine routing with GPU preflight |
| **Infra** | Docker deployment, CUDA/MPS/ROCm auto-detect, cuDNN 8 compat, VRAM-aware model offloading, engine routing (no silent CPU fallback), diagnostics suite & error journal, restricted-network mirror support |
| **AI Provenance** | AudioSeal invisible watermarking (SynthID-like), video logo overlay, watermark detection API |
| **UX** | Undo/redo, keyboard shortcuts, drag-and-drop, session persistence, glassmorphism design system, UI scale fix for Linux/WebKitGTK |
| **Real-time Events** | WebSocket event bus — instant sidebar refresh on data mutations, exponential backoff reconnect |
| **State Management** | Zustand store migration — `uiSlice`, `pillSlice`, `dubSlice`, `generateSlice`, `prefsSlice`, `glossarySlice` |
| **Desktop** | Cross-platform Tauri installers (macOS DMG/Intel, Windows MSI, Linux deb/AppImage), auto-update infrastructure, single-instance enforcement, close-to-tray, macOS Gatekeeper fix |
| **Desktop** | Cross-platform Tauri installers (macOS DMG — Apple Silicon; Intel unsupported for the local backend, #889 Windows MSI, Linux deb/AppImage), auto-update infrastructure, single-instance enforcement, close-to-tray, macOS Gatekeeper fix |
| **Dictation** | Global system-wide hotkey (`⌘+⇧+Space`), frameless floating widget, streaming ASR via WebSocket, auto-paste, customizable hotkey, local-LLM transcript refinement |
| **Batch Pipeline** | Full batch TTS: extract → transcribe → translate → generate → mix → export, with live progress tracking |
| **MCP Server** | OmniVoice as a local TTS/STT provider for Claude, Cursor, and any MCP client |
| **Remote Backend** | Point the desktop UI at a remote backend URL with bearer auth (Tailscale-documented) |
| **Reliability** | Stall watchdog on bootstrap splash, per-engine GPU compatibility matrix, actionable errors for non-executable engine binaries, setuptools auto-repair |
### 🔜 Up Next
- 🎬 **Lip-sync v2** — visual speech timing with wav2lip
- 🌐 **Hosted Demo** — try OmniVoice without installing anything
- 🔌 **Plugin Marketplace** — community-contributed TTS engines and effects
- 🎵 **Real-time Voice Changer** — live microphone transformation during calls
</details>
---
## Sponsor / Donate
<a id="sponsor--donate"></a>
## 💜 Sponsor / Donate
OmniVoice Studio is built by one developer using Claude Code and AI agents — and the agent bills are real. Over the last three months I've spent thousands of dollars on Claude subscriptions to keep the features shipping, the bugs fixed, and your issues answered. If OmniVoice has created value for you, helping cover those bills means I can keep developing full-time.
@@ -396,31 +462,57 @@ OmniVoice Studio is built by one developer using Claude Code and AI agents — a
</div>
<a id="sponsors"></a>
### 🌟 Sponsors
OmniVoice is **free** and **AGPL-3.0** — no paid tier, no SaaS revenue. Sponsors keep development going, and in return get a logo slot here, in the app, and (for top tiers) on the project website. It's a thank-you, never a paywall. **[See tiers & become a sponsor →](SPONSORS.md)**
<div align="center">
<!-- SPONSORS:START — logo slots are filled here as sponsors come aboard; see SPONSORS.md -->
**Your logo here** — [become a sponsor](SPONSORS.md)
<!-- SPONSORS:END -->
</div>
<sub>💡 GitHub also shows a **Sponsor** button at the top of this repo, wired to the same links via <a href=".github/FUNDING.yml"><code>.github/FUNDING.yml</code></a>.</sub>
---
## Community
## 💬 Community
<div align="center">
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/💬_Discord-Join_Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord" /></a>
<br/>
<sub>We respond to setup questions within hours, not days.</sub>
</div>
<details>
<summary><b>What happens in there</b></summary>
<br/>
| Channel | What happens there |
|---------|--------------------|
| `#showcase` | Members share their dubs, clones, and voice designs |
| `#help` | Setup issues, GPU troubleshooting, model questions |
| `#feature-requests` | Vote on what gets built next |
| `#dev` | Architecture discussions, PR reviews, engine integrations |
| `#announcements` | Release notes, breaking changes, early access |
| `#announcements` | Release news and the big moments — new versions land here first |
| `#releases` + `#changelog` | Every build and exactly what's inside it |
| `#issues` | Bug reports as forum posts — triaged straight into GitHub issues |
| `#ideas` | Feature requests, discussed and voted on |
| `#discuss-ideas` | Design talk before things get built |
| `#general` | Setup help, GPU troubleshooting, and showing off your dubs |
**[→ Join the Discord](https://discord.gg/bzQavDfVV9)** — we respond to setup questions within hours, not days.
</details>
---
## Contributing
<a id="contributing"></a>
We welcome contributions of all kinds — bug fixes, new TTS engine adapters, UI improvements, docs, and translations.
## 🤝 Contributing
Yes please — bug fixes, new TTS engine adapters, UI improvements, docs, translations. All of it.
- 📖 Read the **[Contributing Guide](CONTRIBUTING.md)** for setup, code style, and PR workflow
- 🐛 Browse [good first issues](https://github.com/debpalash/OmniVoice-Studio/labels/good%20first%20issue)
@@ -428,7 +520,7 @@ We welcome contributions of all kinds — bug fixes, new TTS engine adapters, UI
---
## FAQ
## FAQ
<details>
<summary><b>Is this really as good as ElevenLabs?</b></summary>
@@ -439,7 +531,7 @@ For voice cloning and dubbing, yes — OmniVoice uses a state-of-the-art diffusi
<details>
<summary><b>Does it work on Apple Silicon (M1/M2/M3/M4)?</b></summary>
<br/>
Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are available for faster transcription on Apple hardware.
Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are available for faster transcription on Apple hardware. <b>Intel Macs are not supported</b>: the app UI installs, but the local Python backend cannot run because PyTorch no longer ships Intel-Mac wheels (<a href="https://github.com/debpalash/OmniVoice-Studio/issues/889">#889</a>) — an Intel Mac can only be used with a remote backend.
</details>
<details>
@@ -463,12 +555,14 @@ Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are availab
<details>
<summary><b>Can I add my own TTS engine?</b></summary>
<br/>
Yes. OmniVoice uses a <b>built-in backend registry</b>. To add an engine in ~50 lines, subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</code> and add it to the <code>_REGISTRY</code> dictionary. Eleven engines are built in: OmniVoice, CosyVoice 3, GPT-SoVITS, MLX-Audio (14+ sub-engines), VoxCPM2, MOSS-TTS-Nano, KittenTTS, Sherpa-ONNX, plus lazy-registered IndexTTS 2, OmniVoice GGUF, and Supertonic 3. See the <a href="#tts-engines">TTS Engines</a> section for details.
Yes. OmniVoice uses a <b>built-in backend registry</b>. To add an engine in ~50 lines, subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</code> and add it to the <code>_REGISTRY</code> dictionary. Fourteen engines are built in: OmniVoice, CosyVoice 3, GPT-SoVITS, MLX-Audio (14+ sub-engines), VoxCPM2, MOSS-TTS-Nano, KittenTTS, Sherpa-ONNX, plus lazy-registered IndexTTS 2, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, and Confucius4-TTS. See the <a href="#tts-engines">TTS Engines</a> section for details.
</details>
---
## License
<a id="license"></a>
## 📜 License
OmniVoice Studio is free and open-source software under the [**GNU Affero General Public License v3.0 (AGPL-3.0)**](https://www.gnu.org/licenses/agpl-3.0.html).
@@ -480,7 +574,7 @@ The bundled `omnivoice/` TTS model by Han Zhu remains Apache-2.0 upstream. See [
---
## Acknowledgments
## 🙏 Acknowledgments
OmniVoice Studio is built on the shoulders of exceptional open-source work:
@@ -499,6 +593,17 @@ OmniVoice Studio is built on the shoulders of exceptional open-source work:
---
## 🧰 More local open-source from the maker
Like the local-first philosophy? It runs in the family:
| Project | What it is |
|---------|------------|
| [**Opal**](https://github.com/debpalash/Opal) 💠 | **Play everything.** The evolved media player for the next decades of entertainment — video, anime, comics, torrents, Jellyfin/Plex, with local AI built in. |
| [**memxt**](https://github.com/debpalash/memxt) 🧠 | **The fastest benchmarked open-source AI memory system.** 100% local memory for AI agents, with MCP support. |
---
<div align="center">
<br/>
+119
View File
@@ -0,0 +1,119 @@
<div align="center">
<img src="docs/logo.png" alt="OmniVoice Logo" width="96" />
<h1>Sponsor OmniVoice Studio</h1>
<p><b>Keep the open-source ElevenLabs alternative free, local, and shipping.</b></p>
</div>
---
## Why sponsor?
OmniVoice Studio is built by one developer, in the open, using Claude Code and AI agents — and the agent bills are real. Over the last few months I've spent thousands of dollars on Claude subscriptions to keep features shipping, bugs fixed, and your issues answered.
OmniVoice is **free**, **fully local**, and **AGPL-3.0**. There's no paid tier, no accounts, no cloud, and no SaaS revenue — nothing runs on a server we bill you for, because nothing runs on a server at all. That's the whole point, and it's also why there's no recurring revenue to fund development. Sponsorship is what makes continued full-time work possible.
If OmniVoice has created value for you or your company, sponsoring means the next release keeps coming — and you get a thank-you (and, at most tiers, a logo slot) in return.
### Where your money goes
Every dollar goes to the cost of building OmniVoice — chiefly the **AI agent bills that keep it shipping** (Claude subscriptions and API usage), plus the occasional signing certificate, test hardware, and model-hosting costs. It is not a salary top-up; it's what keeps the lights on for continuous development.
---
## Sponsorship tiers
Tiers are about **visibility and gratitude** — what you get is placement, not gated features (see [Not a paywall](#not-a-paywall)). Higher tiers include everything in the tiers below them.
| Tier | Suggested monthly | What you get |
|------|-------------------|--------------|
| **🥉 Backer** | _set by owner_ <!-- OWNER: set amounts --> | Your name or handle listed in the **Backers** section of this file, with a link of your choice. |
| **🟫 Bronze** | _set by owner_ <!-- OWNER: set amounts --> | Everything above, **plus** a small logo in `SPONSORS.md` **and** in the README [Sponsors section](README.md#sponsors). |
| **🥈 Silver** | _set by owner_ <!-- OWNER: set amounts --> | Everything above, **plus** your logo in the **README** and in the app's **in-app Sponsors page footer** (as that page ships). |
| **🥇 Gold** | _set by owner_ <!-- OWNER: set amounts --> | Everything above, **plus** a **prominent logo slot** and link on the project **website / landing page**. |
> **Amounts are set by the maintainer** — look for the `<!-- OWNER: set amounts -->` markers in this file's source. If you don't see a price that fits, say so in your inquiry; custom and annual arrangements are welcome.
Placements marked "as that page ships" (the in-app Sponsors page and the project website) are on the near-term roadmap. Until they exist, Silver/Gold logos live in `SPONSORS.md` and the README, and are added to the app and site the moment those land — no re-application needed.
---
## How to become a sponsor
**1. Open a sponsorship inquiry (recommended).** This opens a short GitHub form (name/org, logo, tier, contact) so we can get you set up:
> **[→ Open a sponsorship inquiry](https://github.com/debpalash/OmniVoice-Studio/issues/new?template=sponsor.yml)**
**2. Or start recurring support directly:**
- **Ko-fi (recurring or one-time):** [ko-fi.com/debpalash](https://ko-fi.com/debpalash)
- **PayPal (one-time):** [paypal.me/palashCoder](https://paypal.me/palashCoder)
If you sponsor via Ko-fi/PayPal and want a logo slot, still open an inquiry (or drop a note there) so we know who to credit and where to link.
**3. Prefer to talk first?** Reach out directly:
- Email: <!-- OWNER: add your sponsor contact email here if you want one public -->
- Or ask in the `#dev` / `#announcements` channels on [Discord](https://discord.gg/bzQavDfVV9).
---
## Logo & asset guidelines
To make your logo look sharp everywhere (README on GitHub, the in-app page, the website), please send:
- **Format:** **SVG preferred** (scales cleanly); otherwise **PNG at 2× resolution**.
- **Background:** **transparent** — no baked-in white/black box.
- **Contrast:** send a variant that stays legible on **both light and dark** backgrounds, or one light-mode and one dark-mode file (GitHub and the app both render in either theme).
- **Dimensions:** legible at **~40px tall**; keep the wordmark within roughly **480px wide**. Landscape/wordmark shapes work best in the README row.
- **File size:** keep SVGs under ~50 KB and PNGs under ~100 KB.
- **Link target:** the destination URL you want the logo to point to (usually your homepage).
**How your logo gets added:**
- **Easiest:** attach the asset and link in your [sponsorship inquiry](https://github.com/debpalash/OmniVoice-Studio/issues/new?template=sponsor.yml) — the maintainer places it.
- **Or open a PR:** add your asset under `docs/sponsors/` and an entry to the tables in this file. Silver/Gold logos are also wired into the app's in-app Sponsors page (via the `sponsors.js` manifest) and the project website as those surfaces ship.
By sponsoring you confirm you have the right to use the submitted logo and grant OmniVoice permission to display it in the contexts above. We won't alter your logo beyond scaling, and we'll remove it promptly on request.
---
## Current sponsors
OmniVoice doesn't have any sponsors yet — **you could be the first.** These slots fill in as sponsors come aboard.
### 🥇 Gold
_Be the first Gold sponsor — [claim this slot](#how-to-become-a-sponsor)._
### 🥈 Silver
_Open — [become a Silver sponsor](#how-to-become-a-sponsor)._
### 🟫 Bronze
_Open — [become a Bronze sponsor](#how-to-become-a-sponsor)._
### 🥉 Backers
_Open — [become a Backer](#how-to-become-a-sponsor)._
<!-- When a sponsor joins, add them to the matching section above:
- Logo tiers (Bronze+): <a href="https://sponsor.example"><img src="docs/sponsors/name.svg" alt="Name" height="48" /></a>
- Backers: - [Name / handle](https://link) -->
---
## Not a paywall
Sponsorship is a **thank-you, never a paywall.**
Every feature of OmniVoice Studio is and will remain **free** and **open-source under [AGPL-3.0](LICENSE)**. Sponsors do **not** get private builds, gated features, license exceptions, or anything that degrades the experience for people who don't (or can't) pay. What sponsors get is **visibility and our gratitude** — and the knowledge that they're directly funding the next release.
OmniVoice stays local-first and fully functional with zero dollars spent. Sponsoring just helps it keep getting better, faster.
---
<div align="center">
<sub>Thank you for keeping local-first voice AI alive and free. ❤️</sub><br/>
<sub>Questions? <a href="https://github.com/debpalash/OmniVoice-Studio/issues/new?template=sponsor.yml">Open an inquiry</a> · <a href="https://discord.gg/bzQavDfVV9">Discord</a></sub>
</div>
+8
View File
@@ -33,6 +33,14 @@ hiddenimports = [
'uvicorn.lifespan', 'uvicorn.lifespan.on',
'fastapi', 'fastapi.responses', 'starlette',
'multipart',
# SOCKS proxy support (#959). httpx imports socksio lazily inside a
# try/except (only when a socks5:// proxy env var is set), so
# PyInstaller's static tracer never sees it — without this entry the
# frozen installers keep raising "Using SOCKS proxy, but the 'socksio'
# package is not installed" on every model load under a SOCKS proxy,
# even though pyproject.toml ships the package. Guarded by
# tests/test_socks_proxy.py.
'socksio',
# Core
'uuid', 'asyncio',
+19 -13
View File
@@ -179,6 +179,21 @@ async def _run_batch_pipeline(job_id: str, job: dict):
job["status"] = "failed"
return
# ── Engine resolution (issue #312 class) ────────────────────────────
# Batch used to hardcode OmniVoice via get_model() regardless of the
# engine selected in Settings → Engines. require_cloning only when a
# specific voice is pinned (job["voice_id"]) — an unpinned job is fine on
# any active engine. Resolved ONCE for the whole job (every language
# below shares the same active engine); an uncaught ValueError here
# propagates to _worker()'s existing except-Exception handling, which
# already records a structured job failure via core.failure.build_failure.
from services.tts_backend import resolve_generation_backend
backend = await resolve_generation_backend(
require_cloning=bool(job.get("voice_id")),
cloning_purpose="this batch job's pinned voice",
)
sr = backend.sample_rate
# ── 3. Translate + Generate per language ───────────────────────────
total_langs = len(langs)
outputs = {}
@@ -243,13 +258,10 @@ async def _run_batch_pipeline(job_id: str, job: dict):
total_segments=len(translated_segments),
)
from services.model_manager import get_model
from services.audio_dsp import apply_mastering, normalize_audio
from services.audio_io import atomic_save_wav
import torch
_model = await get_model()
sr = _model.sampling_rate
total_samples = int(duration * sr)
full_audio = torch.zeros(1, total_samples)
total_segs = len(translated_segments)
@@ -295,22 +307,16 @@ async def _run_batch_pipeline(job_id: str, job: dict):
ref_text = row.get("ref_text")
try:
audios = _model.generate(
audio_out = backend.generate(
text=text, language=lang,
ref_audio=ref_audio, ref_text=ref_text,
duration=dur, num_step=16,
guidance_scale=2.0, speed=1.0,
denoise=True, postprocess_output=True,
)
audio_out = audios[0]
# TODO(#312): this route runs the OmniVoice model directly (not the active
# backend), so VoxCPM2 never reaches it. When these routes become
# engine-aware, guard with `if not getattr(backend, "applies_own_mastering", False)`.
mastered = apply_mastering(
audio_out,
sample_rate=sr,
)
return normalize_audio(mastered, target_dBFS=-2.0)
if not getattr(backend, "applies_own_mastering", False):
audio_out = apply_mastering(audio_out, sample_rate=sr)
return normalize_audio(audio_out, target_dBFS=-2.0)
except Exception as e:
logger.warning("TTS failed for seg %d (lang=%s): %s", i, lang, e)
return torch.zeros(1, int(dur * sr))
+15 -2
View File
@@ -120,6 +120,15 @@ async def transcribe_audio(
from services.refinement import collapse_repetitive_artifacts
full_text = collapse_repetitive_artifacts(full_text)
# Cross-transport parity: deterministically polish the final text
# (leading capital + terminal punctuation) exactly like the live
# dictation socket (capture_ws) does, so the widget's POST fallback and
# MCP/CLI callers get the same typed-looking result the WS returns —
# not the raw "...test" the REST path used to leak. Segments stay raw
# (their timings/verbatim recognition are the contract).
from services.text_polish import polish_text
full_text = polish_text(full_text)
# Calculate audio duration from segments if available
duration = 0.0
if segments:
@@ -135,8 +144,12 @@ async def transcribe_audio(
if _truthy(refine) and full_text:
from services.refinement import maybe_refine
refined = await asyncio.to_thread(maybe_refine, full_text)
if refined and refined != full_text:
refined_text = refined
if refined:
# Polish the refined text too, so both surfaced strings read as
# typed text (mirrors the raw-vs-refined contract of the WS).
refined = polish_text(refined)
if refined != full_text:
refined_text = refined
logger.info(
"Capture transcription done: engine=%s, elapsed=%.2fs, duration=%.1fs, mode=%s, refined=%s",
+161 -51
View File
@@ -19,7 +19,15 @@ Protocol:
"segments": [...], "language": "en",
"duration_s": 4.2, "transcription_time_s": 0.8,
"engine": "mlx-whisper"}
{"type": "error", "detail": "..."} error
{"type": "status", "stage": "downloading"|"loading"|"ready"}
model cold-start
{"type": "error", "message": "...", "kind": "...",
"detail": "..."} error ("detail"
kept for legacy)
Every ``final`` text is normalised by services.text_polish (leading
capital for Latin scripts, terminal punctuation, single-spaced) so the
pasted result reads like typed text. Partials are raw.
"""
from __future__ import annotations
@@ -32,6 +40,7 @@ import time
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from api.dependencies import _LOOPBACK_HOSTS, ws_remote_authorized
from services.text_polish import polish_text
router = APIRouter()
logger = logging.getLogger("omnivoice.capture_ws")
@@ -302,20 +311,29 @@ async def ws_transcribe(websocket: WebSocket):
if total_bytes > MIN_FINAL_BUFFER_BYTES:
try:
result = await _transcribe_buffer_full(audio_chunks, pcm_sr=pcm_sr)
# Dictation v2: deterministic polish so the pasted final reads
# like typed text (leading capital, terminal punctuation).
result["text"] = polish_text(result.get("text", ""))
# Wave 2.1: optional local-LLM refinement of the final text.
# Off-thread (network call, not GPU); pass-through on any
# failure or when no LLM backend is configured. The raw text
# always ships too — clients paste refined_text ?? text.
# HARD-BOUNDED (maybe_refine_async, ~4s OMNIVOICE_REFINE_TIMEOUT_S):
# a slow/dead LLM can never delay this `final` beyond the budget —
# it falls back to the unrefined (but polished) text. Best-effort:
# never let refinement turn a good final into an error. The raw
# text always ships too — clients paste refined_text ?? text.
if result.get("text"):
from services.refinement import maybe_refine
refined = await asyncio.to_thread(maybe_refine, result["text"])
if refined and refined != result["text"]:
result["refined_text"] = refined
try:
from services.refinement import maybe_refine_async
refined = await maybe_refine_async(result["text"])
if refined and refined != result["text"]:
result["refined_text"] = refined
except Exception as e: # noqa: BLE001
logger.debug("Dictation refinement skipped: %s", e)
if not await _safe_send({"type": "final", **result}):
logger.debug("Skipped final send — client already disconnected")
except Exception as e:
logger.error("Final transcription failed: %s", e)
await _safe_send({"type": "error", "detail": str(e)})
await _safe_send({"type": "error", "message": str(e),
"kind": "transcribe", "detail": str(e)})
else:
await _safe_send({
"type": "final",
@@ -340,10 +358,19 @@ async def ws_transcribe(websocket: WebSocket):
# opt-in 1-byte type prefix when ?aec=1, else bare PCM) at ?sr= (default 16000).
# This is the low-latency transport — no WebM/ffmpeg in the hot path.
# How often the offline-kind handler re-decodes the growing buffer for a live
# partial (streaming-kind decodes every frame, no cadence needed).
# How often the offline-kind handler re-decodes the live window for a partial
# (streaming-kind decodes every frame, no cadence needed).
SHERPA_OFFLINE_PARTIAL_S = float(os.environ.get("OMNIVOICE_SHERPA_OFFLINE_PARTIAL", "0.8"))
# Utterance gate for the offline-kind handler: once the trailing this-many
# seconds of the live buffer fall below the RMS floor, the utterance is
# COMMITTED — decoded, flushed as a `final`, and dropped from the buffer. Each
# decode is thereby bounded by one utterance instead of the whole session
# (the old full-buffer re-decode was O(n²)), and a sentence commits ~0.6s
# after the user stops speaking instead of only at EOF.
SHERPA_OFFLINE_SILENCE_S = float(os.environ.get("OMNIVOICE_SHERPA_OFFLINE_SILENCE", "0.6"))
SHERPA_OFFLINE_RMS_FLOOR = float(os.environ.get("OMNIVOICE_SHERPA_OFFLINE_RMS", "0.01"))
def _pcm16_to_f32(pcm: bytes):
"""int16 little-endian mono PCM bytes → float32 numpy in [-1, 1]."""
@@ -411,30 +438,64 @@ async def _recv_pcm_frame(websocket: WebSocket, aec):
return "skip", b""
async def _sherpa_load_with_status(websocket: WebSocket, backend, spec) -> bool:
"""Build the recognizer off the event loop, narrating cold-start progress.
Sends ``{"type":"status","stage":"downloading"|"loading"}`` before the
load ("downloading" when the pinned assets aren't in the HF cache yet;
stage-only HF's per-file progress isn't worth a callback plumb-through)
and ``{"type":"status","stage":"ready"}`` after, so the widget can show
*why* the first dictation takes a moment. Returns False when the load
failed (the error frame is sent and the socket closed here).
"""
try:
from services import sherpa_dictation as _sd
stage = "loading" if _sd.is_installed(spec) else "downloading"
except Exception:
stage = "loading"
try:
await websocket.send_json({"type": "status", "stage": stage})
except Exception:
pass
try:
await asyncio.to_thread(backend.ensure_loaded)
except Exception as e:
logger.error("sherpa dictation load failed (%s): %s", spec.id, e)
try:
await websocket.send_json({"type": "error", "message": str(e),
"kind": "load", "detail": str(e)})
await websocket.close()
except Exception:
pass
return False
try:
await websocket.send_json({"type": "status", "stage": "ready"})
except Exception:
pass
return True
async def _run_sherpa_streaming(websocket: WebSocket, spec):
"""True streaming: feed the OnlineRecognizer frame-by-frame, emit `partial`
every time the decoded text grows, and `final` on sherpa's endpoint (silence)
detection and on EOF. <300ms perceived latency on CPU for the tiny models.
"""
import numpy as np
from services.asr_backend import SherpaDictationBackend
from services.asr_backend import get_sherpa_dictation_backend
pcm_sr, aec = await _sherpa_session(websocket)
logger.info("sherpa streaming dictation: model=%s sr=%d aec=%s",
spec.id, pcm_sr, bool(aec))
backend = SherpaDictationBackend(model_id=spec.id)
# Build the recognizer off the event loop (download-on-first-use + ONNX
# session init can take a moment); keep the socket responsive.
try:
await asyncio.to_thread(backend.ensure_loaded)
except Exception as e:
logger.error("sherpa streaming load failed: %s", e)
try:
await websocket.send_json({"type": "error", "detail": str(e)})
await websocket.close()
except Exception:
pass
# Reuse the shared, per-model warm backend (#888): the recognizer is built
# once and shared across sessions instead of rebuilt (1.32.5s) per connect,
# so the first dictation is instant when the preload warmed it. Each session
# still gets its own decode stream below.
backend = get_sherpa_dictation_backend(spec.id)
# Build the recognizer off the event loop if it isn't warm yet
# (download-on-first-use + ONNX session init can take a moment); status
# frames keep the widget honest.
if not await _sherpa_load_with_status(websocket, backend, spec):
return
rec = backend._rec
stream = rec.create_stream()
@@ -484,7 +545,9 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
continue
text, endpoint = await asyncio.to_thread(_decode_after_feed, pcm)
if endpoint:
# Commit this utterance; reset for the next one.
# Commit this utterance (polished — it gets pasted); reset
# for the next one.
text = polish_text(text)
if text:
committed.append(text)
await _send({"type": "final", "text": text,
@@ -507,16 +570,20 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
except Exception as e:
logger.debug("sherpa streaming flush failed: %s", e)
tail_text = ""
tail_text = polish_text(tail_text)
if tail_text and tail_text != (committed[-1] if committed else None):
committed.append(tail_text)
# Pieces are already polished; the join is too (polish is idempotent).
full = " ".join(t for t in committed if t).strip()
segments = [{"start": 0.0, "end": None, "text": t} for t in committed if t]
if not client_disconnected:
if full:
# Hard-bounded refinement (~4s): never delays this summary `final`
# beyond OMNIVOICE_REFINE_TIMEOUT_S even with a dead LLM endpoint.
try:
from services.refinement import maybe_refine
refined = await asyncio.to_thread(maybe_refine, full)
from services.refinement import maybe_refine_async
refined = await maybe_refine_async(full)
except Exception:
refined = None
payload = {"type": "final", "text": full, "segments": segments,
@@ -534,32 +601,34 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
async def _run_sherpa_offline(websocket: WebSocket, spec):
"""Offline-kind sherpa model with live partials: buffer raw PCM and
re-decode the growing buffer every ~800ms so the user still sees text
appear while speaking; finalize on EOF/silence."""
from services.asr_backend import SherpaDictationBackend
"""Offline-kind sherpa model with live partials, utterance-windowed.
Raw PCM accumulates in a *live* buffer holding only the current
(uncommitted) utterance. Every ~800ms the live window is re-decoded for a
``partial``; when the trailing ~0.6s of it fall below the RMS floor the
utterance is committed decoded once more, flushed as a ``final``, and
its samples dropped so per-partial cost is bounded by one utterance
(not the whole session) and sentences commit as the user pauses instead
of only at EOF."""
from services.asr_backend import get_sherpa_dictation_backend
pcm_sr, aec = await _sherpa_session(websocket)
logger.info("sherpa offline dictation: model=%s sr=%d aec=%s",
spec.id, pcm_sr, bool(aec))
backend = SherpaDictationBackend(model_id=spec.id)
try:
await asyncio.to_thread(backend.ensure_loaded)
except Exception as e:
logger.error("sherpa offline load failed: %s", e)
try:
await websocket.send_json({"type": "error", "detail": str(e)})
await websocket.close()
except Exception:
pass
# Shared, per-model warm backend (#888) — built once, reused per session.
backend = get_sherpa_dictation_backend(spec.id)
if not await _sherpa_load_with_status(websocket, backend, spec):
return
buf = bytearray()
buf = bytearray() # live (uncommitted) PCM only
committed: list[str] = [] # polished utterances already flushed
last_partial = ""
running = True
client_disconnected = False
last_audio = time.monotonic()
# Trailing-silence gate window, in bytes of int16 mono PCM.
sil_bytes = max(2, int(SHERPA_OFFLINE_SILENCE_S * pcm_sr) * 2)
async def _send(payload) -> bool:
nonlocal client_disconnected
@@ -572,8 +641,14 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
client_disconnected = True
return False
def _decode_buffer() -> str:
samples = _pcm16_to_f32(bytes(buf))
def _rms(pcm: bytes) -> float:
samples = _pcm16_to_f32(pcm)
if not len(samples):
return 0.0
return float((samples * samples).mean() ** 0.5)
def _decode_window(pcm: bytes) -> str:
samples = _pcm16_to_f32(pcm)
if not len(samples):
return ""
return backend._decode_offline(samples, pcm_sr)
@@ -597,14 +672,43 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
logger.debug("sherpa offline receive ended: %s", e)
running = False
async def _commit(snapshot: bytes):
"""Finalize one utterance: decode it off-thread, flush a polished
`final`, drop its samples from the live buffer. `receive()` may
append while we decode only the snapshot's prefix is dropped."""
nonlocal last_partial
try:
text = await asyncio.to_thread(_decode_window, snapshot)
except Exception as e:
logger.debug("sherpa offline commit decode failed: %s", e)
return
del buf[:len(snapshot)]
last_partial = ""
text = polish_text(text)
if text:
committed.append(text)
await _send({"type": "final", "text": text,
"segments": [{"start": 0.0, "end": None, "text": text}],
"language": "auto", "engine": backend.id})
async def partials():
nonlocal last_partial, running
while running:
await asyncio.sleep(SHERPA_OFFLINE_PARTIAL_S)
if not running or len(buf) < 2000:
continue
snapshot = bytes(buf)
if len(snapshot) > sil_bytes and \
_rms(snapshot[-sil_bytes:]) < SHERPA_OFFLINE_RMS_FLOOR:
if _rms(snapshot[:-sil_bytes]) >= SHERPA_OFFLINE_RMS_FLOOR:
await _commit(snapshot)
else:
# Pure silence — drop it (keep the gate window for
# continuity) so a long pause can't grow the buffer.
del buf[:len(snapshot) - sil_bytes]
continue
try:
text = await asyncio.to_thread(_decode_buffer)
text = await asyncio.to_thread(_decode_window, snapshot)
except Exception as e:
logger.debug("sherpa offline partial failed: %s", e)
continue
@@ -624,20 +728,26 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
except (asyncio.CancelledError, Exception):
pass
# Drain the trailing (un-committed) utterance on EOF.
try:
full = await asyncio.to_thread(_decode_buffer)
tail = await asyncio.to_thread(_decode_window, bytes(buf))
except Exception as e:
logger.error("sherpa offline final failed: %s", e)
full = ""
full = (full or "").strip()
segments = [{"start": 0.0, "end": None, "text": full}] if full else []
tail = ""
tail = polish_text(tail)
if tail:
committed.append(tail)
# Pieces are already polished; the join is too (polish is idempotent).
full = " ".join(committed).strip()
segments = [{"start": 0.0, "end": None, "text": t} for t in committed]
if not client_disconnected:
payload = {"type": "final", "text": full, "segments": segments,
"language": "auto", "engine": backend.id}
if full:
# Hard-bounded refinement (~4s) — never delays the `final`.
try:
from services.refinement import maybe_refine
refined = await asyncio.to_thread(maybe_refine, full)
from services.refinement import maybe_refine_async
refined = await maybe_refine_async(full)
if refined and refined != full:
payload["refined_text"] = refined
except Exception:
+282 -107
View File
@@ -16,6 +16,7 @@ from core.tasks import task_manager
from core import event_bus
from schemas.requests import DubIngestUrlRequest
from services.model_manager import get_model, _gpu_pool, _cpu_pool, get_diarization_pipeline, offload_tts_for_asr, restore_tts_after_asr
from services.asr_backend import ASRTimeoutError, reset_pool_after_wedge, run_transcribe_guarded
from services.audio_io import _safe_soundfile_write
from services.ffmpeg_utils import find_ffmpeg
from services.segmentation import (
@@ -35,24 +36,6 @@ router = APIRouter()
logger = logging.getLogger("omnivoice.api")
def _reset_pool_on_wedge(pool) -> None:
"""Abandon a GPU pool whose worker is wedged on a timed-out transcribe (#730).
Python can't kill the stuck thread, but dropping the poisoned pool means the
next submit (the next chunk, or a concurrent TTS generate) gets a fresh
worker instead of queueing behind the wedged one the same recovery the
whole-file paths get inside ``run_transcribe_guarded``. Best-effort and a
no-op for a pool without ``reset`` (a plain executor), so it never raises on
the failure path it's trying to recover from.
"""
_reset = getattr(pool, "reset", None)
if callable(_reset):
try:
_reset()
except Exception:
logger.exception("GPU pool reset after transcribe timeout failed")
# ── Legacy-name aliases to services/dub_pipeline.py ────────────────────────
# Phase 2.4 moved the business logic into a service. Other routers
# (dub_generate, dub_translate, dub_export) + internal call sites below still
@@ -392,6 +375,31 @@ _CHUNK_TRANSCRIBE_ATTEMPTS = max(1, int(os.environ.get("OMNIVOICE_TRANSCRIBE_CHU
_sse_event = dub_pipeline.sse_event
_prep_event_helper = dub_pipeline.prep_event # alias; we keep the module-local _prep_event below for the inline one-liner shape
#: User-facing warning emitted when auto voice cloning is skipped because the
#: speaker labels came from the silence-gap heuristic (see _diarize /
#: extract_speaker_clones — gap-based labels routinely mix two people's audio
#: into one reference, which is how "made up" clone voices happen).
CLONE_SKIP_HEURISTIC_MSG = (
"auto voice cloning skipped: speaker labels are gap-based estimates — "
"set up diarization (Settings → Models → pyannote) for per-speaker clones"
)
def _clamp_num_speakers(value) -> Optional[int]:
"""Clamp the user's speaker-count hint to a sane 120 range.
Shared by the SSE and legacy transcribe endpoints so the two can't drift.
None / non-int / out-of-range None (auto-detect), so a bad query string
can never break a diarization call.
"""
if value is None:
return None
try:
value = int(value)
except (TypeError, ValueError):
return None
return value if 1 <= value <= 20 else None
@router.get("/dub/transcribe-stream/{job_id}")
async def dub_transcribe_stream(
@@ -410,15 +418,14 @@ async def dub_transcribe_stream(
pyannote auto-detects the count but its auto-detect can collapse a
multi-speaker clip to a single speaker (issue #274). When the user knows
the exact count, supplying it forces pyannote to return that many speakers.
On paths that can't honor the hint exactly (inline ASR turns, the
silence-gap heuristic) it is never silently dropped: the heuristic cycles
the requested count and a `warning` SSE event tells the user how far the
labels can be trusted.
"""
# Clamp to a sane range; ignore anything non-positive / absurd so a bad
# query string can never break the diarization call. None → auto-detect.
if num_speakers is not None:
try:
num_speakers = int(num_speakers)
num_speakers = num_speakers if 1 <= num_speakers <= 20 else None
except (TypeError, ValueError):
num_speakers = None
num_speakers = _clamp_num_speakers(num_speakers)
job = _get_job(job_id)
@@ -445,6 +452,11 @@ async def dub_transcribe_stream(
asr_audio_target = job.get("vocals_path")
if not asr_audio_target or not os.path.exists(asr_audio_target):
asr_audio_target = job.get("audio_path")
# #963: onset snapping is only trustworthy on the Demucs vocals
# track. When separation failed/was skipped, dub_pipeline sets
# vocals_path to the mixed audio_path — so compare paths instead
# of trusting the key's presence.
asr_on_vocals = bool(asr_audio_target) and asr_audio_target != job.get("audio_path")
if not asr_audio_target or not os.path.exists(asr_audio_target):
preflight_error = "No audio available for transcription."
else:
@@ -580,35 +592,38 @@ async def dub_transcribe_stream(
# the hole instead of leaving silent gaps.
part = None
for _attempt in range(1, _CHUNK_TRANSCRIBE_ATTEMPTS + 1):
# A wedged chunk gets the SAME guarded-timeout + pool-reset
# semantics as the whole-file paths (#730/#851):
# run_transcribe_guarded bounds the call, abandons the poisoned
# pool so the retry (and any concurrent TTS work) gets a fresh
# worker, and raises the actionable ASRTimeoutError. Run it as
# a task and poll so we can keep yielding pings — the
# EventSource connection drops without them.
pool_reset_by_guard = False
task = asyncio.ensure_future(run_transcribe_guarded(
_gpu_pool, _transcribe_chunk,
what=f"Dub chunk {i + 1}/{chunks_n}",
timeout=TRANSCRIBE_CHUNK_TIMEOUT_S,
timeout_env="OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S",
))
while True:
done, _pending = await asyncio.wait({task}, timeout=5.0)
if done:
break
yield _sse_event("ping", {})
try:
# wait_for in a loop to yield pings so the EventSource connection doesn't drop
fut = loop.run_in_executor(_gpu_pool, _transcribe_chunk)
waited = 0.0
while True:
done, pending = await asyncio.wait([fut], timeout=5.0)
if done:
part = done.pop().result()
break
yield _sse_event("ping", {})
waited += 5.0
if waited >= TRANSCRIBE_CHUNK_TIMEOUT_S:
# Re-raise TimeoutError if we exceed the overall limit
raise asyncio.TimeoutError()
except asyncio.TimeoutError:
part = task.result()
except ASRTimeoutError as e:
# The guard already reset the pool; keep the actionable
# message (it names the durable fixes, and — after repeated
# timeouts — the crash-isolated engine escape hatch).
pool_reset_by_guard = True
logger.error(
"Transcribe chunk %d/%d timed out after %.0fs (attempt %d/%d, job=%s)",
i + 1, chunks_n, TRANSCRIBE_CHUNK_TIMEOUT_S, _attempt,
_CHUNK_TRANSCRIBE_ATTEMPTS, job_id,
)
# #730: the wedged chunk thread keeps holding its GPU-pool
# worker. Abandon the poisoned pool so the retry (and any TTS
# work) gets a fresh worker instead of queueing behind it.
_reset_pool_on_wedge(_gpu_pool)
part = {
"chunks": [], "language": None,
"error": f"Chunk {i+1} timed out after {TRANSCRIBE_CHUNK_TIMEOUT_S:.0f}s — "
f"ASR backend may be stuck. Try restarting the server.",
}
part = {"chunks": [], "language": None, "error": str(e)}
# Success → keep it. Failure/timeout → retry once on a fresh
# worker (the internal _transcribe_chunk except returns an
# error-part; the timeout path already reset the pool).
@@ -619,7 +634,9 @@ async def dub_transcribe_stream(
"Retrying transcribe chunk %d/%d after failure/timeout (next attempt %d/%d, job=%s)",
i + 1, chunks_n, _attempt + 1, _CHUNK_TRANSCRIBE_ATTEMPTS, job_id,
)
_reset_pool_on_wedge(_gpu_pool)
if not pool_reset_by_guard:
reset_pool_after_wedge(
_gpu_pool, what=f"Dub chunk {i + 1}/{chunks_n}")
if part.get("error"):
chunk_errors.append(part["error"])
logger.warning("Chunk %d/%d error: %s", i + 1, chunks_n, part["error"])
@@ -637,12 +654,19 @@ async def dub_transcribe_stream(
# leading music/silence (classic case: speech begins at 0:03,
# transcript says 0.0 → the dub plays 3 s early). Snap starts
# forward to the actual speech onset. `audio_np` is the same
# track ASR ran on — vocals.wav when Demucs succeeded.
# track ASR ran on — vocals.wav when Demucs succeeded. #963:
# when it didn't (mixed audio), snapping is disabled — every
# footstep/sigh/score cue is a false onset candidate there.
try:
snap_segment_starts(chunk_segs, audio_np, sr)
snap_segment_starts(chunk_segs, audio_np, sr,
separated_vocals=asr_on_vocals)
except Exception as e:
logger.warning("onset alignment skipped for chunk %d: %s", i, e)
chunk_segs = assign_speakers_heuristic(chunk_segs)
# Provisional per-chunk labels for the streaming UI only — the
# final diarization pass below overwrites them. Honor the user's
# speaker-count hint here too so the interim view doesn't flip
# between 2 and N speakers.
chunk_segs = assign_speakers_heuristic(chunk_segs, num_speakers)
for s in chunk_segs:
s["id"] = f"s{next_seg_id:05x}"
s["text_original"] = s.get("text", "")
@@ -696,33 +720,110 @@ async def dub_transcribe_stream(
return
def _diarize():
"""Returns (segments, warning_payload_or_None).
"""Returns (segments, warning_payload_or_None, labels_source).
`labels_source` records where the speaker labels came from
`"pyannote"` | `"turns"` | `"heuristic"` so downstream
auto-clone extraction can refuse to cut reference audio from
gap-based estimates (a mixed-speaker reference is how "made up"
clone voices happen).
`warning_payload` is a structured dict
`{detail, error_class, docs_url}` whenever we silently fell back
to the silence-gap heuristic (no HF_TOKEN, model unavailable,
license not accepted, or pyannote raised). The heuristic only
detects speaker turns from >1.2s silences, so a rapid-fire
manwoman exchange will read as one speaker. Issue #78 — we
attach an `error_class` so the front-end's errorDocsMap can
render a "See docs" deeplink instead of a dead-end toast.
license not accepted, or pyannote raised) or whenever the
user's `num_speakers` hint could not be honored exactly. The
heuristic only detects speaker turns from >1.2s silences, so a
rapid-fire manwoman exchange will read as one speaker. Issue
#78 — we attach an `error_class` so the front-end's errorDocsMap
can render a "See docs" deeplink instead of a dead-end toast.
"""
# The active ASR backend already diarized inline (FunASR cam++):
# use its speaker turns directly and skip pyannote entirely (#182).
if asr_speaker_turns:
logger.info("Using inline ASR diarization (%d turns); skipping pyannote.", len(asr_speaker_turns))
assigned = assign_speakers_from_turns(all_segments, asr_speaker_turns)
# #486: split any segment that spans two speakers' turns at the
# word boundary (single-speaker segments pass through unchanged).
return resplit_segments_by_turns(assigned, all_words, asr_speaker_turns), None
from services.model_manager import (
DIARIZATION_ERR_LICENSE,
DIARIZATION_ERR_NO_TOKEN,
)
from core import error_docs_map
diar_pipe, err_sentinel = get_diarization_pipeline(return_error=True)
def _hint_suffix() -> str:
"""Honest caveat appended to heuristic-fallback warnings when a
multi-speaker hint is set: the count is now honored, but the
heuristic can't attribute voices. (A hint of 1 IS fully
honored one label so it needs no caveat.)"""
if not num_speakers or num_speakers < 2:
return ""
return (
f" Your speaker-count setting ({num_speakers}) is only "
f"approximately honored: the heuristic cycles "
f"{num_speakers} speaker labels on silence gaps instead "
f"of recognizing voices, so lines may be attributed to "
f"the wrong speaker."
)
def _use_turns(crash: Exception | None = None, err_sentinel=None):
"""Label from the ASR backend's inline speaker turns; warn when
that means the user's explicit count can't be enforced."""
logger.info(
"Using inline ASR diarization (%d turns)%s.",
len(asr_speaker_turns),
"" if crash else "; skipping pyannote",
)
assigned = assign_speakers_from_turns(all_segments, asr_speaker_turns)
# #486: split any segment that spans two speakers' turns at the
# word boundary (single-speaker segments pass through unchanged).
resplit = resplit_segments_by_turns(assigned, all_words, asr_speaker_turns)
if not num_speakers:
return resplit, None, "turns"
error_class = (
"HF_AUTH_FAILED"
if err_sentinel == DIARIZATION_ERR_NO_TOKEN
else "PYANNOTE_LICENSE_REQUIRED"
)
if crash:
detail = (
f"Speaker diarization crashed mid-run "
f"({type(crash).__name__}); falling back to the ASR "
f"engine's built-in speaker turns. Speaker-count hint "
f"ignored: the detected count may differ from the "
f"{num_speakers} you set."
)
else:
detail = (
f"Speaker-count hint ignored: pyannote diarization is "
f"unavailable, so the ASR engine's built-in speaker "
f"turns were used and the detected count may differ "
f"from the {num_speakers} you set. Set up diarization "
f"(Settings → Models → pyannote) to enforce an exact "
f"speaker count."
)
return resplit, {
"detail": detail,
"error_class": error_class,
"docs_url": error_docs_map.lookup(error_class),
"speaker_hint": {"requested": num_speakers, "status": "ignored"},
}, "turns"
# The active ASR backend already diarized inline (FunASR cam++):
# its turns are the fast path and skip pyannote entirely (#182) —
# but ONLY when the user didn't set an explicit speaker count.
# Inline turns are labeled per-30s-chunk and can't be forced to N
# speakers, so a set num_speakers prefers pyannote — the one
# engine that honors an exact count. When pyannote can't load,
# the turns are still the best labels available; use them and say
# so instead of silently eating the hint.
diar_pipe = None
err_sentinel = None
if asr_speaker_turns:
if num_speakers:
diar_pipe, err_sentinel = get_diarization_pipeline(return_error=True)
if not diar_pipe:
return _use_turns(err_sentinel=err_sentinel)
logger.info(
"num_speakers=%d set: preferring pyannote over %d inline "
"ASR turns (only pyannote honors an exact count).",
num_speakers, len(asr_speaker_turns),
)
else:
diar_pipe, err_sentinel = get_diarization_pipeline(return_error=True)
if not diar_pipe:
# Phase 1 AUTH-01: ask the resolver (App → Env → HF-CLI),
# not just the env var. This is the #35 fix — users who
@@ -773,13 +874,20 @@ async def dub_transcribe_stream(
f"heuristic; rapid speaker turns may be merged."
)
error_class = "PYANNOTE_LICENSE_REQUIRED"
warning = {
"detail": detail + _hint_suffix(),
"error_class": error_class,
"docs_url": error_docs_map.lookup(error_class),
}
if num_speakers:
warning["speaker_hint"] = {
"requested": num_speakers,
"status": "approximate" if num_speakers > 1 else "honored",
}
return (
assign_speakers_heuristic(all_segments),
{
"detail": detail,
"error_class": error_class,
"docs_url": error_docs_map.lookup(error_class),
},
assign_speakers_heuristic(all_segments, num_speakers),
warning,
"heuristic",
)
try:
# Pass the user's speaker-count hint through to pyannote when
@@ -794,9 +902,14 @@ async def dub_transcribe_stream(
assigned = assign_speakers_from_diarization(all_segments, diar)
# #486: split any segment that spans two speakers' turns at the
# word boundary (single-speaker segments pass through unchanged).
return resplit_segments_by_diarization(assigned, all_words, diar), None
return resplit_segments_by_diarization(assigned, all_words, diar), None, "pyannote"
except Exception as e:
logger.error(f"Diarization failed: {e}")
# Inline ASR turns beat the silence-gap heuristic as a crash
# fallback (this path is reachable with turns present since a
# set num_speakers routes turns-jobs through pyannote).
if asr_speaker_turns:
return _use_turns(crash=e)
# Mid-run failure — classify against the same sentinels so a
# post-load 401 (rare but possible after a token rotation)
# still gets the right docs deeplink.
@@ -807,36 +920,50 @@ async def dub_transcribe_stream(
if err_class_post == DIARIZATION_ERR_LICENSE
else "PYANNOTE_LICENSE_REQUIRED" # LOAD failures land here too
)
warning = {
"detail": (
f"Speaker diarization crashed mid-run "
f"({type(e).__name__}); falling back to a silence-gap "
f"heuristic. Rapid speaker turns may be merged."
+ _hint_suffix()
),
"error_class": error_class,
"docs_url": error_docs_map.lookup(error_class),
}
if num_speakers:
warning["speaker_hint"] = {
"requested": num_speakers,
"status": "approximate" if num_speakers > 1 else "honored",
}
return (
assign_speakers_heuristic(all_segments),
{
"detail": (
f"Speaker diarization crashed mid-run "
f"({type(e).__name__}); falling back to a silence-gap "
f"heuristic. Rapid speaker turns may be merged."
),
"error_class": error_class,
"docs_url": error_docs_map.lookup(error_class),
},
assign_speakers_heuristic(all_segments, num_speakers),
warning,
"heuristic",
)
fut_diar = loop.run_in_executor(_gpu_pool, _diarize)
final_segs = None
diar_warning = None
labels_source = "heuristic"
while True:
done, pending = await asyncio.wait([fut_diar], timeout=5.0)
if done:
final_segs, diar_warning = done.pop().result()
final_segs, diar_warning, labels_source = done.pop().result()
break
yield _sse_event("ping", {})
if diar_warning:
logger.warning("diarization fallback: %s", diar_warning.get("detail"))
yield _sse_event("warning", {
payload = {
"detail": diar_warning.get("detail"),
"source": "diarization",
"error_class": diar_warning.get("error_class"),
"docs_url": diar_warning.get("docs_url"),
})
}
# Machine-readable trail of what happened to the user's
# speaker-count hint (the `detail` text carries the human story).
if diar_warning.get("speaker_hint"):
payload["speaker_hint"] = diar_warning["speaker_hint"]
yield _sse_event("warning", payload)
job["segments"] = final_segs
@@ -848,17 +975,42 @@ async def dub_transcribe_stream(
try:
from services.speaker_clone import extract_speaker_clones, auto_profile_id
vocals_for_clone = job.get("vocals_path") or asr_audio_target
fut_clones = loop.run_in_executor(
_cpu_pool, extract_speaker_clones,
vocals_for_clone, final_segs, os.path.dirname(vocals_for_clone),
)
clones = None
while True:
done, pending = await asyncio.wait([fut_clones], timeout=5.0)
if done:
clones = done.pop().result()
break
yield _sse_event("ping", {})
clones = {}
if labels_source == "heuristic":
# Clone-purity guard: heuristic labels are silence-gap
# estimates, not voice identity — a per-speaker reference cut
# from them routinely concatenates two people's audio and the
# clone sounds "made up". Skip auto-clones and say so instead
# of shipping bad ones. (extract_speaker_clones enforces the
# same guard internally; this branch exists to surface the
# warning to the user.)
logger.info(
"auto speaker clones skipped (labels_source=heuristic, job=%s)",
job_id,
)
yield _sse_event("warning", {
"detail": CLONE_SKIP_HEURISTIC_MSG,
"source": "speaker_clone",
})
else:
fut_clones = loop.run_in_executor(
_cpu_pool, lambda: extract_speaker_clones(
vocals_for_clone, final_segs,
os.path.dirname(vocals_for_clone),
labels_source=labels_source,
),
)
while True:
done, pending = await asyncio.wait([fut_clones], timeout=5.0)
if done:
clones = done.pop().result()
break
yield _sse_event("ping", {})
if clones:
from services.speaker_clone import refine_ref_texts
clones = await loop.run_in_executor(
_gpu_pool, lambda: refine_ref_texts(clones, _asr_backend),
)
# Wave 3.2: per-segment clone refs. Cut each long-enough segment's
# own reference from the vocals so the dub of each line matches the
# prosody of its source line. Short lines fall back to the
@@ -878,6 +1030,10 @@ async def dub_transcribe_stream(
),
)
if seg_clones:
from services.speaker_clone import refine_ref_texts
seg_clones = await loop.run_in_executor(
_gpu_pool, lambda: refine_ref_texts(seg_clones, _asr_backend),
)
job["segment_clones"] = seg_clones
except Exception as e:
logger.warning("per-segment clone refs skipped: %s", e)
@@ -972,18 +1128,28 @@ async def dub_transcribe_stream(
@router.post("/dub/transcribe/{job_id}")
async def dub_transcribe(job_id: str):
async def dub_transcribe(job_id: str, num_speakers: Optional[int] = None):
"""Legacy synchronous transcribe (kept for the headless CLI).
`num_speakers` mirrors the SSE endpoint's query param (same 120 clamp):
an exact speaker count forwarded to pyannote, or cycled by the silence-gap
heuristic when pyannote is unavailable. None auto-detect.
"""
num_speakers = _clamp_num_speakers(num_speakers)
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
_model = await get_model()
def _transcribe():
asr_audio_target = job.get("vocals_path")
if not asr_audio_target or not os.path.exists(asr_audio_target):
asr_audio_target = job.get("audio_path")
# #963: same source-awareness as the SSE endpoint — vocals_path
# falls back to the mixed audio_path when Demucs failed/skipped.
asr_on_vocals = bool(asr_audio_target) and asr_audio_target != job.get("audio_path")
import torch
detected_lang = None
@@ -1027,10 +1193,13 @@ async def dub_transcribe(job_id: str):
segments = segment_transcript(result, duration=job.get("duration", 0.0), scene_cuts=scene_cuts)
# #280: snap segment starts forward to the actual speech onset so the
# dub doesn't begin seconds before the original speaker does.
# dub doesn't begin seconds before the original speaker does. #963:
# only on the separated vocals track — on mixed audio every ambient
# sound is a false onset candidate, so snapping is disabled.
try:
audio_for_onset, onset_sr = sf.read(asr_audio_target, dtype="float32")
snap_segment_starts(segments, audio_for_onset, onset_sr)
snap_segment_starts(segments, audio_for_onset, onset_sr,
separated_vocals=asr_on_vocals)
except Exception as e:
logger.warning("onset alignment skipped: %s", e)
@@ -1038,13 +1207,20 @@ async def dub_transcribe(job_id: str):
if diar_pipe:
try:
diar_target = job.get("vocals_path") or job.get("audio_path")
diarization = diar_pipe(diar_target)
# Same hint pass-through as the SSE endpoint (#274): omit the
# kwarg entirely when unset so we don't depend on it existing
# in every pyannote build.
if num_speakers:
logger.info("Diarizing with num_speakers=%d (user hint)", num_speakers)
diarization = diar_pipe(diar_target, num_speakers=num_speakers)
else:
diarization = diar_pipe(diar_target)
segments = assign_speakers_from_diarization(segments, diarization)
except Exception as e:
logger.error(f"Pyannote diarization failed during inference: {e}. Falling back to heuristic.")
segments = assign_speakers_heuristic(segments)
segments = assign_speakers_heuristic(segments, num_speakers)
else:
segments = assign_speakers_heuristic(segments)
segments = assign_speakers_heuristic(segments, num_speakers)
# Previously ran `segment_for_subtitles(segments)` here. Removed 2026-04-21 —
# that splitter enforces Netflix's 17 CPS reading-speed ceiling which
@@ -1068,7 +1244,6 @@ async def dub_transcribe(job_id: str):
# call would otherwise hold its GPU-pool worker forever and starve
# every other request into a "can't reach backend". run_transcribe_guarded
# also resets the pool on timeout so capacity is restored.
from services.asr_backend import run_transcribe_guarded
segments_result = await run_transcribe_guarded(_gpu_pool, _transcribe, what="Dub")
except asyncio.CancelledError:
job["aborted"] = True
+78 -15
View File
@@ -170,8 +170,42 @@ async def dub_list_tracks(job_id: str):
return {"tracks": job.get("dubbed_tracks", {})}
def _segments_for_lang(job: dict, lang: "str | None") -> list:
"""Job segments with `text` overlaid from ``job["segments_i18n"][lang]``.
P1.2 ``job["segments"]`` is single-slot: it holds whichever language was
generated LAST, so exporting subtitles for track A after generating track B
emitted B's text under A's language label (the "N identical subtitle
files" class). ``segments_i18n`` ({lang: {segKey: text}}, written by
``dub_generate._sync_job_segments``) preserves each generated track's text;
this overlays it non-destructively when present.
Back-compat: no lang requested, no ``segments_i18n`` on the job (predates
the field), no entry for this lang, or no text for a given segment each
falls back to the segment as-is, i.e. exactly today's behaviour.
Segment keys are the stable id (str) with the list index (str) as the
legacy fallback, mirroring how the map is written.
"""
segments = job.get("segments", [])
if not lang:
return segments
i18n = job.get("segments_i18n")
lang_texts = i18n.get(lang) if isinstance(i18n, dict) else None
if not isinstance(lang_texts, dict) or not lang_texts:
return segments
out = []
for i, seg in enumerate(segments):
key = str(seg.get("id")) if seg.get("id") is not None else str(i)
txt = lang_texts.get(key)
if txt is None:
txt = lang_texts.get(str(i))
out.append(dict(seg, text=txt) if isinstance(txt, str) and txt.strip() else seg)
return out
def _write_burn_srt(job: dict, exports_dir: str, stamp: str, dual: bool,
fitted_segments: "list[dict] | None" = None) -> str | None:
fitted_segments: "list[dict] | None" = None,
lang: "str | None" = None) -> str | None:
"""Build a temp SRT from job segments for use with ffmpeg's subtitles filter.
Returned path is already ffmpeg-filter-safe (plain ASCII basename under exports_dir).
@@ -181,8 +215,11 @@ def _write_burn_srt(job: dict, exports_dir: str, stamp: str, dual: bool,
fitted timeline when provided, cue times come from there instead of
the original ``job["segments"]`` timings, so burned subs track the
retimed video / fitted audio rather than the source timeline.
``lang`` (P1.2): burn the named track's text (see ``_segments_for_lang``)
instead of whatever language generated last.
"""
segments = job.get("segments", [])
segments = _segments_for_lang(job, lang)
if not segments:
return None
if fitted_segments:
@@ -486,7 +523,9 @@ async def dub_download(
# Smart Fit: cue times come from the fitted timeline — that's where the
# dubbed audio actually sits, whether or not the video retime succeeds.
fitted_segments = _fitted_segments_for(job, default_track) if default_track and default_track != "original" else None
sub_path = _write_burn_srt(job, exports_dir, stamp, dual, fitted_segments=fitted_segments) if burn_subs else None
# Burn the DEFAULT track's text (P1.2) — it's the audio the viewer hears.
_burn_lang = default_track if default_track and default_track != "original" else None
sub_path = _write_burn_srt(job, exports_dir, stamp, dual, fitted_segments=fitted_segments, lang=_burn_lang) if burn_subs else None
# ── Smart Fit video retime (two-tier) ─────────────────────────────────
# Tier 1 (≤48 chunks): single filter_complex graph inlined into the mux
@@ -1125,20 +1164,40 @@ async def dub_get_audio(job_id: str):
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(audio, media_type="audio/wav")
def _seg_wav_candidates(job: dict, lang: "str | None", seg_keys: tuple) -> list:
"""Per-segment WAV name candidates, language-keyed first (P1.3).
Generation writes ``seg_{lang}_{id}.wav`` now; ``lang`` defaults to the
job's last-generated track. Legacy un-keyed names (``seg_{id}.wav`` /
``seg_{index}.wav``) stay as fallbacks so jobs rendered by previous
builds keep serving their audio these read-only endpoints keep the
permissive fallback that matches their historic behaviour (the strict
single-track gate lives on the generate splice path, where a wrong-
language read would be baked into a track).
"""
lang = lang or job.get("language_code")
keys = []
if lang:
keys.extend(f"{lang}_{k}" for k in seg_keys)
keys.extend(seg_keys)
return keys
@router.get("/dub/preview/{job_id}/{segment_index}")
async def dub_preview_segment(job_id: str, segment_index: int):
async def dub_preview_segment(job_id: str, segment_index: int, lang: str = Query(None)):
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
# Resolve the stable-id-named WAV via the render manifest; fall back to the
# legacy index name for jobs rendered before id-based naming (#185). Each
# candidate is realpath-normalised and containment-checked BEFORE any
# filesystem access, so the guard dominates every path sink.
# Resolve the stable-id-named WAV via the render manifest — language-keyed
# name first (P1.3), then the legacy id/index names for jobs rendered
# before per-language (and before id-based, #185) naming. Each candidate
# is realpath-normalised and containment-checked BEFORE any filesystem
# access, so the guard dominates every path sink.
order = job.get("seg_order") or []
seg_id = order[segment_index] if 0 <= segment_index < len(order) else segment_index
base = os.path.realpath(DUB_DIR)
seg_path = None
for _sid in (seg_id, segment_index):
for _sid in _seg_wav_candidates(job, lang, (seg_id, segment_index)):
cand = os.path.realpath(dub_seg_path(job_id, _sid))
if cand.startswith(base + os.sep) and os.path.exists(cand):
seg_path = cand
@@ -1342,13 +1401,16 @@ def _fitted_cue_times(job: dict, lang: str | None) -> list | None:
async def dub_export_srt(
job_id: str,
dual: bool = False,
lang: str = Query(None, description="Track language code. When that track was generated under Smart Fit or stretch_video, cue times come from the fitted timeline."),
lang: str = Query(None, description="Track language code. Emits that track's text (segments_i18n) when the job carries it; when that track was generated under Smart Fit or stretch_video, cue times come from the fitted timeline."),
):
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
segments = job.get("segments", [])
# P1.2 — text follows the REQUESTED track, not whichever language was
# generated last (job["segments"] is single-slot). Legacy jobs without
# segments_i18n fall back to today's behaviour.
segments = _segments_for_lang(job, lang)
if not segments:
raise HTTPException(status_code=400, detail="No transcript segments available")
@@ -1391,13 +1453,14 @@ def _format_vtt_time(seconds):
async def dub_export_vtt(
job_id: str,
dual: bool = False,
lang: str = Query(None, description="Track language code. When that track was generated under Smart Fit or stretch_video, cue times come from the fitted timeline."),
lang: str = Query(None, description="Track language code. Emits that track's text (segments_i18n) when the job carries it; when that track was generated under Smart Fit or stretch_video, cue times come from the fitted timeline."),
):
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
segments = job.get("segments", [])
# Same per-track text resolution as /dub/srt (see comment there, P1.2).
segments = _segments_for_lang(job, lang)
if not segments:
raise HTTPException(status_code=400, detail="No transcript segments available")
@@ -1427,7 +1490,7 @@ async def dub_export_vtt(
@router.get("/dub/export-segments/{job_id}")
async def dub_export_segments_zip(job_id: str):
async def dub_export_segments_zip(job_id: str, lang: str = Query(None)):
import zipfile
job = _get_job(job_id)
if not job:
@@ -1445,7 +1508,7 @@ async def dub_export_segments_zip(job_id: str):
seg_id = order[i] if i < len(order) else i
# realpath + containment guard before any filesystem access.
seg_path = None
for _sid in (seg_id, i):
for _sid in _seg_wav_candidates(job, lang, (seg_id, i)):
cand = os.path.realpath(dub_seg_path(job_id, _sid))
if cand.startswith(base + os.sep) and os.path.exists(cand):
seg_path = cand
+173 -66
View File
@@ -11,7 +11,8 @@ from core.db import db_conn
from core.config import DUB_DIR, VOICES_DIR, dub_seg_path
from core.tasks import task_manager
from schemas.requests import DubRequest
from services.model_manager import get_model, _gpu_pool, run_on_gpu_pool_guarded
from services.model_manager import _gpu_pool, run_on_gpu_pool_guarded
from services.tts_backend import resolve_generation_backend
from services.audio_dsp import apply_mastering, normalize_audio, apply_effects_chain, get_effect_chain
from services.audio_io import atomic_save_wav, _safe_torchaudio_save
from services.ffmpeg_utils import (
@@ -87,6 +88,62 @@ def _sync_job_segments(job: dict, req: DubRequest) -> None:
merged.append(row)
job["segments"] = merged
# P1.2 — per-language text, additively. `job["segments"]` stays the flat
# single-slot map every existing consumer reads (last generated language);
# `job["segments_i18n"]` preserves EACH generated track's text so
# /dub/srt|vtt?lang= can emit that language instead of N identical files.
# Shape: { langCode: { segKey: text } } where segKey is the segment's
# stable id (str) or, for id-less legacy segments, its list index (str).
# The whole per-language map is rebuilt on every generate of that language
# (the request always carries the full segment list), so deleted segments
# never linger. Jobs predating this field simply lack it — every reader
# falls back to `job["segments"]`.
lang = (req.language_code or "und").strip() or "und"
i18n = job.setdefault("segments_i18n", {})
i18n[lang] = {
(str(row["id"]) if row.get("id") is not None else str(i)): row["text"]
for i, row in enumerate(merged)
}
def _seg_hashes_by_lang(job: dict) -> dict:
"""Per-language segment fingerprints: { langCode: { segId: hash } }.
Additive migration (P1.3): jobs written by previous builds carry ONE flat
`seg_hashes` map that was overwritten by whichever language generated
last. That flat map can only describe the job's last-generated track, so
it is attributed to `job["language_code"]` (which generate has always
kept in lock-step with the last run). When even that is unknown the
legacy hashes are dropped segments then read as stale and regenerate
cleanly, which is safer than guessing a language and splicing wrong-track
audio. Note the legacy hashes also predate language-scoped fingerprints
(see services.incremental.segment_fingerprint), so they compare stale
once regardless carrying them over just preserves the job shape.
"""
by_lang = job.get("seg_hashes_by_lang")
if not isinstance(by_lang, dict):
by_lang = {}
legacy = job.get("seg_hashes")
prev_lang = job.get("language_code")
if isinstance(legacy, dict) and legacy and prev_lang:
by_lang[prev_lang] = dict(legacy)
job["seg_hashes_by_lang"] = by_lang
return by_lang
def _legacy_seg_cache_ok(job: dict, lang_code: str) -> bool:
"""May this run reuse legacy un-keyed ``seg_<id>.wav`` files?
Only when no OTHER language's audio could be sitting in them: the job has
no dubbed track in a different language. Single-language jobs rendered by
previous builds therefore keep their whole on-disk cache; the moment a
job carries a second language the un-keyed files are ambiguous (they hold
whichever language wrote them last) and must never be spliced into a
track again the P1.3 cross-contamination class.
"""
tracks = job.get("dubbed_tracks") or {}
return not any(lc != lang_code for lc in tracks)
router = APIRouter()
@@ -100,13 +157,37 @@ async def dub_generate(job_id: str, req: DubRequest):
detail="This dub session has expired or was never created. Re-upload the video to start a new one.",
)
_model = await get_model()
# ── Engine resolution (issue #312 class) ────────────────────────────────
# Dub used to hardcode OmniVoice via get_model() regardless of the engine
# selected in Settings → Engines — a SILENT fallback. Every real dub
# segment's ref_audio resolves to either an auto:<speaker>/auto-seg:<id>
# clone cut from the source video or a saved voice-profile row (see
# `_gen` below), so require_cloning=True: an engine that can't clone
# would either mis-clone per segment or fail deep into the job. Checked
# ONCE here, before the streaming task starts, so a doomed job fails fast
# with one clear message instead of N per-segment ones.
try:
backend = await resolve_generation_backend(require_cloning=True)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
async def _stream(task_id):
total = len(req.segments)
all_segment_wavs = []
sync_scores = []
# Track language for this run. Everything per-track — the per-segment
# WAV cache, fingerprints, seg_wav_kind — is keyed by it (P1.3) so a
# multi-language job's tracks can't cross-contaminate.
lang_code = req.language_code or "und"
def _seg_lang_path(seg_key) -> str:
# Per-language per-segment WAV: seg_{lang}_{id}.wav. Built through
# dub_seg_path so the sanitisation + DUB_DIR containment guard
# apply to the combined key. Legacy un-keyed seg_{id}.wav files
# remain readable via the gated fallback (_legacy_seg_cache_ok).
return dub_seg_path(job_id, f"{lang_code}_{seg_key}")
# Throttle the device cache flush. empty_cache() is a synchronous
# device stall, so calling it every segment (as the old code did)
# serialised the GPU loop; the batched-I/O design it replaced kept
@@ -236,7 +317,16 @@ async def dub_generate(job_id: str, req: DubRequest):
# double-compress. Force one full regen; afterwards seg_wav_kind is
# "natural" and partial regen / fit-only re-mix (regen_only=[]) work.
# Jobs predating this field have unknown kind → also regen once.
if strategy == "smart_fit" and regen_only is not None and job.get("seg_wav_kind") != "natural":
# P1.3: the kind is per-track now (each language renders under its own
# strategy); the flat job["seg_wav_kind"] is only consulted for jobs
# written before the per-language map existed — once the map is
# present, a language without an entry has unknown-kind WAVs (or none
# at all) and must regen once, exactly like the pre-field case.
_kind_map = job.get("seg_wav_kind_by_lang")
_wav_kind = (
_kind_map.get(lang_code) if isinstance(_kind_map, dict) else job.get("seg_wav_kind")
)
if strategy == "smart_fit" and regen_only is not None and _wav_kind != "natural":
regen_only = None
# Manifest: stable segment id per current index. Per-segment WAVs are
# named by stable id (dub_seg_path) so regen reuses the right audio after
@@ -266,7 +356,7 @@ async def dub_generate(job_id: str, req: DubRequest):
seg_duration = seg.end - seg.start
if seg_duration <= 0.05 or not seg.text.strip():
sr = _model.sampling_rate
sr = backend.sample_rate
# max(0, …): a zero/negative-duration slot must not feed a
# negative length to torch.zeros (raises) — _store_mix_wav
# turns the empty buffer into a harmless in-memory entry.
@@ -283,31 +373,38 @@ async def dub_generate(job_id: str, req: DubRequest):
# Partial regen: if this segment isn't in the allow-list, reuse its
# previously-rendered WAV so the final mix still covers the timeline.
if regen_only is not None and seg_id not in regen_only:
seg_wav_path = dub_seg_path(job_id, seg_id)
if not os.path.exists(seg_wav_path):
# Back-compat: jobs rendered before id-named files used seg_{index}.wav.
_legacy = dub_seg_path(job_id, i)
if os.path.exists(_legacy):
seg_wav_path = _legacy
# This track's own cache first (seg_{lang}_{id}.wav). Legacy
# un-keyed files (seg_{id}.wav / seg_{index}.wav) are reused
# ONLY when no other-language track exists on the job — a
# multi-track job's un-keyed files hold whichever language
# rendered last, and splicing them here was exactly how
# "Regen N changed" mixed language B into track A (P1.3).
seg_wav_path = _seg_lang_path(seg_id)
if not os.path.exists(seg_wav_path) and _legacy_seg_cache_ok(job, lang_code):
for _legacy_key in (seg_id, i):
_legacy = dub_seg_path(job_id, _legacy_key)
if os.path.exists(_legacy):
seg_wav_path = _legacy
break
if os.path.exists(seg_wav_path):
try:
_t_cache_0 = time.perf_counter()
cached_wav, cached_sr = torchaudio.load(seg_wav_path)
if cached_sr != _model.sampling_rate:
if cached_sr != backend.sample_rate:
import torchaudio.functional as AF
cached_wav = AF.resample(cached_wav, cached_sr, _model.sampling_rate)
cached_wav = AF.resample(cached_wav, cached_sr, backend.sample_rate)
# Pad/trim to slot — except smart_fit, whose mix
# loop needs the natural-rate length to compute the
# audio/video split (the seg_wav_kind guard above
# guarantees these cached WAVs are natural-rate).
if strategy != "smart_fit":
target_samples = int(seg_duration * _model.sampling_rate)
target_samples = int(seg_duration * backend.sample_rate)
current_samples = cached_wav.shape[-1]
if target_samples > current_samples:
cached_wav = torch.nn.functional.pad(cached_wav, (0, target_samples - current_samples))
elif current_samples > target_samples:
cached_wav = cached_wav[..., :target_samples]
all_segment_wavs.append(_store_mix_wav(seg.start, seg.end, cached_wav, _model.sampling_rate, f"mix_{seg_id}"))
all_segment_wavs.append(_store_mix_wav(seg.start, seg.end, cached_wav, backend.sample_rate, f"mix_{seg_id}"))
try:
del cached_wav
except Exception:
@@ -320,7 +417,7 @@ async def dub_generate(job_id: str, req: DubRequest):
# Fall through to a silent placeholder if the cached WAV
# is broken — cleaner than aborting the whole mix.
yield f"data: {json.dumps({'type': 'warning', 'segment': i, 'message': f'cached seg lost, padding silence: {str(e)[:120]}'})}\n\n"
sr = _model.sampling_rate
sr = backend.sample_rate
silence = torch.zeros(1, max(0, int(seg_duration * sr)))
all_segment_wavs.append(_store_mix_wav(seg.start, seg.end, silence, sr, f"mix_{seg_id}"))
try:
@@ -404,25 +501,23 @@ async def dub_generate(job_id: str, req: DubRequest):
torch.manual_seed(used_seed)
try:
audios = _model.generate(
audio_out = backend.generate(
text=text, language=lang if lang != "Auto" else None,
ref_audio=ref_audio, ref_text=ref_text,
instruct=instruct_str if instruct_str else None,
duration=dur_s, num_step=nstep, guidance_scale=cfg,
speed=spd, denoise=True, postprocess_output=True,
)
audio_out = audios[0]
sr = _model.sampling_rate if hasattr(_model, 'sampling_rate') else 24000
sr = backend.sample_rate
# Apply per-segment DSP effect preset (default: broadcast)
seg_effect_preset = effect_preset or "broadcast"
if seg_effect_preset == "raw":
return audio_out
# TODO(#312): this route runs the OmniVoice model directly (not the active
# backend), so VoxCPM2 never reaches it. When these routes become
# engine-aware, guard with `if not getattr(backend, "applies_own_mastering", False)`.
mastered_audio = apply_mastering(audio_out, sample_rate=sr)
mastered_audio = audio_out
if not getattr(backend, "applies_own_mastering", False):
mastered_audio = apply_mastering(audio_out, sample_rate=sr)
effect_chain = get_effect_chain(seg_effect_preset)
if effect_chain:
mastered_audio = apply_effects_chain(
@@ -453,24 +548,22 @@ async def dub_generate(job_id: str, req: DubRequest):
nstep, retry_steps,
)
try:
audios = _model.generate(
audio_out = backend.generate(
text=text, language=lang if lang != "Auto" else None,
ref_audio=ref_audio, ref_text=ref_text,
instruct=instruct_str if instruct_str else None,
duration=dur_s, num_step=retry_steps, guidance_scale=cfg,
speed=spd, denoise=True, postprocess_output=True,
)
audio_out = audios[0]
sr = _model.sampling_rate if hasattr(_model, 'sampling_rate') else 24000
sr = backend.sample_rate
seg_effect_preset = effect_preset or "broadcast"
if seg_effect_preset == "raw":
return audio_out
# TODO(#312): this route runs the OmniVoice model directly (not the active
# backend), so VoxCPM2 never reaches it. When these routes become
# engine-aware, guard with `if not getattr(backend, "applies_own_mastering", False)`.
mastered_audio = apply_mastering(audio_out, sample_rate=sr)
mastered_audio = audio_out
if not getattr(backend, "applies_own_mastering", False):
mastered_audio = apply_mastering(audio_out, sample_rate=sr)
effect_chain = get_effect_chain(seg_effect_preset)
if effect_chain:
mastered_audio = apply_effects_chain(
@@ -557,7 +650,7 @@ async def dub_generate(job_id: str, req: DubRequest):
yield f"data: {json.dumps({'type': 'cancelled', 'segments_processed': i + 1})}\n\n"
return
target_samples = int(seg_duration * _model.sampling_rate)
target_samples = int(seg_duration * backend.sample_rate)
current_samples = audio_tensor.shape[-1]
if strategy == "strict_slot":
@@ -575,7 +668,7 @@ async def dub_generate(job_id: str, req: DubRequest):
# trim, slip, stretch the video, or split audio/video
# retiming (smart_fit) to accommodate it.
generated_dur = audio_tensor.shape[-1] / _model.sampling_rate
generated_dur = audio_tensor.shape[-1] / backend.sample_rate
sync_ratio = round(generated_dur / max(seg_duration, 0.01), 3)
sync_scores.append(sync_ratio)
@@ -584,6 +677,9 @@ async def dub_generate(job_id: str, req: DubRequest):
# and job flush to the batch-write phase after the GPU loop.
_seg_fp = None
try:
# track_lang scopes the hash to THIS track (P1.3); the
# client-side recompute (/tools/incremental) sends the
# same code, so parity (#281 class) holds per language.
_seg_fp = segment_fingerprint({
"text": seg.text,
"target_lang": getattr(seg, "target_lang", None),
@@ -592,24 +688,24 @@ async def dub_generate(job_id: str, req: DubRequest):
"speed": getattr(seg, "speed", None),
"direction": getattr(seg, "direction", None),
"effect_preset": getattr(seg, "effect_preset", None),
})
}, track_lang=lang_code)
except Exception as e:
logger.debug("seg fingerprint skipped for %s: %s", seg_id, e)
_pending_seg_writes.append((i, _model.sampling_rate, seg_id, _seg_fp, _num_step))
_pending_seg_writes.append((i, backend.sample_rate, seg_id, _seg_fp, _num_step))
# RVC needs the WAV on disk, so write it immediately only
# when RVC is active (uncommon path).
if rvc_is_enabled():
seg_wav_path = dub_seg_path(job_id, seg_id)
atomic_save_wav(seg_wav_path, audio_tensor, _model.sampling_rate)
seg_wav_path = _seg_lang_path(seg_id)
atomic_save_wav(seg_wav_path, audio_tensor, backend.sample_rate)
try:
await loop.run_in_executor(_gpu_pool, apply_rvc, seg_wav_path)
rvc_wav, rvc_sr = torchaudio.load(seg_wav_path)
if rvc_sr == _model.sampling_rate:
if rvc_sr == backend.sample_rate:
audio_tensor = rvc_wav
target_samples = int(seg_duration * _model.sampling_rate)
target_samples = int(seg_duration * backend.sample_rate)
current_samples = audio_tensor.shape[-1]
if target_samples > current_samples:
audio_tensor = torch.nn.functional.pad(audio_tensor, (0, target_samples - current_samples))
@@ -619,31 +715,32 @@ async def dub_generate(job_id: str, req: DubRequest):
yield f"data: {json.dumps({'type': 'warning', 'segment': i, 'message': f'RVC skipped: {str(e)[:120]}'})}\n\n"
# Watermark this FRESH TTS output exactly once, right before it
# is persisted. The same seg_<id>.wav is BOTH the downloadable
# per-segment file AND the assembly input for the final track,
# so marking it here (and nowhere else) gives the downloadable
# WAV its mark back and the final mix inherits it — no double-
# mark. Cached-reuse audio is already marked; silence/zero slots
# carry no speech to mark, so neither is re-watermarked.
audio_tensor = embed_watermark(audio_tensor, _model.sampling_rate)
# is persisted. The same seg_{lang}_{id}.wav is BOTH the
# downloadable per-segment file AND the assembly input for the
# final track, so marking it here (and nowhere else) gives the
# downloadable WAV its mark back and the final mix inherits it —
# no double-mark. Cached-reuse audio is already marked;
# silence/zero slots carry no speech to mark, so neither is
# re-watermarked.
audio_tensor = embed_watermark(audio_tensor, backend.sample_rate)
seg_wav_path = dub_seg_path(job_id, seg_id)
seg_wav_path = _seg_lang_path(seg_id)
try:
# Keep the existing per-segment WAV contract for previews
# and partial regeneration, but do not keep the tensor in RAM.
atomic_save_wav(seg_wav_path, audio_tensor, _model.sampling_rate)
atomic_save_wav(seg_wav_path, audio_tensor, backend.sample_rate)
except Exception as e:
logger.warning("seg write failed for %s: %s", seg_id, e)
# If the durable segment write fails, still preserve a mix
# copy so this generation can finish.
all_segment_wavs.append(_store_mix_wav(seg.start, seg.end, audio_tensor, _model.sampling_rate, f"mix_{seg_id}"))
all_segment_wavs.append(_store_mix_wav(seg.start, seg.end, audio_tensor, backend.sample_rate, f"mix_{seg_id}"))
try:
del audio_tensor
except Exception:
pass
_release_audio_tensors()
else:
all_segment_wavs.append((seg.start, seg.end, seg_wav_path, _model.sampling_rate))
all_segment_wavs.append((seg.start, seg.end, seg_wav_path, backend.sample_rate))
try:
del audio_tensor
except Exception:
@@ -651,7 +748,7 @@ async def dub_generate(job_id: str, req: DubRequest):
_release_audio_tensors()
except Exception as e:
yield f"data: {json.dumps({'type': 'error', 'segment': i, 'error': str(e)})}\n\n"
sr = _model.sampling_rate
sr = backend.sample_rate
all_segment_wavs.append(_store_mix_wav(seg.start, seg.end, torch.zeros(1, max(0, int(seg_duration * sr))), sr, f"mix_{seg_id}"))
sync_scores.append(1.0)
@@ -663,17 +760,24 @@ async def dub_generate(job_id: str, req: DubRequest):
# Per-segment WAVs were written during the loop to keep RAM bounded.
# Flush only lightweight fingerprints/quality metadata here.
_t_diskw_0 = time.perf_counter()
hashes = job.setdefault("seg_hashes", {})
# P1.3 — fingerprints live per language so each track's staleness is
# judged against ITS OWN last generate. The flat job["seg_hashes"] is
# kept as a mirror of the CURRENT track's map: every existing consumer
# (the `done` event, dub-history restore, older frontends) already
# treats it as "the hashes of the language generated last", which is
# exactly what it now provably contains.
hashes = _seg_hashes_by_lang(job).setdefault(lang_code, {})
quality_map = job.setdefault("seg_num_step", {})
for (_si, _sr, _sid, _fp, _nstep) in _pending_seg_writes:
if _fp is not None:
hashes[_sid] = _fp
quality_map[_sid] = _nstep
job["seg_hashes"] = dict(hashes)
# Single job flush instead of one per 8 segments.
_save_job(job_id, job)
_t_diskw = time.perf_counter() - _t_diskw_0
sr = _model.sampling_rate
sr = backend.sample_rate
slot_fit = (req.slot_fit or "time_stretch").lower()
overflow_budget_s = max(0.0, float(req.overflow_budget_s or 0.0))
@@ -759,7 +863,6 @@ async def dub_generate(job_id: str, req: DubRequest):
# not from the plan — so subtitles land exactly on the audio.
fitted_cues: list[dict] = []
lang_code = req.language_code or "und"
track_path = os.path.join(DUB_DIR, job_id, f"dubbed_{lang_code}.wav")
os.makedirs(os.path.dirname(track_path), exist_ok=True)
@@ -1049,8 +1152,12 @@ async def dub_generate(job_id: str, req: DubRequest):
job["dubbed_tracks"][lang_code]["fit_fp"] = fit_fp
# Record what kind of per-segment WAVs are on disk so a later
# smart_fit run knows whether partial regen / fit-only re-mix can
# reuse them ("natural") or must regen once ("slotted").
job["seg_wav_kind"] = "slotted" if strategy == "strict_slot" else "natural"
# reuse them ("natural") or must regen once ("slotted"). Per-track
# (P1.3) — each language renders under its own strategy; the flat
# field stays in lock-step for older readers.
_kind = "slotted" if strategy == "strict_slot" else "natural"
job.setdefault("seg_wav_kind_by_lang", {})[lang_code] = _kind
job["seg_wav_kind"] = _kind
_save_job(job_id, job)
_t_total = time.perf_counter() - _t_start
@@ -1098,7 +1205,13 @@ async def preview_segment(job_id: str, req: SegmentPreviewRequest):
if not job:
raise HTTPException(status_code=404, detail="Job not found")
_model = await get_model()
# See the /dub/generate/{job_id} resolution above (issue #312 class) —
# a segment preview resolves ref_audio from the same auto-clone /
# voice-profile sources, so it needs the same cloning-capable gate.
try:
backend = await resolve_generation_backend(require_cloning=True)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
def _gen():
ref_audio = None
@@ -1133,7 +1246,7 @@ async def preview_segment(job_id: str, req: SegmentPreviewRequest):
instruct_str = row["instruct"]
lang = req.language if req.language != "Auto" else None
audios = _model.generate(
audio_out = backend.generate(
text=req.text,
language=lang,
ref_audio=ref_audio,
@@ -1146,21 +1259,15 @@ async def preview_segment(job_id: str, req: SegmentPreviewRequest):
denoise=True,
postprocess_output=True,
)
audio_out = audios[0]
# TODO(#312): this route runs the OmniVoice model directly (not the active
# backend), so VoxCPM2 never reaches it. When these routes become
# engine-aware, guard with `if not getattr(backend, "applies_own_mastering", False)`.
mastered = apply_mastering(
audio_out,
sample_rate=getattr(_model, "sampling_rate", 24000),
)
return normalize_audio(mastered, target_dBFS=-2.0)
if not getattr(backend, "applies_own_mastering", False):
audio_out = apply_mastering(audio_out, sample_rate=backend.sample_rate)
return normalize_audio(audio_out, target_dBFS=-2.0)
# Bounded + pool-reset on hang so a wedged preview generate can't starve the
# GPU pool and brick the backend (#730 class).
audio_tensor = await run_on_gpu_pool_guarded(_gen, what="Dub preview generate")
sr = getattr(_model, "sampling_rate", 24000)
sr = backend.sample_rate
buf = io.BytesIO()
_safe_torchaudio_save(buf, audio_tensor, sr, format="wav")
buf.seek(0)
+202 -77
View File
@@ -8,7 +8,7 @@ from fastapi.responses import JSONResponse
from schemas.requests import TranslateRequest
from services.model_manager import _cpu_pool, _gpu_pool
from services.translator import cinematic_available, cinematic_refine_many
from services.translator import cinematic_available, cinematic_refine_many, _cinematic_budget
from api.routers.dub_core import _get_job
router = APIRouter()
@@ -302,15 +302,69 @@ async def dub_translate(req: TranslateRequest):
translated = await loop.run_in_executor(_gpu_pool, _translate_nllb)
if os.environ.get("OMNIVOICE_UNLOAD_NLLB", "1") == "1":
_unload_nllb()
return {"translated": translated, "target_lang": req.target_lang, "source_lang": src_lang,
**_dialect_flags(req, applied=False)}
# Cinematic/Autofit refine + rate-ratio badges must run for NLLB too
# (previously this returned before _maybe_cinematic, so a Cinematic
# pick on NLLB silently produced plain Fast output). Unloading NLLB
# first is fine — the refine LLM is a separate network provider.
return await _maybe_cinematic(translated, req, src_lang, loop)
# OpenAI / Ollama Local LLM Translation
# LLM translation — resolves through the LLM Skills registry: per-skill
# "Dub translation" override → global active provider (Settings → LLM
# Providers). The keys users configure + test in the app now actually
# power this engine; the raw TRANSLATE_* env vars stay working as a
# power-user override so pre-skills setups see zero behavior change.
if provider == "openai":
base_url = os.environ.get("TRANSLATE_BASE_URL")
model_name = os.environ.get("TRANSLATE_MODEL", "gpt-3.5-turbo")
from openai import OpenAI
client = OpenAI(base_url=base_url, api_key=api_key or "local")
from services import llm_skills
llm_timeout = llm_skills._default_timeout()
handle = None
try:
handle = llm_skills.resolve_skill_client("dub_translation")
except Exception: # noqa: BLE001 — resolution must never 500 a translate
logger.exception("dub_translation skill resolution failed; trying env fallback")
if handle is not None:
client = handle.client
model_name = handle.model
llm_timeout = handle.timeout
# The provider-store key never touches env; resolve it so the
# error scrubber below can redact it if a provider echoes it.
try:
from services import llm_providers
api_key = llm_providers.resolve_api_key(
llm_skills.effective_provider("dub_translation")) or api_key
except Exception: # noqa: BLE001 — scrub-key resolution is best-effort
pass
elif os.environ.get("TRANSLATE_BASE_URL") or api_key:
# Legacy env-only setup (no provider configured in-app).
from openai import OpenAI
# max_retries=0: a 429 + long Retry-After must not let one segment's
# SDK call sleep+retry and blow the overall translate wall time.
client = OpenAI(base_url=os.environ.get("TRANSLATE_BASE_URL"),
api_key=api_key or "local", max_retries=0)
model_name = os.environ.get("TRANSLATE_MODEL", "gpt-4o-mini")
else:
# Nothing configured anywhere — name the exact next step instead
# of letting an empty key surface as a raw 401 per segment.
try:
reason = llm_skills.resolve_skill("dub_translation").reason
except Exception: # noqa: BLE001
reason = None
if reason == "disabled":
friendly = (
"The LLM translation engine is turned off — enable the "
"'Dub translation' skill in Settings → LLM Skills, or "
"pick another engine in the Engine dropdown."
)
else:
friendly = (
"The LLM translation engine has no provider configured. "
"Add and test one in Settings → LLM Providers (it powers "
"this engine; route it per-skill in Settings → LLM "
"Skills), or set TRANSLATE_BASE_URL + TRANSLATE_API_KEY "
"+ TRANSLATE_MODEL. Or pick another engine in the "
"Engine dropdown."
)
return JSONResponse(status_code=400, content={"error": friendly})
def _build_prompt(src_code: str, tgt_code: str) -> str:
"""Build a system prompt that resists hallucinations on small
@@ -372,6 +426,7 @@ async def dub_translate(req: TranslateRequest):
res = client.chat.completions.create(
model=model_name,
temperature=0.2, # less drift than default 1.0
timeout=llm_timeout, # bound per call (OMNIVOICE_LLM_TIMEOUT, 45s default)
messages=[
{"role": "system", "content": sys_for_attempt},
{"role": "user", "content": seg.text},
@@ -399,14 +454,22 @@ async def dub_translate(req: TranslateRequest):
seg.id, attempt + 1, e,
)
# Both attempts failed — keep source text + flag error so the
# frontend can surface "fallback to literal" warning.
return {"id": seg.id, "text": seg.text, "error": last_err or "llm-failed"}
# frontend can surface "fallback to literal" warning. Scrub the
# provider error: some OpenAI-compatible providers echo the key
# or a user_id in the body, which must not reach the UI verbatim.
from core.scrub import scrub_provider_error
return {"id": seg.id, "text": seg.text,
"error": scrub_provider_error(last_err, api_key) or "llm-failed"}
tasks = [loop.run_in_executor(_cpu_pool, _translate_llm, seg) for seg in req.segments]
translated = await asyncio.gather(*tasks)
translated.sort(key=lambda x: str(x["id"]))
return {"translated": translated, "target_lang": req.target_lang, "source_lang": src_lang,
**_dialect_flags(req, applied=True)}
# provider="openai" is already an LLM translation — _maybe_cinematic
# skips the reflect/adapt re-refine (already_llm) but still stamps
# rate-ratio badges and runs the bounded Autofit fit pass. Before
# this it returned here, so Cinematic/Autofit on the LLM engine did
# nothing.
return await _maybe_cinematic(translated, req, src_lang, loop, already_llm=True)
# Offline Argos Translate
if provider == "argos" or provider == "libretranslate":
@@ -465,8 +528,11 @@ async def dub_translate(req: TranslateRequest):
return results
translated = await loop.run_in_executor(_cpu_pool, _translate_argos)
return {"translated": translated, "target_lang": req.target_lang, "source_lang": src_lang,
**_dialect_flags(req, applied=False)}
# Argos is the DEFAULT engine — routing it through _maybe_cinematic is
# the headline fix: a user who picks Cinematic/Autofit on Argos now
# gets the LLM refine + fit pass (and rate-ratio badges in Fast mode)
# instead of silent plain-Fast output.
return await _maybe_cinematic(translated, req, src_lang, loop)
# Legacy / API Deep_Translator logic.
# Preflight the optional `deep_translator` dep once so we fail with a
@@ -540,7 +606,11 @@ async def dub_translate(req: TranslateRequest):
)
time.sleep(0.25 * (attempt + 1))
logger.error("translate %s -> %s gave up (provider=%s): %s", src_arg, seg_lc, provider, last_err)
return {"id": seg.id, "text": seg.text, "error": last_err or "unknown"}
# Scrub before it reaches the UI — DeepL/Microsoft errors can echo
# the API key (same class as the OpenAI user_id leak).
from core.scrub import scrub_provider_error
return {"id": seg.id, "text": seg.text,
"error": scrub_provider_error(last_err, _deepl_key or _msft_key or api_key) or "unknown"}
tasks = [loop.run_in_executor(_cpu_pool, _translate_single, seg) for seg in req.segments]
translated = await asyncio.gather(*tasks)
@@ -554,24 +624,19 @@ async def dub_translate(req: TranslateRequest):
return JSONResponse(status_code=500, content={"error": str(e)})
async def _maybe_cinematic(translated, req, src_lang, loop):
"""If quality=cinematic and a usable LLM is configured, run REFLECT+ADAPT.
Otherwise return Fast-mode shape unchanged.
def _stamp_predicted_rate_ratio(translated, req) -> None:
"""Stamp a predicted ``rate_ratio`` on every row that has a known slot.
No LLM needed just the per-language CPS table from ``services/speech_rate``.
The UI's ``seg-rate-badge`` reads it (Fast mode included) to show which
segments will compress hard at generation time, so users can edit text or
pick a heavier quality. Mutates ``translated`` in place; never raises.
"""
quality = (getattr(req, "quality", None) or "fast").lower()
# Stamp the predicted rate_ratio on every translated row that has a
# known slot. Works for Fast mode too — no LLM needed; just the CPS
# table from services/speech_rate. The UI's `seg-rate-badge` reads
# this value and shows users which segments will compress hard at
# generation time, so they can edit text or pick Cinematic quality.
try:
from services.speech_rate import rate_ratio as _predict_rate_ratio
slots = {str(s.id): getattr(s, "slot_seconds", None) for s in req.segments}
for row in translated:
seg_ref = next(
(s for s in req.segments if str(s.id) == str(row["id"])),
None,
)
slot = getattr(seg_ref, "slot_seconds", None) if seg_ref else None
slot = slots.get(str(row["id"]))
text = (row.get("text") or "").strip()
if slot and text and not row.get("error"):
row["rate_ratio"] = round(
@@ -580,22 +645,119 @@ async def _maybe_cinematic(translated, req, src_lang, loop):
except Exception as e:
logger.debug("non-LLM rate_ratio prediction skipped: %s", e)
base = {"translated": translated, "target_lang": req.target_lang, "source_lang": src_lang,
"quality_used": "fast", **_dialect_flags(req, applied=False)}
# Autofit is Cinematic + a strict "never exceed the slot" fit pass, so both
# qualities take the LLM refine path below. Fast (and anything else) returns
# the plain translation unchanged.
async def _apply_fit_pass(rows, req, slots_by_id, source_by_id, quality, loop, deadline) -> None:
"""Run the Autofit slot-fit pass over ``rows`` concurrently, in place.
Bounded by ``deadline`` (shared with the cinematic refine) so a slow /
rate-limited LLM can't spin the fit pass per-segment unbounded — the old
behavior, which ran one blocking ``adjust_for_slot`` per segment in the
merge loop, outside any budget. Segments still running at the deadline keep
their current text and get ``rate_error='fit-budget'``. Only rows with a
slot + text + no prior error participate.
"""
strict = (quality == "autofit")
items = []
for row in rows:
seg_id = str(row["id"])
slot = slots_by_id.get(seg_id)
text = row.get("text") or ""
if slot and text and not row.get("error"):
items.append((seg_id, text, float(slot), req.target_lang,
source_by_id.get(seg_id), strict))
if not items:
return
try:
from services.speech_rate import adjust_for_slot_many
fits = await adjust_for_slot_many(
items, executor=_cpu_pool, deadline=deadline, loop=loop,
)
except Exception as e:
logger.warning("rate-fit pass skipped: %s", e)
return
for row in rows:
f = fits.get(str(row["id"]))
if not f:
continue
if f.get("text"):
row["text"] = f["text"]
if f.get("rate_ratio") is not None:
row["rate_ratio"] = f["rate_ratio"]
if f.get("error"):
row["rate_error"] = f["error"]
async def _maybe_cinematic(translated, req, src_lang, loop, *, already_llm=False):
"""Post-process a literal translation into Cinematic/Autofit output.
Runs for EVERY provider now (Argos/NLLB/Google//OpenAI). The three
LLM-independent branches (nllb/argos) and the openai branch used to return
*before* reaching this, so a Cinematic/Autofit pick on them including the
DEFAULT Argos engine silently produced plain Fast output with a success
toast. Fast mode still returns the plain translation (plus rate-ratio badges).
``already_llm`` (provider="openai"): the translation was itself produced by
an LLM, so the REFLECT+ADAPT *re*-refine is skipped, but the bounded Autofit
fit pass + rate-ratio stamping still run, and the dialect the translate
prompt already baked in is reported as applied.
"""
quality = (getattr(req, "quality", None) or "fast").lower()
_stamp_predicted_rate_ratio(translated, req)
# #280 item 2 — regional dialect hint, guarded against a stale dialect from
# another language. For already_llm the initial translate prompt already
# applied it, so it's reported applied in the Fast-shape base too.
dialect_hint = ""
_dialect = getattr(req, "dialect", None)
if _dialect and str(_dialect).lower().startswith(str(req.target_lang).lower()[:2]):
dialect_hint = dialect_clause(_dialect)
base = {"translated": translated, "target_lang": req.target_lang, "source_lang": src_lang,
"quality_used": "fast",
**_dialect_flags(req, applied=(already_llm and bool(dialect_hint)))}
# Fast (and anything unrecognised) returns the plain translation unchanged.
if quality not in ("cinematic", "autofit"):
return base
source_by_id: dict[str, str] = {str(s.id): s.text for s in req.segments}
slots_by_id = {
str(s.id): getattr(s, "slot_seconds", None)
for s in req.segments
if getattr(s, "slot_seconds", None)
}
# One wall-clock deadline shared by the whole LLM phase (refine + fit), so a
# slow/rate-limited provider can't run either pass unbounded. <=0 disables.
budget = _cinematic_budget()
deadline = (loop.time() + budget) if budget and budget > 0 else None
# provider="openai": already an LLM translation → skip REFLECT+ADAPT, keep
# the rate-ratio badges, still run the bounded fit pass.
if already_llm:
merged = []
for row in translated:
out = {"id": row["id"],
"text": row.get("text", "") or "",
"literal": row.get("text", "") or ""}
if row.get("error"):
out["error"] = row["error"]
if "rate_ratio" in row:
out["rate_ratio"] = row["rate_ratio"]
merged.append(out)
await _apply_fit_pass(merged, req, slots_by_id, source_by_id, quality, loop, deadline)
return {"translated": merged, "target_lang": req.target_lang,
"source_lang": src_lang, "quality_used": quality,
**_dialect_flags(req, applied=bool(dialect_hint))}
# Non-LLM provider → the reflect/adapt refine needs a separately-configured
# LLM (Settings → LLM Providers). Without one, degrade to Fast with a flag.
if not cinematic_available():
logger.warning("%s requested but no LLM configured — returning Fast result.", quality)
base["cinematic_skipped"] = "no-llm-configured"
return base
# Build a map from id → original segment (to fetch source text + direction).
source_by_id: dict[str, str] = {str(s.id): s.text for s in req.segments}
directions: dict[str, str] = {
str(s.id): s.direction
for s in req.segments
@@ -603,7 +765,7 @@ async def _maybe_cinematic(translated, req, src_lang, loop):
}
pairs = []
passthrough_index = {}
for i, row in enumerate(translated):
for row in translated:
seg_id = str(row["id"])
literal = row.get("text", "") or ""
if row.get("error") or not literal.strip():
@@ -614,12 +776,6 @@ async def _maybe_cinematic(translated, req, src_lang, loop):
if not pairs:
return base
# #280 item 2: thread the regional-dialect hint into the reflect/adapt
# prompts. Guard against a stale dialect from another language.
dialect_hint = ""
if req.dialect and str(req.dialect).lower().startswith(str(req.target_lang).lower()[:2]):
dialect_hint = dialect_clause(req.dialect)
refined = await cinematic_refine_many(
pairs,
source_lang=src_lang,
@@ -631,16 +787,6 @@ async def _maybe_cinematic(translated, req, src_lang, loop):
)
refined_by_id = {r["id"]: r for r in refined}
# Phase 4.4 — speech-rate fit pass. Segment boundaries aren't in the
# translate request (by design — translator is boundary-agnostic), so we
# only run it when the caller supplied `slot_seconds` on each segment.
# The frontend populates this for Cinematic calls from the edit view.
slots_by_id = {
str(s.id): getattr(s, "slot_seconds", None)
for s in req.segments
if getattr(s, "slot_seconds", None)
}
merged = []
for row in translated:
seg_id = str(row["id"])
@@ -659,32 +805,11 @@ async def _maybe_cinematic(translated, req, src_lang, loop):
}
if r.get("error"):
out["error"] = r["error"]
# Optional slot-fit pass — only when the caller asked for cinematic
# *and* provided a slot. Runs best-effort; no-LLM or mid-loop failure
# just leaves the cinematic text untouched.
slot = slots_by_id.get(seg_id)
if slot and out["text"]:
try:
from services.speech_rate import adjust_for_slot
fit = await asyncio.to_thread(
adjust_for_slot,
out["text"],
slot_seconds=float(slot),
target_lang=req.target_lang,
source_text=source_by_id.get(seg_id),
strict=(quality == "autofit"),
)
if fit.get("text"):
out["text"] = fit["text"]
out["rate_ratio"] = fit.get("rate_ratio")
if fit.get("error"):
out["rate_error"] = fit["error"]
except Exception as e:
logger.warning("rate-fit skipped for %s: %s", seg_id, e)
merged.append(out)
# Phase 4.4 speech-rate fit pass — now concurrent + bounded (see helper).
await _apply_fit_pass(merged, req, slots_by_id, source_by_id, quality, loop, deadline)
return {
"translated": merged,
"target_lang": req.target_lang,
+189
View File
@@ -15,6 +15,9 @@ Environment variables (`OMNIVOICE_TTS_BACKEND`, `OMNIVOICE_ASR_BACKEND`,
`OMNIVOICE_LLM_BACKEND`) still win over the UI choice so power-users can pin
a backend without Settings silently undoing it.
"""
import os
import re
import threading
from time import perf_counter
from fastapi import APIRouter, Depends, HTTPException
@@ -261,9 +264,177 @@ def engine_health(engine_id: str):
}
# ── Real-synthesis self-test (in-process TTS engines) ──────────────────────
#
# ``/health`` above is a liveness/import probe — for an in-process backend it
# only calls ``is_available()`` and the UI labels the result "deps OK". This
# route goes one step further: for an AVAILABLE, IN-PROCESS TTS engine it runs
# a *tiny real synthesis* from a fixed short phrase and reports duration +
# sample-rate + sample count, proving the engine actually emits audio rather
# than merely importing. The Compat Matrix's "Self-test" button calls it.
#
# Guardrails (kept identical across macOS/Windows/Linux per the default-feature
# rule — the phrase, timeout and gating don't branch on OS):
# * TTS family + available + in-process only. Subprocess engines keep their
# spawn-and-ping ``health_check`` (a real synth there is a sidecar
# cold-start — out of scope for a click-to-test affordance).
# * Bounded wall-clock timeout (``OMNIVOICE_SELFTEST_TIMEOUT_S``, default 90s):
# a runaway synth returns ``ok=False`` / ``timed_out=True`` instead of
# hanging the Settings panel. The orphaned worker is best-effort daemon.
# * A process-wide lock serialises self-tests so a click-storm can't stack
# concurrent model loads.
# * Only ever on user click (POST) — never on Settings load. Loopback-gated.
# Deliberately short + ASCII so the synth stays CPU-cheap and the phrase never
# trips the no-hardcoded-CJK guard.
_SELFTEST_PHRASE = "OmniVoice engine self test."
_SELFTEST_LOCK = threading.Lock()
def _selftest_timeout_s() -> float:
try:
return max(1.0, float(os.environ.get("OMNIVOICE_SELFTEST_TIMEOUT_S", "90")))
except (TypeError, ValueError):
return 90.0
def _sample_count(audio) -> int:
"""Total sample count of an engine's ``generate()`` return, tolerant of
torch.Tensor / numpy.ndarray / list shapes. 0 when it can't be measured."""
try:
shape = getattr(audio, "shape", None)
if shape is not None and len(shape) > 0:
return int(shape[-1])
return int(len(audio))
except Exception:
return 0
def _run_synth_bounded(backend, timeout_s: float) -> dict | None:
"""Run one tiny synthesis in a daemon thread, bounded by ``timeout_s``.
Returns ``{"audio": .., "duration_ms": ..}`` on success, ``{"error": exc}``
on a synth exception, or ``None`` when the timeout elapsed (worker left
running best-effort Python threads can't be force-killed)."""
box: dict = {}
def _worker():
t0 = perf_counter()
try:
audio = backend.generate(_SELFTEST_PHRASE, language="en", num_step=8)
box["audio"] = audio
except Exception as exc: # noqa: BLE001 — surfaced to the caller as ok=False
box["error"] = exc
finally:
box["duration_ms"] = (perf_counter() - t0) * 1000.0
th = threading.Thread(target=_worker, name="engine-selftest", daemon=True)
th.start()
th.join(timeout_s)
if th.is_alive():
return None
return box
class SelfTestResponse(BaseModel):
id: str
ok: bool
message: str
duration_ms: float
sample_rate: int | None = None
num_samples: int | None = None
audio_seconds: float | None = None
timed_out: bool = False
@router.post(
"/engines/{engine_id}/selftest",
response_model=SelfTestResponse,
dependencies=[Depends(require_loopback)],
)
def engine_selftest(engine_id: str):
"""Run a bounded, real synthesis on an available in-process TTS engine.
404 for an unknown TTS id; 400 when the engine is subprocess-isolated or
not currently available (a real synth on either is meaningless). Never
raises through to a 500 on a synth failure the exception is captured into
``ok=False`` / ``message`` so the panel renders a per-row failure."""
if engine_id not in tts_backend._REGISTRY:
raise HTTPException(
status_code=404,
detail=f"unknown TTS engine id: {engine_id!r}",
)
cls = tts_backend._REGISTRY[engine_id]
if getattr(cls, "_is_subprocess_isolated", False):
raise HTTPException(
status_code=400,
detail=(
f"{engine_id} is subprocess-isolated — self-test runs real "
"synthesis for in-process engines only. Use Test engine "
"(spawn-and-ping) for subprocess engines."
),
)
try:
ok, msg = cls.is_available()
except Exception as exc: # noqa: BLE001
ok, msg = False, f"{type(exc).__name__}: {exc}"
if not ok:
raise HTTPException(
status_code=400,
detail=(
f"{engine_id} is not available: {tts_backend._mask_hf_tokens(msg)}. "
"Install/enable the engine, then self-test."
),
)
timeout_s = _selftest_timeout_s()
# Serialise so a click-storm can't stack concurrent model loads.
with _SELFTEST_LOCK:
backend = _get_engine_instance(cls)
res = _run_synth_bounded(backend, timeout_s)
if res is None:
return SelfTestResponse(
id=engine_id,
ok=False,
message=f"timed out after {timeout_s:.0f}s (model still loading?)",
duration_ms=timeout_s * 1000.0,
timed_out=True,
)
if "error" in res:
exc = res["error"]
return SelfTestResponse(
id=engine_id,
ok=False,
message=tts_backend._mask_hf_tokens(f"{type(exc).__name__}: {exc}"),
duration_ms=res.get("duration_ms", 0.0),
)
n = _sample_count(res.get("audio"))
try:
sr = int(getattr(backend, "sample_rate", 0) or 0) or None
except Exception:
sr = None
secs = round(n / sr, 3) if (sr and n) else None
return SelfTestResponse(
id=engine_id,
ok=n > 0,
message="synthesized" if n > 0 else "engine returned no audio",
duration_ms=res["duration_ms"],
sample_rate=sr,
num_samples=n or None,
audio_seconds=secs,
)
class SelectEngineRequest(BaseModel):
family: str # "tts" | "asr" | "llm"
backend_id: str
# Only meaningful for family="tts", backend_id="mlx-audio" (#981) — picks
# which of mlx-audio's curated models is actually loaded. A curated key
# ("kokoro") or a raw HF repo id ("mlx-community/Kokoro-82M-bf16") — the
# same tolerance MLXAudioBackend.__init__ already has. Ignored otherwise.
model_id: str | None = None
class SelectEngineResponse(BaseModel):
@@ -306,6 +477,24 @@ def select_engine(req: SelectEngineRequest):
f"Backend {req.backend_id} can't run on this machine: {why}. "
f"Pick an engine with a CPU path, or one that supports this host's GPU.",
)
# #981: mlx-audio multiplexes 7+ curated models behind one backend id —
# persist the model pick alongside the backend id so the UI can actually
# select which curated model gets loaded (previously it always defaulted
# to Kokoro no matter what the user downloaded in Settings → Models).
if req.family == "tts" and req.backend_id == "mlx-audio" and req.model_id is not None:
known_keys = tts_backend.MLXAudioBackend.CURATED_MODELS
# Accept a curated key OR a raw HF repo id ("owner/name") — the same
# tolerance MLXAudioBackend.__init__ already has for power users.
# Anything else (typo'd key, malformed id) is rejected outright
# rather than silently persisted as a "custom repo" that then fails
# to resolve at load time.
if req.model_id not in known_keys and not re.fullmatch(r"[\w.-]+/[\w.-]+", req.model_id):
raise HTTPException(
400,
f"Unknown mlx-audio model: {req.model_id!r}. Expected one of "
f"{sorted(known_keys)} or a HF repo id like 'owner/name'.",
)
prefs.set_("mlx_audio_model_id", req.model_id)
prefs.set_(pref_key, req.backend_id)
return {
"family": req.family,
+205 -6
View File
@@ -1,5 +1,6 @@
import os
import io
import re
import uuid
import time
import random
@@ -104,8 +105,8 @@ def _apply_effect_chain(audio_out, sample_rate, effect_preset, *, skip_mastering
``skip_mastering`` honors a backend's ``applies_own_mastering`` flag
(issue #312): studio engines (e.g. VoxCPM2's native 48 kHz output)
opt out of the broadcast Compressor + Reverb chain that's tuned for
OmniVoice's 24 kHz clone output. Loudness normalization still runs —
opt out of the broadcast highpass + Compressor pre-stage that's tuned
for OmniVoice's 24 kHz clone output. Loudness normalization still runs —
it's a benign peak scale. Mirrors ``_run_tts`` in openai_compat.py.
"""
from services.audio_dsp import (
@@ -142,6 +143,158 @@ def _apply_effect_chain(audio_out, sample_rate, effect_preset, *, skip_mastering
return normalize_audio(audio_out, target_dBFS=-2.0)
def _safe_exc_text(e: BaseException) -> str:
"""``f"{type(e).__name__}: {e}"`` — the house style used for
unrecognized-error formatting throughout the backend (grep
``type(e).__name__`` in settings.py / asr_backend.py / model_manager.py
/ engines.py) with a guard against leaking a raw container repr.
#977: an AssertionError raised deep inside a vendored dependency
(mlx-audio's Kokoro pipeline) had ``.args`` shaped like
``('du', {'a': 'American English', ...})`` a tuple containing a dict.
``str(e)`` on that renders the WHOLE table straight into the user-facing
message. Any engine's ``generate()`` can raise something shaped like
this (not just Kokoro), so guard generically: if any element of
``e.args`` is a container rather than a plain string, don't interpolate
``str(e)`` at all name the exception type and point at the log
instead.
"""
args = getattr(e, "args", ())
if any(isinstance(a, (dict, list, tuple, set, frozenset)) for a in args):
return f"{type(e).__name__} — see Settings → Logs → Backend for details"
return f"{type(e).__name__}: {e}"
def _exception_chain(e):
"""Yield ``e`` plus every ``__cause__``/``__context__`` beneath it
(cycle-safe). Engines and hub libraries routinely wrap the original
transport/allocator error, so classification must look at the whole
chain, not just the outermost message."""
seen = set()
stack = [e]
while stack:
exc = stack.pop()
if exc is None or id(exc) in seen:
continue
seen.add(id(exc))
yield exc
stack.append(exc.__cause__)
stack.append(exc.__context__)
# #880: transport-level exception type names from httpx (huggingface_hub ≥1.x
# downloads over it) and requests/urllib3 (older engine deps). Any of these
# anywhere in the exception chain means the network — not memory — killed the
# generation.
_NETWORK_EXC_NAMES = frozenset({
# httpx
"ConnectError", "ConnectTimeout", "ReadTimeout", "ReadError",
"WriteError", "WriteTimeout", "PoolTimeout", "NetworkError",
"TransportError", "RemoteProtocolError", "ProxyError", "CloseError",
# requests / urllib3
"ConnectionError", "ChunkedEncodingError", "MaxRetryError",
"NewConnectionError", "ProtocolError",
# stdlib socket-level drops mid-download
"ConnectionResetError", "ConnectionAbortedError", "ConnectionRefusedError",
# huggingface_hub: failed first-use download with nothing in the disk cache
"LocalEntryNotFoundError",
})
# Same class, but the transport error was stringified into a wrapper message
# (so the type name is gone). All lowercase; matched against .lower().
_NETWORK_MSG_SIGNATURES = (
"client has been closed", # httpx closed-client lifecycle error (#880)
"cannot send a request", # httpx: same error, message head
"connection error", # requests / huggingface_hub wording
"connection reset", # ECONNRESET mid-download
"read timed out", # requests/urllib3 timeout wording
"max retries exceeded", # urllib3 retry exhaustion
"temporary failure in name resolution", # DNS down (glibc)
"name or service not known", # DNS down (glibc)
"getaddrinfo failed", # DNS down (Windows)
)
def _is_network_failure(e) -> bool:
"""True iff the failure (anywhere in its chain) is an HTTP-client
lifecycle / network-transport error e.g. a first-use model download
from the HF Hub dying mid-generation (#880)."""
for exc in _exception_chain(e):
if type(exc).__name__ in _NETWORK_EXC_NAMES:
return True
low = str(exc).lower()
if any(sig in low for sig in _NETWORK_MSG_SIGNATURES):
return True
return False
# Signatures of an *actual* out-of-memory condition. All lowercase.
_OOM_MSG_SIGNATURES = (
"out of memory", # CUDA / MPS / generic torch wording
"not enough memory", # torch CPU DefaultCPUAllocator
"cannot allocate memory", # OS-level ENOMEM
"std::bad_alloc", # C++ allocator failure
"cublas_status_alloc_failed", # cuBLAS workspace allocation
"cuda_error_out_of_memory", # raw CUDA driver error name
"paging file is too small", # Windows [WinError 1455] mapping DLLs
)
def _is_oom_failure(e) -> bool:
"""True iff the failure (anywhere in its chain) actually looks like an
out-of-memory condition the only case where the Flush hint is honest."""
for exc in _exception_chain(e):
if isinstance(exc, MemoryError):
return True
# torch.cuda.OutOfMemoryError subclasses RuntimeError; match by name
# so this needs no torch import (and covers other frameworks' twins).
if type(exc).__name__ == "OutOfMemoryError":
return True
low = str(exc).lower()
if any(sig in low for sig in _OOM_MSG_SIGNATURES):
return True
return False
# #919: an engine that requires a model path / env var which isn't set (or is
# set to a directory missing its model files) fails with a *configuration*
# error, not a runtime one. The reporting user selected sherpa-onnx and hit
# "OMNIVOICE_SHERPA_MODEL not set. Point it to a sherpa-onnx TTS model
# directory …" — a pure setup problem — yet the OOM catch-all told them (on a
# 63 GB-RAM box) to press Flush for memory they never ran out of. Classify the
# whole CLASS of "engine not configured / required env var not set" errors so
# any current or future opt-in engine (sherpa/Confucius4/dots/MOSS …) surfaces
# actionable setup guidance instead of the memory hint. All lowercase; matched
# over the whole exception chain (engines wrap the original error).
_CONFIG_MSG_SIGNATURES = (
"not set. point it to", # sherpa: OMNIVOICE_SHERPA_MODEL not set
"no model.onnx found in", # sherpa: dir set but the model file is missing
"not configured", # generic "engine not configured" wording
"venv not found. set", # confucius4/dots/MOSS dedicated-venv opt-ins
"unavailable: omnivoice_", # is_available() reason wrapped by _ensure_loaded
)
# An OMNIVOICE_* engine env var named alongside "not set" / "point it to" /
# "set omnivoice_…" is the strongest config-missing signal and generalizes to
# any engine gated on such a var (issue #919 class).
_CONFIG_ENV_RE = re.compile(r"omnivoice_[a-z0-9_]+")
def _is_config_failure(e) -> bool:
"""True iff the failure is a *configuration* problem — a required engine
model path / env var that isn't set (or points nowhere) — rather than a
runtime fault. The remedy is to set the value, never to Flush VRAM."""
for exc in _exception_chain(e):
low = str(exc).lower()
if any(sig in low for sig in _CONFIG_MSG_SIGNATURES):
return True
if _CONFIG_ENV_RE.search(low) and (
"not set" in low or "point it to" in low or "set omnivoice_" in low
):
return True
return False
def _oom_friendly_reraise(e):
"""Best-effort cache flush + the user-facing OOM hint shared by both
inference paths."""
@@ -233,10 +386,56 @@ def _oom_friendly_reraise(e):
f"Restart the app and try again; the Flush button won't help here. "
f"Underlying error: {e}"
) from e
# #880: an httpx/requests transport failure surfacing from generation —
# most commonly a first-use model download from the HF Hub dying with
# httpx's "Cannot send a request, as the client has been closed" (the
# shared client got closed mid-lifecycle), a connect/read timeout, or a
# dropped connection — is NOT out of memory. The model never finished
# loading, so Flush is the wrong remedy; retrying is. Matched over the
# whole exception chain (type names + stringified signatures) because
# engines wrap the original transport error.
if _is_network_failure(e):
raise RuntimeError(
f"A model download or network call failed mid-generation (usually "
f"the engine fetching its model files on first use). This is a "
f"network problem, not a memory problem — flushing VRAM won't "
f"help. Retry the generation; if it keeps failing, check your "
f"internet connection and any HF_ENDPOINT/mirror setting. "
f"Underlying error: {e}"
) from e
# #919: a required engine model path / env var that isn't set is a pure
# CONFIGURATION problem, not a runtime one. sherpa-onnx's
# "OMNIVOICE_SHERPA_MODEL not set. Point it to …" used to fall through to
# the OOM catch-all, telling a user with 63 GB of RAM to press Flush. Point
# at the real fix — set the variable — and never mention memory or Flush.
# The underlying error already names the exact variable + what to point it
# at (and Settings → Engines shows a copy-paste setup line), so keep it
# front-and-center. Checked before the OOM branch so a config error can
# never be mislabeled as memory.
if _is_config_failure(e):
raise RuntimeError(
f"This TTS engine isn't set up yet — it needs a model path or "
f"environment variable that isn't configured, so nothing was "
f"generated. Set it as the underlying error describes (it names the "
f"exact variable and what to point it at), then restart OmniVoice — "
f"or pick a ready engine in Settings → Engines. This is a setup "
f"problem, not a memory one. Underlying error: {e}"
) from e
# #880 (the class bug): the OOM hint used to be the catch-all fallback,
# so ANY unrecognized error told the user to press Flush for memory they
# never ran out of. Only claim OOM when something in the chain actually
# looks like one; everything else surfaces as what it is — unrecognized —
# with the real error front and center.
if _is_oom_failure(e):
raise RuntimeError(
f"TTS engine stopped mid-generation. This usually means it ran out of memory. "
f"Try the Flush button to reload the model, then regenerate. Underlying error: {e}"
) from e
raise RuntimeError(
f"TTS engine stopped mid-generation. This usually means it ran out of memory. "
f"Try the Flush button to reload the model, then regenerate. Underlying error: {e}"
)
f"TTS engine stopped mid-generation with an error OmniVoice doesn't "
f"recognize. Retry once; if it keeps failing, please report it with "
f"the full trace. Underlying error: {_safe_exc_text(e)}"
) from e
def _run_inference(
@@ -757,7 +956,7 @@ async def generate_speech(
status_code=500,
detail=(
f"Couldn't synthesize audio. See Settings → Logs → Backend for the full trace. "
f"Underlying error: {e}"
f"Underlying error: {_safe_exc_text(e)}"
),
)
finally:
+24 -9
View File
@@ -189,15 +189,21 @@ def auto_extract(project_id: str, req: AutoExtractRequest):
Writes them as `auto=1` rows. Existing terms with the same (source,target)
are NOT duplicated. Returns the full current glossary after the pass.
"""
from services.translator import _llm_client, _llm_model, _llm_timeout # reuse same client
# Resolved through the LLM Skills registry so auto-extract can be toggled
# or routed to its own provider (Settings → LLM Skills) independently of
# the translation pipeline. None == disabled or no provider configured.
from services import llm_skills
client = _llm_client()
if client is None:
handle = llm_skills.resolve_skill_client("glossary_extract")
if handle is None:
raise HTTPException(
status_code=503,
detail=(
"Auto-extract needs an LLM. Set TRANSLATE_BASE_URL + TRANSLATE_API_KEY "
"(Ollama works locally: base_url=http://localhost:11434/v1) and try again."
"Auto-extract needs an LLM. Set one up in Settings → LLM Providers "
"(pick a provider, add its key, choose a model, Test) — or use local "
"Ollama / LM Studio for a fully offline setup — and make sure the "
"Glossary auto-extract skill is enabled in Settings → LLM Skills, "
"then try again."
),
)
@@ -220,9 +226,9 @@ def auto_extract(project_id: str, req: AutoExtractRequest):
)
try:
res = client.chat.completions.create(
model=_llm_model(),
timeout=_llm_timeout(),
res = handle.client.chat.completions.create(
model=handle.model,
timeout=handle.timeout,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
@@ -231,9 +237,18 @@ def auto_extract(project_id: str, req: AutoExtractRequest):
body = (res.choices[0].message.content or "").strip()
except Exception as e:
logger.warning("auto-extract LLM call failed: %s", e)
# Scrub the provider error — some OpenAI-compatible providers echo the
# API key or a user_id in the body, which must not reach the UI verbatim.
from core.scrub import scrub_provider_error
from services import llm_providers
_p = llm_providers.active_provider()
_key = llm_providers.resolve_api_key(_p) if _p else None
raise HTTPException(
status_code=502,
detail=f"LLM didn't respond. Check Settings → Logs → Backend for the trace. Error: {e}",
detail=(
"LLM didn't respond. Check Settings → Logs → Backend for the trace. "
f"Error: {scrub_provider_error(e, _key)}"
),
)
# Parse: SOURCE || TARGET || note (lines are allowed to be sloppy — we're forgiving).
+4 -4
View File
@@ -237,10 +237,10 @@ def _run_tts(backend, text: str, kw: dict):
sr = backend.sample_rate
# Engines that already emit mastered, studio-grade audio (e.g. VoxCPM2's
# native 48 kHz) opt out of apply_mastering via `applies_own_mastering`.
# That chain's Compressor + 8% Reverb is tuned for OmniVoice's 24 kHz clone
# output; applied to a studio engine it adds an audible level pump and a
# reverb tail that degrade the very output we want clean. Loudness
# normalisation still runs — it's a benign peak scale, not dynamics.
# That chain's highpass + Compressor is tuned for OmniVoice's 24 kHz clone
# output; applied to a studio engine it adds an audible level pump that
# degrades the very output we want clean. Loudness normalisation still
# runs — it's a benign peak scale, not dynamics.
if not getattr(backend, "applies_own_mastering", False):
wav = apply_mastering(wav, sample_rate=sr)
wav = normalize_audio(wav, target_dBFS=-2.0)
+11
View File
@@ -73,6 +73,17 @@ async def create_profile(
raise ValueError("not an object")
except ValueError:
raise HTTPException(status_code=422, detail="vd_states must be a JSON object")
# Root-cause close for #983: a design profile must never be PERSISTED
# with a partial vd_states shape, regardless of which client (older
# frontend build, hand-edited payload, third-party API caller) created
# it — a missing category key crashes DesignMethodPanel's render on
# every future client that selects this profile. CATEGORY_ORDER is the
# same single source of truth the frontend's CATEGORIES keys mirror
# (core/describe_voice.py), so this can't drift from the picker.
from core.describe_voice import CATEGORY_ORDER
for _cat in CATEGORY_ORDER:
parsed.setdefault(_cat, "Auto")
vd_states = _json.dumps(parsed)
# An all-Auto design (every category left on "Auto") yields an empty
# instruct — that's still a valid, saveable voice: synthesis falls back
# to neutral instruct-only conditioning (see generation.py design path).
+310 -16
View File
@@ -12,6 +12,7 @@ The state endpoint duplicates `/system/hf-token/state` (which lives on
"""
from __future__ import annotations
import asyncio
import logging
import os
from dataclasses import asdict
@@ -134,12 +135,22 @@ class _RefinementBody(BaseModel):
def _refinement_state():
from services.refinement import get_refinement_config
from services.llm_backend import get_active_llm_backend
from services.refinement import (
_skill_llm,
get_last_refine_status,
get_refinement_config,
)
cfg = get_refinement_config()
# The UI shows whether refinement can actually run (needs an LLM).
cfg["llm_ready"] = get_active_llm_backend().id != "off"
# `llm_ready` only means "an endpoint is CONFIGURED" — a placeholder/dead
# endpoint still reads ready. It's resolved through the LLM Skills registry
# so a disabled dictation_refinement skill / per-skill provider override
# reads the same here as on the actual refine path. The honesty layer is
# `last_refine_status`: {ok, reason, at} from the most recent final, so the
# panel can flag a configured-but-failing LLM (the real safety is the hard
# refine timeout, which keeps a dead endpoint from ever stalling the final).
cfg["llm_ready"] = _skill_llm().id != "off"
cfg["last_refine_status"] = get_last_refine_status()
return cfg
@@ -271,7 +282,8 @@ def save_llm_provider(provider_id: str, body: _LLMProviderBody):
A None field is left unchanged; an empty api_key clears the stored key.
"""
from services import llm_providers
if llm_providers.get_provider(provider_id) is None:
p = llm_providers.get_provider(provider_id)
if p is None:
raise HTTPException(status_code=404, detail=f"unknown provider {provider_id!r}")
if body.api_key is not None:
llm_providers.save_key(provider_id, body.api_key.strip())
@@ -279,7 +291,17 @@ def save_llm_provider(provider_id: str, body: _LLMProviderBody):
provider_id, base_url=body.base_url, model=body.model,
account_id=body.account_id,
)
if body.make_active:
# An explicit save also claims the active slot when the user has never
# chosen a provider (#963). Without this, a saved-and-tested local
# provider (Ollama/LM Studio) evaporates on restart: active_provider_id()
# deliberately excludes local providers from auto-select, so the plain
# "Save" left nothing persisted to resolve. Gated on the STORED selection
# only — an explicit prior choice is never stolen by a plain save, and an
# unconfigured provider can't claim the slot.
if body.make_active or (
llm_providers.stored_active_provider_id() is None
and llm_providers.is_configured(p)
):
llm_providers.set_active_provider(provider_id)
return list_llm_providers()
@@ -293,13 +315,58 @@ def set_active_llm_provider(body: _LLMActiveBody):
return list_llm_providers()
def _scrub_llm_detail(e: Exception, api_key: str | None) -> str:
"""Scrubbed, UI-safe failure text. scrub_text() covers env secrets and
home paths but a STORE-persisted key isn't in the env, and some
providers echo the key in error bodies, so redact the exact resolved key
explicitly before the generic pass."""
from core.scrub import scrub_text
detail = f"{type(e).__name__}: {e}"
if api_key and api_key != "local" and len(api_key) >= 8:
detail = detail.replace(api_key, "•••")
return scrub_text(detail)
def _classify_llm_error(e: Exception) -> str:
"""Map a provider-call failure to an actionable kind the UI can localize.
Kinds: auth (bad/missing key), not_found (model or endpoint path),
rate_limit, network (DNS/conn/timeout), error (everything else).
Status codes win when the OpenAI SDK provides one; exception-family
names catch the non-HTTP failures (DNS, refused, TLS, timeout).
"""
status = getattr(e, "status_code", None)
if status in (401, 403):
return "auth"
if status == 404:
return "not_found"
if status == 429:
return "rate_limit"
name = type(e).__name__
if name in ("APIConnectionError", "APITimeoutError", "ConnectError",
"ConnectTimeout", "TimeoutError"):
return "network"
if name == "AuthenticationError":
return "auth"
if name == "NotFoundError":
return "not_found"
if name == "RateLimitError":
return "rate_limit"
return "error"
@router.post("/llm-providers/{provider_id}/test")
def test_llm_provider(provider_id: str):
"""One cheap round-trip against a provider to prove the key/URL work.
Temporarily activates the provider for the probe by resolving its config
directly (does not change the persisted active selection).
directly (does not change the persisted active selection). Returns
latency_ms plus, on failure, a classified ``kind`` (config / auth /
not_found / rate_limit / network / error) so the UI shows an actionable,
localizable message instead of a raw exception string.
"""
import time as _time
from services import llm_providers
p = llm_providers.get_provider(provider_id)
if p is None:
@@ -307,22 +374,117 @@ def test_llm_provider(provider_id: str):
base_url = llm_providers.resolve_base_url(p)
api_key = llm_providers.resolve_api_key(p)
if not base_url:
return {"ok": False, "detail": "No Base URL set for this provider."}
return {"ok": False, "kind": "config", "detail": "No Base URL set for this provider."}
if not api_key:
return {"ok": False, "detail": "No API key configured for this provider."}
return {"ok": False, "kind": "config", "detail": "No API key configured for this provider."}
t0 = _time.monotonic()
try:
from openai import OpenAI
client = OpenAI(api_key=api_key, base_url=base_url)
# max_retries=0: this is an interactive probe with a live spinner — the
# SDK's default 2 automatic retries turn a 429/timeout into a ~34s hang.
# Surface the first failure immediately instead.
client = OpenAI(api_key=api_key, base_url=base_url, max_retries=0)
res = client.chat.completions.create(
model=llm_providers.resolve_model(p),
messages=[{"role": "user", "content": "Reply with the single word: ok"}],
timeout=20,
)
reply = (res.choices[0].message.content or "").strip()
return {"ok": True, "model": llm_providers.resolve_model(p), "reply": reply[:80]}
return {
"ok": True,
"model": llm_providers.resolve_model(p),
"reply": reply[:80],
"latency_ms": int((_time.monotonic() - t0) * 1000),
}
except Exception as e: # noqa: BLE001 — surface a clean, scrubbed error to the UI
from core.scrub import scrub_text
return {"ok": False, "detail": scrub_text(f"{type(e).__name__}: {e}")}
return {
"ok": False,
"kind": _classify_llm_error(e),
"detail": _scrub_llm_detail(e, api_key),
"latency_ms": int((_time.monotonic() - t0) * 1000),
}
@router.get("/llm-providers/{provider_id}/models")
def list_llm_provider_models(provider_id: str):
"""List model ids the provider's key can access (OpenAI-compat /models).
Powers the model-picker datalist in Settings LLM Providers so users
don't have to guess model names. Read-only; failures return the same
classified shape as /test; capped so a huge catalog can't bloat the UI.
"""
from services import llm_providers
p = llm_providers.get_provider(provider_id)
if p is None:
raise HTTPException(status_code=404, detail=f"unknown provider {provider_id!r}")
base_url = llm_providers.resolve_base_url(p)
api_key = llm_providers.resolve_api_key(p)
if not base_url or not api_key:
return {"ok": False, "kind": "config", "models": []}
try:
from openai import OpenAI
# max_retries=0: interactive probe — fail fast, don't burn ~34s on the
# SDK's default retry ladder when the key/URL is wrong (matches /test).
client = OpenAI(api_key=api_key, base_url=base_url, max_retries=0)
ids = sorted(m.id for m in client.models.list(timeout=10))
# Cap so a huge catalog can't bloat the datalist; flag the cap so the UI
# can say "first 200 shown" rather than implying it's the full list.
return {"ok": True, "models": ids[:200], "truncated": len(ids) > 200}
except Exception as e: # noqa: BLE001
return {
"ok": False,
"kind": _classify_llm_error(e),
"detail": _scrub_llm_detail(e, api_key),
"models": [],
}
# ── LLM Skills (Settings → LLM Skills) ─────────────────────────────────────
# Per-feature enable/route control for every LLM consumption point. Each
# skill can be toggled off (degrades exactly like "no LLM configured") or
# routed to a specific provider (local Ollama/LM Studio vs a remote key)
# instead of the one global active provider. Loopback-gated (router dep).
class _LLMSkillBody(BaseModel):
enabled: bool | None = Field(None, description="None leaves the toggle unchanged")
provider_override: str | None = Field(
None,
description="provider id to route this skill to; '' or null clears "
"it (skill follows the active provider). Omit to leave "
"unchanged.",
)
@router.get("/llm-skills")
def list_llm_skills():
"""Every LLM skill with its toggle, routing, and resolved ready status."""
from services import llm_skills
return {"skills": [llm_skills.describe(s.id) for s in llm_skills.all_skills()]}
@router.put("/llm-skills/{skill_id}")
def set_llm_skill(skill_id: str, body: _LLMSkillBody):
"""Toggle a skill and/or set its provider routing.
Field semantics match the providers PUT: an omitted field is left
unchanged; ``provider_override: ""``/``null`` clears the override.
404 for an unknown skill or an unknown provider id.
"""
from services import llm_skills
if llm_skills.get_skill(skill_id) is None:
raise HTTPException(status_code=404, detail=f"unknown LLM skill {skill_id!r}")
kwargs = {}
if body.enabled is not None:
kwargs["enabled"] = body.enabled
if "provider_override" in body.model_fields_set:
kwargs["provider_override"] = body.provider_override
try:
if kwargs:
llm_skills.configure_skill(skill_id, **kwargs)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return list_llm_skills()
# ── License acceptance (Phase 3 Plan 03-01 / TTS-05) ──────────────────────
@@ -488,6 +650,41 @@ def set_models_dir(body: _ModelsDirBody):
return {"configured": path, "effective": _effective_models_dir(), "restart_required": True}
# ── Storage report (Settings → Storage) ────────────────────────────────────
# Per-volume disk totals + du-style sizes for everything the app owns (HF
# model cache, app data subtotals, engine venvs, temp files) with server-side
# warnings. Heavy directory walks run in a worker thread with per-category
# deadlines and a 5-minute in-process cache (services.storage_report), so the
# endpoint stays cheap on repeat Settings visits. Loopback-gated via the
# router-level dep like every sibling.
@router.get("/storage")
async def get_storage_report(refresh: bool = Query(False)):
"""Disk + per-category storage usage for the Settings → Storage panel.
`refresh=1` bypasses the 5-minute cache and rescans. `min_free_gb`
reuses the setup wizard's constant so both surfaces warn at the same
threshold.
"""
from api.routers.setup.wizard import MIN_FREE_GB
from core.config import DATA_DIR
from services import storage_report
try:
return await asyncio.to_thread(
storage_report.get_report,
data_dir=DATA_DIR,
hf_cache_dir=_effective_models_dir(),
app_venv=storage_report.default_app_venv(),
min_free_gb=MIN_FREE_GB,
refresh=refresh,
)
except Exception:
logger.exception("storage report failed")
raise HTTPException(status_code=500, detail="Failed to compute storage report")
# ── HF mirror endpoint (parity program Wave 4.3 / §R4 c) ──────────────────
# Restricted-network users (e.g. behind the Great Firewall) need to point
# huggingface_hub at a mirror. HF reads HF_ENDPOINT at import time, so a
@@ -530,6 +727,11 @@ def set_hf_mirror(body: _HFMirrorBody):
url = (body.url or "").strip().rstrip("/")
if url and not url.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="Mirror URL must start with http(s)://")
# Compare against the currently-persisted value (normalised the same way) so
# a no-op save doesn't nag the user to restart. Only a real change to the
# persisted endpoint can require a restart.
previous = (user_env.get_user_env(_HF_ENDPOINT_ENV) or "").strip().rstrip("/")
changed = url != previous
try:
if url:
user_env.set_user_env(_HF_ENDPOINT_ENV, url)
@@ -540,6 +742,98 @@ def set_hf_mirror(body: _HFMirrorBody):
except Exception:
logger.exception("set_hf_mirror failed")
raise HTTPException(status_code=500, detail="Failed to persist mirror setting")
# HF endpoint is read at import time by huggingface_hub, so the override
# is only guaranteed once the backend restarts.
return {"configured": url, "restart_required": True, "presets": _HF_MIRROR_PRESETS}
# Model Store downloads pick up the new mirror immediately — the download
# path resolves the endpoint per-call and we updated os.environ above. Only
# transformers-side model *loads* (which read HF_ENDPOINT at import time)
# need a restart, so restart_required is True ONLY when the value actually
# changed — a no-op re-save never asks for a restart.
return {"configured": url, "restart_required": changed, "presets": _HF_MIRROR_PRESETS}
# ── OpenAI-compatible remote ASR (#877) ─────────────────────────────────────
# A path to Qwen3-ASR/FunASR/SenseVoice — or OpenAI's own Whisper API — today,
# without waiting on transformers to ship a direct Qwen3-ASR integration.
# base_url/model are plain settings_store text rows; the key is encrypted via
# settings_store.set_secret — same convention as /llm-providers, never
# returned to the client, '' clears it, omitted/None leaves it unchanged.
class _ASROpenAICompatBody(BaseModel):
base_url: str | None = None
model: str | None = None
api_key: str | None = Field(None, description="'' clears it, None leaves unchanged")
@router.get("/asr-openai-compat")
def get_asr_openai_compat():
from services import asr_backend
return {
"base_url": asr_backend.resolve_openai_compat_asr_base_url(),
"model": asr_backend.resolve_openai_compat_asr_model(),
"has_key": asr_backend.openai_compat_asr_has_key(),
}
@router.put("/asr-openai-compat")
def set_asr_openai_compat(body: _ASROpenAICompatBody):
from services import asr_backend, settings_store
if body.base_url is not None:
url = body.base_url.strip().rstrip("/")
if url and not url.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="Base URL must start with http(s)://")
settings_store.set_text(asr_backend._ASR_OPENAI_COMPAT_BASE_URL_KEY, url)
if body.model is not None:
settings_store.set_text(
asr_backend._ASR_OPENAI_COMPAT_MODEL_KEY, body.model.strip() or "whisper-1"
)
if body.api_key is not None:
settings_store.set_secret(
asr_backend._ASR_OPENAI_COMPAT_SECRET_NAME, body.api_key.strip()
)
return get_asr_openai_compat()
# ── Updates panel: shipped changelog + pre-migration DB backup state ────────
# (feat/safe-updates). Both are read-only, local-first surfaces for
# Settings → Updates: the "What's new" viewer reads the CHANGELOG.md that
# ships with the app, and the backup line shows the newest pre-migration
# snapshot written by core.db_backup before `alembic upgrade head` runs.
@router.get("/changelog")
def get_changelog(limit_versions: int = Query(5, ge=1, le=50)):
"""Structured release notes from the shipped CHANGELOG.md (newest first).
Bullets are raw markdown-lite (bold leads, `code`, (#NNN) refs) — the
frontend renders them safely without HTML. `available: false` when this
install has no changelog (never an error: the viewer just hides)."""
from core import changelog
path = changelog.changelog_path()
if not path:
return {"available": False, "releases": []}
try:
with open(path, encoding="utf-8") as fh:
releases = changelog.parse_changelog(fh.read(), limit_versions)
except Exception:
logger.exception("changelog parse failed")
return {"available": False, "releases": []}
return {"available": bool(releases), "releases": releases}
@router.get("/db-backup")
def get_db_backup_state():
"""Newest pre-migration database backup (or none yet). Feeds the
"your data is backed up before every update" line in Settings Updates."""
from core import db_backup
from core.config import DB_PATH
latest = db_backup.latest_backup(DB_PATH)
return {
"available": latest is not None,
"latest": latest,
"count": len(db_backup.list_backups(DB_PATH)),
"keep": db_backup.KEEP_BACKUPS,
}
+28 -1
View File
@@ -29,6 +29,7 @@ from .models import ( # noqa: F401
KNOWN_MODELS,
invalidate_cache,
snapshot_has_weights,
disk_space_error,
_MIN_WEIGHT_BYTES,
_WEIGHT_FLOORS,
)
@@ -404,6 +405,26 @@ async def install_model(req: InstallModelRequest):
try:
_plan = snapshot_download(**_preflight_kwargs)
_summary = compute_plan(_plan)
# Disk-space guard (before a single byte flows): the preflight
# gives an exact "to download" size, so reject an install that
# would overrun the cache volume — with the numbers named —
# instead of failing mid-download with a cryptic OSError. No-op
# when it fits or the size is unknown. Same on every platform.
_disk_err = disk_space_error(_summary["to_download_bytes"])
if _disk_err:
logger.info("model install %s: rejected — %s", req.repo_id, _disk_err)
_resolving.set() # stop the heartbeat thread before we bail
hf_progress.emit({
"repo_id": req.repo_id,
"filename": req.repo_id,
"downloaded": 0, "total": 0, "pct": 0.0,
"phase": "install_error",
"error": _disk_err,
})
# A disk-full is not a transient network failure — don't set
# a cooldown (freeing space, not waiting, is the fix). The
# outer finally still cleans up the aggregator + context.
return
download_aggregator.start(
req.repo_id,
total_bytes=_summary["to_download_bytes"],
@@ -507,12 +528,18 @@ async def install_model(req: InstallModelRequest):
logger.info("model install failed for %s: %s", req.repo_id, e)
import time as _time_fail
_install_cooldowns[req.repo_id] = _time_fail.time()
# #874: when the install failed because the configured HF mirror is
# unreachable, name the mirror + the setting instead of leaking the
# raw connectivity error. #959: likewise for the SOCKS-proxy class
# (missing socksio fails the download's session construction).
# No-op for every other failure.
from core.failure import append_hint
hf_progress.emit({
"repo_id": req.repo_id,
"filename": req.repo_id,
"downloaded": 0, "total": 0, "pct": 0.0,
"phase": "install_error",
"error": str(e),
"error": append_hint(str(e)),
})
finally:
_cancelled.discard(req.repo_id)
+64
View File
@@ -123,6 +123,66 @@ def hf_cache_dir() -> str:
)
# ── Disk-space guard (shared, single-sourced) ──────────────────────────────
# MIN_FREE_GB is the headroom we insist on keeping free on the model-cache
# volume — the wizard's absolute pre-install floor AND the extra buffer the
# per-install check demands on top of the download itself, so an "Install all"
# can't fill the disk to the brim (setup/download.py). Lives here — the lowest
# module in the setup import graph — so the wizard, the /models header, and the
# install endpoint can't drift apart (mirrors the weight-floor single-sourcing).
_GIB = 1024 ** 3
MIN_FREE_GB = 10
def disk_free_bytes(path: "str | None" = None) -> int:
"""Free bytes on the volume backing *path* (defaults to the HF cache).
Walks up to the nearest existing ancestor so a not-yet-created cache dir
still probes the correct mount point. ``shutil.disk_usage`` is cross-platform
(macOS/Windows/Linux) so this behaves identically everywhere. Never raises.
"""
import shutil
try:
p = Path(path or hf_cache_dir()).resolve()
while not p.exists():
parent = p.parent
if parent == p: # reached the volume root
break
p = parent
return int(shutil.disk_usage(str(p)).free)
except Exception:
return 0
def disk_space_error(to_download_bytes: "int | None", *, cache_dir: "str | None" = None) -> "str | None":
"""Actionable message when *to_download_bytes* (+ MIN_FREE_GB headroom) won't
fit on the cache volume; ``None`` when it fits, the size is unknown, or the
volume can't be probed (never block on missing information).
Names the three numbers a user needs to act needs X, headroom Y, have Z
so "Install all" can't silently overrun the disk (issue: no pre-install disk
check). Platform-agnostic; applied identically on macOS/Windows/Linux.
"""
if not to_download_bytes or to_download_bytes <= 0:
return None # unknown plan (older/gated repo, mirror without dry-run) → don't block
cache = cache_dir or hf_cache_dir()
free = disk_free_bytes(cache)
if free <= 0:
return None # couldn't probe the volume → don't block on missing info
required = int(to_download_bytes) + MIN_FREE_GB * _GIB
if free >= required:
return None
def _gb(n: int) -> str:
return f"{n / _GIB:.1f} GB"
return (
f"Not enough disk space to install: this download needs {_gb(int(to_download_bytes))} "
f"plus {MIN_FREE_GB} GB free headroom ({_gb(required)} total), but only {_gb(free)} "
f"is free at {cache}. Free up space (or move the model cache to a bigger volume) and retry."
)
def _repo_dir_name(repo_id: str) -> str:
"""HF cache dir name for a repo: 'k2-fsa/OmniVoice''models--k2-fsa--OmniVoice'."""
return "models--" + repo_id.replace("/", "--")
@@ -391,6 +451,10 @@ def list_models():
"models": out,
"total_installed_bytes": sum(m["size_on_disk_bytes"] for m in out),
"hf_cache_dir": hf_cache_dir(),
# Free space on the cache volume, so the Model Store header can warn
# BEFORE an "Install all" overruns the disk (pairs with the per-install
# disk_space_error guard in setup/download.py).
"disk_free_gb": round(disk_free_bytes() / _GIB, 1),
"platform_tags": _current_platform_tags(),
}
_set_cache("models", response)
+76 -31
View File
@@ -18,33 +18,20 @@ import sys
from fastapi import APIRouter
from api.schemas import SetupStatusResponse, PreflightResponse
from .models import REQUIRED_MODELS, hf_cache_dir, is_cached
# 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.
from .models import REQUIRED_MODELS, hf_cache_dir, is_cached, MIN_FREE_GB, disk_free_bytes
logger = logging.getLogger("omnivoice.setup.wizard")
router = APIRouter()
MIN_FREE_GB = 10
def _disk_free_gb(path: str) -> float:
"""Return free GB on the volume containing *path*.
If *path* doesn't exist yet (e.g. after a fresh wipe), walk up to the
nearest existing ancestor so ``shutil.disk_usage`` can still probe the
correct mount point.
"""
try:
from pathlib import Path
p = Path(path).resolve()
# Walk up until we find a directory that exists
while not p.exists():
parent = p.parent
if parent == p: # root
break
p = parent
return _shutil.disk_usage(str(p)).free / (1024 ** 3)
except Exception:
return 0.0
"""Free GB on the volume containing *path* (thin GB wrapper over the shared
``models.disk_free_bytes``, which walks up to the nearest existing ancestor
for a not-yet-created path)."""
return disk_free_bytes(path) / (1024 ** 3)
# ── Setup Status ───────────────────────────────────────────────────────────
@@ -181,16 +168,40 @@ def _detect_gpu() -> dict:
return info
def _probe_network(host: str = "huggingface.co", timeout: float = 2.0) -> bool:
def _probe_network(host: str = "huggingface.co", port: int = 443, timeout: float = 2.0) -> bool:
"""Tiny TCP connect test."""
import socket
try:
with socket.create_connection((host, 443), timeout=timeout):
with socket.create_connection((host, port), timeout=timeout):
return True
except Exception:
return False
def _hf_endpoint_host() -> tuple[str, int]:
"""Host/port of the Hugging Face endpoint actually in effect.
Mirror-aware: restricted-network users (e.g. behind the Great Firewall)
point HF_ENDPOINT at a mirror via Settings Models Hugging Face
mirror. Probing hardcoded huggingface.co would fail them even when their
configured mirror works fine.
"""
try:
from core.failure import configured_hf_mirror
mirror = configured_hf_mirror()
except Exception:
mirror = ""
if mirror:
try:
from urllib.parse import urlsplit
u = urlsplit(mirror)
if u.hostname:
return u.hostname, u.port or (80 if u.scheme == "http" else 443)
except Exception:
pass
return "huggingface.co", 443
def _ram_gb() -> float:
try:
import psutil
@@ -413,15 +424,49 @@ def preflight():
"status": r_status, "detail": r_detail, "fix": r_fix,
})
# ── Network
net_ok = _probe_network()
# ── Network — probes the HF endpoint actually in effect (mirror-aware),
# and a dead network is a WARNING, not a blocker. The app is local-first:
# already-downloaded models work offline, and a hard fail here dead-ends
# restricted-network users (e.g. China, where huggingface.co is blocked)
# on the very first screen — before they can reach the mirror setting
# that fixes it. Model downloads surface their own actionable errors.
net_host, net_port = _hf_endpoint_host()
net_ok = _probe_network(net_host, net_port)
mirror_reachable = False
if not net_ok and net_host == "huggingface.co":
# Official endpoint blocked — if the community mirror is reachable,
# tell the user exactly which switch unblocks them.
mirror_reachable = _probe_network("hf-mirror.com")
if net_ok:
net_fix = None
elif mirror_reachable:
net_fix = (
"huggingface.co is blocked on this network, but the hf-mirror.com "
"community mirror is reachable — apply it below and re-check. "
"Model downloads will use the mirror immediately."
)
elif net_host != "huggingface.co":
net_fix = (
f"Your configured Hugging Face mirror ({net_host}) is unreachable "
"— it may be down or blocked. Pick another mirror or the official "
"endpoint below, or continue offline: models already downloaded "
"keep working."
)
else:
net_fix = (
"Check internet connection, VPN, or corporate firewall whitelist "
"for huggingface.co. You can continue — models already downloaded "
"keep working offline; new downloads need a connection or a "
"mirror (configurable below)."
)
checks.append({
"id": "network", "label": "Network (huggingface.co)",
"status": "pass" if net_ok else "fail",
"detail": "Reachable" if net_ok else "Unreachable on port 443",
"fix": None if net_ok else
"Check internet connection, VPN, or corporate firewall "
"whitelist for huggingface.co.",
"id": "network", "label": f"Network ({net_host})",
"status": "pass" if net_ok else "warn",
"detail": "Reachable" if net_ok else f"Unreachable on port {net_port}",
"fix": net_fix,
# Frontend affordance hint: the wizard offers the mirror quick-pick
# when the endpoint is unreachable (PreflightCheck allows extras).
"mirror_reachable": mirror_reachable,
})
# Aggregate
+5
View File
@@ -77,6 +77,10 @@ async def probe(req: ProbeReq):
class IncrementalReq(BaseModel):
segments: list[dict]
stored_hashes: Optional[dict[str, str]] = None
# P1.3 — the ACTIVE track's language code. When set, fingerprints are
# scoped to that language (pass that language's stored hashes alongside);
# omitted → legacy language-agnostic hashing, kept for old callers.
lang: Optional[str] = None
@router.post("/tools/incremental")
@@ -84,6 +88,7 @@ def plan_incremental(req: IncrementalReq):
return incremental.plan_incremental(
req.segments,
stored_hashes=req.stored_hashes or {},
track_lang=req.lang,
)
+138
View File
@@ -0,0 +1,138 @@
"""Parse the shipped CHANGELOG.md into structured release notes.
Feeds ``GET /api/settings/changelog`` the Settings Updates "What's new"
viewer. Local-first by design: the changelog ships with the app (repo root in
dev; copied into the packaged project dir by the Tauri bootstrap alongside
README.md), so the viewer works fully offline.
The house format (see CHANGELOG.md / the release-notes hard rule):
## [X.Y.Z] — DATE
one-paragraph headline (the "intro")
### Added / Fixed / Changed / ...
- **Bold one-line lead.** 1-3 lines of plain-English why. (#NNN)
Bullets may be a single long line (recent sections) *or* hard-wrapped across
indented continuation lines (older sections) the parser normalizes both to
one logical line per bullet. Bullets stay raw markdown-lite; the frontend's
safe renderer handles **bold** / `code` / (#NNN) refs.
"""
from __future__ import annotations
import os
import re
#: ``## [0.3.9] — 2026-07-02`` (em/en dash or hyphen; date optional).
_RELEASE_RE = re.compile(r"^##\s+\[(?P<version>[^\]]+)\]\s*(?:[—–-]\s*(?P<date>.+?))?\s*$")
_SECTION_RE = re.compile(r"^###\s+(?P<title>.+?)\s*$")
_BULLET_RE = re.compile(r"^\s*[-*]\s+(?P<text>.*\S)\s*$")
def changelog_path() -> str | None:
"""The shipped CHANGELOG.md, or None when this install doesn't have one.
``backend/core/changelog.py`` two levels up is the project root: the
repo root in dev, and ``<env>/project`` in packaged installs (where the
bootstrap copies CHANGELOG.md next to README.md). ``OMNIVOICE_CHANGELOG``
overrides for tests/containers.
"""
override = os.environ.get("OMNIVOICE_CHANGELOG")
if override:
return override if os.path.isfile(override) else None
here = os.path.dirname(os.path.abspath(__file__))
candidate = os.path.join(os.path.dirname(os.path.dirname(here)), "CHANGELOG.md")
return candidate if os.path.isfile(candidate) else None
def _looks_like_release_version(version: str) -> bool:
"""Only released ``X.Y.Z...`` sections (skip ``[Unreleased]`` etc.)."""
return bool(re.match(r"^v?\d", version.strip()))
def parse_changelog(text: str, limit_versions: int = 5) -> list[dict]:
"""CHANGELOG.md text → newest-first list of releases::
{"version": "0.3.9", "date": "2026-07-02", "intro": "",
"sections": [{"title": "Fixed", "bullets": ["", ]}, ]}
Tolerates both single-line bullets and older hard-wrapped bullets
(continuation lines are joined with a space). Content between the version
heading and the first ``###`` becomes ``intro`` (paragraphs joined by
blank lines).
"""
releases: list[dict] = []
release: dict | None = None
section: dict | None = None
intro_parts: list[str] = []
bullet_open = False # last bullet may still absorb continuation lines
intro_new_para = True
def close_release():
nonlocal release, section, intro_parts, bullet_open, intro_new_para
if release is not None:
release["intro"] = "\n\n".join(p for p in intro_parts if p)
release["sections"] = [s for s in release["sections"] if s["bullets"]]
releases.append(release)
release = None
section = None
intro_parts = []
bullet_open = False
intro_new_para = True
for raw in text.splitlines():
m = _RELEASE_RE.match(raw)
if m:
close_release()
if len(releases) >= limit_versions:
break
version = m.group("version").strip().lstrip("v")
if not _looks_like_release_version(version):
continue # e.g. [Unreleased] — skip until the next heading
release = {
"version": version,
"date": (m.group("date") or "").strip(),
"intro": "",
"sections": [],
}
continue
if release is None:
continue
line = raw.strip()
if not line:
bullet_open = False
intro_new_para = True
continue
sm = _SECTION_RE.match(raw)
if sm:
section = {"title": sm.group("title"), "bullets": []}
release["sections"].append(section)
bullet_open = False
continue
bm = _BULLET_RE.match(raw)
if bm:
if section is None:
# Rare: a bullet before any ### heading — group it untitled.
section = {"title": "", "bullets": []}
release["sections"].append(section)
section["bullets"].append(bm.group("text"))
bullet_open = True
continue
if section is not None:
if bullet_open and section["bullets"]:
# Hard-wrapped bullet continuation (older sections) → join.
section["bullets"][-1] += " " + line
continue
# Headline paragraph(s) before the first ### section.
if intro_new_para or not intro_parts:
intro_parts.append(line)
else:
intro_parts[-1] += " " + line
intro_new_para = False
close_release()
return releases[:limit_versions]
+140 -20
View File
@@ -3,6 +3,8 @@ import sqlite3
import logging
from contextlib import contextmanager
from core.config import DB_PATH
from core import db_backup
from core.version import APP_VERSION
logger = logging.getLogger("omnivoice.db")
@@ -312,14 +314,88 @@ def init_db():
_run_alembic_upgrade()
class MigrationError(RuntimeError):
"""A schema migration failed *while executing*. Startup must NOT continue
on a possibly half-migrated database the caller lets this propagate so
the process stops with an actionable message naming the pre-migration
backup (see ``core.db_backup``). Restore is deliberately manual: silently
auto-restoring the snapshot could itself discard user data."""
def _reconcile_after_alembic_skip() -> None:
"""Converge the schema directly when alembic can't run at all (not
importable, or stamped at a removed revision #552/#547) so additive
columns still land instead of 500-ing on `no such column`. Only for the
"nothing was applied" classes; a mid-migration failure must NOT reach
here (see MigrationError)."""
try:
conn = get_db()
try:
_reconcile_additive_columns(conn)
finally:
conn.close()
except Exception as exc: # noqa: BLE001
logger.warning("schema reconcile after alembic skip also failed: %s", exc)
def _stamped_revisions(db_path: str) -> set | None:
"""Revisions recorded in ``alembic_version`` (empty set = never stamped),
or None when the DB can't be read."""
try:
conn = sqlite3.connect(db_path)
try:
try:
return {r[0] for r in conn.execute("SELECT version_num FROM alembic_version")}
except sqlite3.OperationalError:
return set() # table absent — nothing ever stamped
finally:
conn.close()
except Exception: # noqa: BLE001
return None
def _plan_alembic(cfg) -> str:
"""Decide what an ``upgrade head`` run would actually do:
- ``up_to_date`` stamped at head; upgrade is a no-op.
- ``pending`` migrations WILL execute (snapshot the DB first).
- ``unknown_revision`` stamped at a revision this build doesn't ship
(previewstable downgrade, #552/#547); upgrade would fail before
applying anything, so skip it and reconcile additively instead.
- ``indeterminate`` can't tell; treat like pending (snapshot, run).
"""
try:
from alembic.script import ScriptDirectory
script = ScriptDirectory.from_config(cfg)
known = {rev.revision for rev in script.walk_revisions()}
heads = set(script.get_heads())
stamped = _stamped_revisions(DB_PATH)
if stamped is None:
return "indeterminate"
if stamped and not stamped <= known:
return "unknown_revision"
if stamped == heads:
return "up_to_date"
return "pending"
except Exception: # noqa: BLE001
return "indeterminate"
def _run_alembic_upgrade() -> None:
"""Best-effort `alembic upgrade head` on startup. Non-fatal: if alembic
isn't reachable (e.g. a stripped-down install) or its version is stamped at
a revision no longer in versions/ (e.g. after running a preview build), log
a warning and move on. The schema is still kept correct by
_reconcile_additive_columns (run in init_db above and again here on failure)
CREATE TABLE IF NOT EXISTS alone does NOT add columns to a pre-existing
table, so the reconcile is what actually guarantees additive columns land."""
"""`alembic upgrade head` on startup, wrapped in the data-safety net.
Failure classes are handled differently on purpose:
- alembic unavailable / stamped at an unknown revision **non-fatal**
(nothing was applied; warn + `_reconcile_additive_columns` keeps the
schema converged, exactly the pre-existing #552/#547 behavior).
- migrations actually pending the DB is snapshotted first
(``omnivoice.db.backup-<version>-<n>``, newest 3 kept), then upgraded.
- a migration fails **while executing** raise :class:`MigrationError`:
startup stops with a message naming the backup, instead of silently
running the app on a half-migrated DB.
"""
try:
import os
from alembic import command
@@ -335,18 +411,62 @@ def _run_alembic_upgrade() -> None:
return
cfg = Config(ini)
cfg.set_main_option("sqlalchemy.url", f"sqlite:///{DB_PATH}")
except Exception as exc: # noqa: BLE001 — alembic not importable / bad ini
logger.warning("alembic upgrade head skipped: %s", exc)
_reconcile_after_alembic_skip()
return
plan = _plan_alembic(cfg)
if plan == "up_to_date":
return
if plan == "unknown_revision":
logger.warning(
"alembic_version is stamped at a revision this build doesn't ship "
"(preview/newer build ran on this DB) — skipping alembic and "
"reconciling the schema additively (#552/#547)"
)
_reconcile_after_alembic_skip()
return
# Migrations may actually execute: snapshot the DB first so a failed or
# interrupted migration can never cost user data. A backup problem alone
# must not brick startup (the >500 MB skip is by design), so log and go on.
# ``db_backup``/``APP_VERSION`` are module-level imports (top of file), not
# re-imported here: a test that patches ``core.db_backup.MAX_BACKUP_DB_BYTES``
# on the object it imported at collection must see the same object this
# function uses. A lazy ``from core import db_backup`` would re-resolve
# through the (possibly re-imported) ``core`` package and silently miss the
# patch after another suite purged ``core.*`` from ``sys.modules``.
backup_path = None
try:
backup_path = db_backup.snapshot_before_migration(DB_PATH, APP_VERSION)
except Exception: # noqa: BLE001
logger.exception("Pre-migration DB backup failed — continuing without one")
try:
command.upgrade(cfg, "head")
except Exception as exc:
# Don't block startup on a migration tooling problem. Converge the schema
# directly so a swallowed failure (alembic not importable, or
# alembic_version stamped at a removed revision) still lands the additive
# columns instead of 500-ing on `no such column` (#552/#547).
logger.warning("alembic upgrade head skipped: %s", exc)
try:
conn = get_db()
try:
_reconcile_additive_columns(conn)
finally:
conn.close()
except Exception as exc2: # noqa: BLE001
logger.warning("schema reconcile after alembic failure also failed: %s", exc2)
if "Can't locate revision" in str(exc):
# Belt for an unknown-revision case _plan_alembic missed: alembic
# bails before applying anything, so the old non-fatal path is safe.
logger.warning("alembic upgrade head skipped: %s", exc)
_reconcile_after_alembic_skip()
return
backup_note = (
f"A backup of your data from just before the migration is at: {backup_path}"
if backup_path
else "No pre-migration backup was written this run (see the log above)"
)
msg = (
f"Database migration failed while running: {exc}. "
f"OmniVoice stopped instead of running on a partially migrated database, "
f"and nothing was auto-restored (your database at {DB_PATH} was left "
f"exactly as the failed migration left it). "
f"{backup_note}. "
"What to do: relaunch to retry; if it keeps failing, report it at "
"https://github.com/debpalash/OmniVoice-Studio/issues (keep the backup file). "
"To roll back manually: quit the app, replace omnivoice.db with the backup "
"file, and reinstall the previous version."
)
logger.error(msg)
raise MigrationError(msg) from exc
+169
View File
@@ -0,0 +1,169 @@
"""Pre-migration SQLite safety net (data-safe updates).
Before ``alembic upgrade head`` applies *pending* migrations at startup
which is exactly the first launch of a new app version that changed the
schema the live database is snapshotted next to itself as
``omnivoice.db.backup-<version>-<n>`` so a failed or interrupted migration
can never cost user data (voices, projects, history, settings).
Design rules (owner intent: "never corrupt/erase user data on update"):
- Snapshots use the SQLite online-backup API (``sqlite3.Connection.backup``),
not a file copy the live DB runs in WAL mode, so a plain copy could miss
everything still sitting in ``omnivoice.db-wal``.
- Only the most recent ``KEEP_BACKUPS`` snapshots are kept; older ones are
pruned so backups can't grow without bound.
- DBs larger than ``MAX_BACKUP_DB_BYTES`` are skipped with a log line (a
multi-hundred-MB copy on every schema upgrade is worse than the risk it
hedges on those installs).
- Restore is NEVER automatic. On migration failure the caller
(``core.db._run_alembic_upgrade``) stops startup and names the backup path
so the user (or a support thread) decides a silent auto-restore could
itself discard data written after the snapshot.
"""
from __future__ import annotations
import logging
import os
import re
import sqlite3
import time
logger = logging.getLogger("omnivoice.db.backup")
#: Keep this many snapshots; older ones are pruned after each new snapshot.
KEEP_BACKUPS = 3
#: Skip the snapshot (with a log line) when the DB exceeds this size.
MAX_BACKUP_DB_BYTES = 500 * 1024 * 1024
#: ``<db name>.backup-<version>-<n>`` — ``<version>`` may itself contain
#: dashes (preview builds stamp ``0.3.9-41``), so the counter is the final
#: ``-<digits>`` group.
_BACKUP_SUFFIX_RE = re.compile(r"\.backup-(?P<version>.+)-(?P<n>\d+)$")
def _sanitize_version(version: str) -> str:
"""Version string → filesystem-safe fragment (defense in depth; real
versions are semver and already safe)."""
safe = re.sub(r"[^A-Za-z0-9._-]", "_", str(version).strip()) or "unknown"
return safe[:64]
def list_backups(db_path: str) -> list[str]:
"""All backup files for ``db_path``, newest first (mtime desc)."""
directory = os.path.dirname(os.path.abspath(db_path)) or "."
base = os.path.basename(db_path)
try:
names = os.listdir(directory)
except OSError:
return []
out = []
for name in names:
if not name.startswith(base + ".backup-"):
continue
if not _BACKUP_SUFFIX_RE.search(name[len(base):]):
continue
out.append(os.path.join(directory, name))
out.sort(key=lambda p: (_mtime(p), p), reverse=True)
return out
def _mtime(path: str) -> float:
try:
return os.path.getmtime(path)
except OSError:
return 0.0
def latest_backup(db_path: str) -> dict | None:
"""Newest backup as ``{"path", "created_at", "size_bytes"}`` or None."""
backups = list_backups(db_path)
if not backups:
return None
path = backups[0]
try:
st = os.stat(path)
except OSError:
return None
return {"path": path, "created_at": st.st_mtime, "size_bytes": st.st_size}
def _next_counter(db_path: str, safe_version: str) -> int:
"""Next free ``<n>`` for this version so a re-run never overwrites an
earlier snapshot of the same version."""
base = os.path.basename(db_path)
prefix = f"{base}.backup-{safe_version}-"
highest = 0
for path in list_backups(db_path):
name = os.path.basename(path)
if not name.startswith(prefix):
continue
tail = name[len(prefix):]
if tail.isdigit():
highest = max(highest, int(tail))
return highest + 1
def prune_backups(db_path: str, keep: int = KEEP_BACKUPS) -> list[str]:
"""Delete all but the ``keep`` newest backups. Returns deleted paths."""
deleted = []
for path in list_backups(db_path)[keep:]:
try:
os.remove(path)
deleted.append(path)
logger.info("Pruned old DB backup %s", path)
except OSError as exc:
logger.warning("Could not prune old DB backup %s: %s", path, exc)
return deleted
def snapshot_before_migration(db_path: str, version: str) -> str | None:
"""Snapshot ``db_path`` to ``<db>.backup-<version>-<n>``.
Returns the backup path, or None when skipped (no DB yet, or DB larger
than ``MAX_BACKUP_DB_BYTES``). Raises on an actual backup failure so the
caller can decide (the caller treats that as "continue without a backup",
logged loudly a backup problem must not brick startup by itself).
"""
if not os.path.isfile(db_path):
logger.debug("No DB at %s yet — nothing to back up", db_path)
return None
size = os.path.getsize(db_path)
if size > MAX_BACKUP_DB_BYTES:
logger.info(
"Skipping pre-migration DB backup: %s is %.0f MB (> %.0f MB limit)",
db_path, size / (1024 * 1024), MAX_BACKUP_DB_BYTES / (1024 * 1024),
)
return None
safe_version = _sanitize_version(version)
target = f"{db_path}.backup-{safe_version}-{_next_counter(db_path, safe_version)}"
tmp = f"{target}.part-{os.getpid()}"
src = sqlite3.connect(db_path)
try:
dst = sqlite3.connect(tmp)
try:
# Online backup: consistent snapshot including WAL contents.
src.backup(dst)
dst.commit()
finally:
dst.close()
except BaseException:
try:
os.remove(tmp)
except OSError:
pass
raise
finally:
src.close()
os.replace(tmp, target)
# A same-second rotation must still rank the new file newest.
try:
now = time.time()
os.utime(target, (now, now))
except OSError:
pass
logger.info("Pre-migration DB backup written: %s (%.1f MB)", target, size / (1024 * 1024))
prune_backups(db_path)
return target
+6
View File
@@ -76,6 +76,12 @@ _CLASS_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
"connection refused",
"connection reset",
"connection aborted",
# transformers' download-failure wording ("We couldn't connect to
# '<endpoint>' to load the files") — the #874 mirror-down class was
# journaled as UNKNOWN without these.
"couldn't connect to",
"could not connect to",
"max retries exceeded",
"timed out",
"timeout",
"name or service not known",
+199 -1
View File
@@ -21,6 +21,7 @@ import re
import sys
from pathlib import Path
from typing import Any, Optional
from urllib.parse import urlsplit
from core import error_docs_map
from core.logging_filter import REDACTED, _HF_TOKEN_RE
@@ -39,12 +40,164 @@ _HINTS: dict[str, str] = {
"PYANNOTE_LICENSE_REQUIRED": "Accept the pyannote model licenses on Hugging Face, then retry.",
"COMPUTE_TYPE_UNSUPPORTED": "Your GPU doesn't support float16 — OmniVoice retried on int8. If transcription still fails, set OMNIVOICE/ASR_COMPUTE_TYPE=int8 or use CPU.",
"TRANSFORMERS_IMPORT": "Your transformers install is incomplete. Reinstall it (`uv pip install --reinstall transformers`) or switch ASR to faster-whisper (Settings → Models).",
"OS_INVALID_ARGUMENT": "The OS rejected a file operation (Errno 22 / invalid argument) — in the transcribe path this is the temporary WAV write before ASR. It's almost always the temp directory: missing, read-only, on a full or removed drive, or blocked by antivirus. Check that your system TEMP/TMP folder exists and is writable and the drive has free space (add an OmniVoice antivirus exclusion if you use one), then retry.",
"SOCKS_PROXY_SUPPORT_MISSING": "A SOCKS proxy is configured in your environment (ALL_PROXY/HTTPS_PROXY=socks5://…) and the backend's HTTP client is missing SOCKS support. Newer OmniVoice builds ship SOCKS support (the socksio package) — update the app. If you still see this, unset ALL_PROXY/HTTPS_PROXY for OmniVoice, or run `uv pip install 'httpx[socks]'` in the backend venv, then restart.",
"SSL_HANDSHAKE_FAILURE": "A corporate or antivirus proxy is intercepting HTTPS traffic and re-signing certificates with its own CA — your OS trusts that CA, but Python's bundled certifi CA list doesn't, so the TLS handshake fails even though the connection reached the server. Newer OmniVoice builds trust the OS certificate store at startup (the truststore package), which should already fix this — update the app and retry. If you still see this, add an HTTPS-scanning exclusion for OmniVoice/Python in your antivirus, or ask IT for the proxy's CA bundle and set SSL_CERT_FILE to it, then restart.",
"UNSUPPORTED_VIDEO_URL": "This link isn't a directly downloadable video. Paste a direct video page (e.g. a youtube.com/watch?v=… or douyin.com/video/<id> link), not a share/profile/feed link — or download the file and drop it in directly.",
"VIDEO_DOWNLOAD_NETWORK": "The connection to the video server dropped mid-download (often a transient CDN/network blip or a regional rate-limit). Just retry — OmniVoice already cleaned up the partial download. If it keeps failing, check your network/VPN.",
"BROKEN_VENV": "The Python backend environment was moved or damaged. OmniVoice rebuilds it automatically on the next launch; if it keeps failing, use Clean & Retry on the setup screen.",
# HF_MIRROR_UNREACHABLE has a DYNAMIC hint (it names the configured mirror)
# — see hf_mirror_hint(); build_failure special-cases it.
}
# ── HF mirror connectivity (#874) ────────────────────────────────────────────
# When a non-default HF_ENDPOINT (a mirror, e.g. hf-mirror.com — set via
# Settings → Models → Hugging Face mirror) is configured and a model
# download/load fails with a connectivity error, the raw transformers/hf_hub
# message ("We couldn't connect to 'https://hf-mirror.com' to load the files…")
# gives the user no next step. This is the single classifier for that class,
# shared by every surface: build_failure() (model status, dub/task events),
# the global 500 handler (main.py — covers /generate and every other route
# that can leak a model-load error), and the model-install SSE
# (setup/download.py).
_OFFICIAL_HF_ENDPOINTS = {"https://huggingface.co", "https://hf.co"}
# Connectivity signatures across the layers an HF download failure surfaces
# from: transformers' wording, huggingface_hub errors, requests/urllib3, and
# raw socket/DNS failures (Linux/macOS/Windows variants).
_HF_CONNECTIVITY_SIGNATURES = (
"couldn't connect to", # transformers: "We couldn't connect to '<endpoint>' …"
"could not connect to",
"connection error", # huggingface_hub / requests
"connection refused",
"connection reset",
"connection aborted",
"max retries exceeded", # urllib3 via requests
"failed to establish a new connection",
"name or service not known", # Linux DNS
"temporary failure in name resolution",
"nodename nor servname provided", # macOS DNS
"getaddrinfo failed", # Windows DNS
"timed out",
"an error happened while trying to locate the file on the hub", # LocalEntryNotFoundError
"we cannot find the requested files", # LocalEntryNotFoundError
)
# The failure must also be Hugging-Face-shaped — the configured endpoint/host
# named in the message, or HF-download wording — so a random socket error
# (e.g. a local LLM provider being down) doesn't get the mirror hint just
# because a mirror happens to be configured.
_HF_CONTEXT_MARKERS = (
"huggingface",
"hf_hub",
"hf-hub",
"load the files", # transformers
"cached files", # transformers
"the requested files", # LocalEntryNotFoundError
"locate the file on the hub",
"snapshot_download",
)
def configured_hf_mirror() -> str:
"""The non-default Hugging Face endpoint (mirror) in effect, or "".
Same resolution the download paths use: ``HF_ENDPOINT`` env (what
Settings Models Hugging Face mirror persists via user_env, and what
the HF libraries read) with the ``hf_endpoint`` pref as fallback
(mirrors setup/download.py's ``prefs.resolve``). Never raises.
"""
ep = (os.environ.get("HF_ENDPOINT") or "").strip()
if not ep:
try:
from core import prefs
ep = str(prefs.get("hf_endpoint", "") or "").strip()
except Exception:
ep = ""
ep = ep.rstrip("/")
if not ep or ep.lower() in _OFFICIAL_HF_ENDPOINTS:
return ""
return ep
def hf_mirror_hint(reason: Optional[str]) -> str:
"""Actionable hint when ``reason`` is an HF-download connectivity failure
and a non-default mirror endpoint is configured; "" otherwise.
The hint names the configured mirror, says it may be down, points at the
setting (Settings Models Hugging Face mirror), suggests the official
endpoint when the model isn't cached yet, and notes the restart
requirement (HF reads HF_ENDPOINT at import time see the hf-mirror
endpoints in api/routers/settings.py). Never raises.
"""
mirror = configured_hf_mirror()
if not mirror:
return ""
low = (reason or "").lower()
if not any(sig in low for sig in _HF_CONNECTIVITY_SIGNATURES):
return ""
try:
host = (urlsplit(mirror).netloc or "").lower()
except Exception:
host = ""
if not (
mirror.lower() in low
or (host and host in low)
or any(m in low for m in _HF_CONTEXT_MARKERS)
):
return ""
return (
f"Your Hugging Face mirror is set to {mirror}, which couldn't be "
"reached — the mirror may be down or blocked on your network. If the "
'model isn\'t in your local cache yet, switch to "Hugging Face '
'(official)" in Settings → Models → Hugging Face mirror (or wait for '
"the mirror to recover), then restart OmniVoice — the mirror setting "
"is applied when the app starts."
)
def append_hf_mirror_hint(text: str) -> str:
"""``"{text}{hint}"`` when the mirror-connectivity class applies;
``text`` unchanged otherwise. For surfaces that hand a raw error string to
the UI (the global 500 handler, the model-install SSE). Never raises."""
try:
hint = hf_mirror_hint(text)
except Exception:
return text
return f"{text}{hint}" if hint else text
# Classes whose hint is safe to attach on the CONTEXT-FREE surfaces (the
# global 500 handler in main.py, the model-install SSE in setup/download.py),
# where all we have is a raw error string with no stage. Only classes whose
# classify() trigger is unmistakable belong here — e.g. VIDEO_DOWNLOAD_NETWORK
# must NOT be added: its bare "timed out" trigger would stamp a "video server"
# hint on a model-load timeout that leaks through the 500 handler.
_CONTEXT_FREE_HINT_CLASSES = frozenset({
"SOCKS_PROXY_SUPPORT_MISSING",
"SSL_HANDSHAKE_FAILURE",
})
def append_hint(text: str) -> str:
"""``"{text}{hint}"`` for raw-string surfaces (the global 500 handler,
the model-install SSE): the dynamic mirror hint (#874) when that class
applies, else a context-free static class hint (#959). ``text`` unchanged
otherwise a no-op for every other error. Never raises."""
try:
hint = hf_mirror_hint(text)
if not hint:
topic = classify(text)
if topic in _CONTEXT_FREE_HINT_CLASSES:
hint = _HINTS.get(topic, "")
except Exception:
return text
return f"{text}{hint}" if hint else text
def classify(reason: str) -> str:
"""Map a failure reason to a docs-taxonomy key, or "" when unknown.
@@ -65,6 +218,19 @@ def classify(reason: str) -> str:
# failure gets its hint rather than falling through to "".
if "compute type" in low or "efficient float16" in low:
return "COMPUTE_TYPE_UNSUPPORTED"
# #763: a bare OS-level EINVAL ("[Errno 22] Invalid argument") while writing
# the per-chunk temp WAV for transcription (tempfile.NamedTemporaryFile /
# soundfile.write on the system temp dir) used to collapse into a dead-end
# "produced no segments. [Errno 22] Invalid argument" toast with no next
# step. errno 22 is EINVAL on every platform; in this path it's almost always
# a temp dir that's missing, read-only, on a full/removed drive, or blocked
# by antivirus. Name the class so build_failure attaches an actionable hint
# instead of a raw errno. Matching the errno (not the generic "invalid
# argument" wording) keeps this from mislabelling unrelated failures; the
# transformers "errno 2" rule below is unaffected — it also requires the
# transformers + site-packages markers, which this signature lacks.
if "errno 22" in low:
return "OS_INVALID_ARGUMENT"
if (
"could not import module" in low
or "autofeatureextractor" in low
@@ -84,10 +250,39 @@ def classify(reason: str) -> str:
)
):
return "TRANSFORMERS_IMPORT"
# #959: httpx raises ImportError AT CLIENT CONSTRUCTION ("Using SOCKS
# proxy, but the 'socksio' package is not installed. Make sure to install
# httpx using `pip install httpx[socks]`.") when ALL_PROXY/HTTPS_PROXY is
# socks5:// and socksio isn't importable. It surfaced from
# huggingface_hub's get_session() inside model load — a bare 500 on
# /generate with no next step. Checked BEFORE the HF-auth/mirror rules so
# a message that also carries HF wording still names this class.
if "socks proxy" in low or "socksio" in low:
return "SOCKS_PROXY_SUPPORT_MISSING"
# #976: a TLS handshake failing AFTER the TCP connection succeeds — the
# signature of a corporate/antivirus proxy that TLS-inspects traffic and
# re-signs certificates with a CA the OS trusts but Python's bundled
# certifi list doesn't (a different failure mode from #984's TCP-level
# "can't reach the host at all"). Requires "ssl" plus a handshake/cert-
# verify marker so a generic connection error isn't mislabelled.
if "ssl" in low and (
"handshake" in low
or "certificate verify failed" in low
or "sslv3_alert" in low
or "sslcertverificationerror" in low
):
return "SSL_HANDSHAKE_FAILURE"
if ("huggingface" in low or "hf_token" in low or "401" in low or "unauthorized" in low) and (
"token" in low or "auth" in low or "401" in low or "unauthorized" in low
):
return "HF_AUTH_FAILED"
# #874: a model download that failed because the CONFIGURED HF mirror is
# unreachable. Env-aware by design — the class only exists when a
# non-default HF_ENDPOINT is configured. Checked BEFORE the video-download
# network class so a model download's "timed out"/"connection reset"
# names the mirror instead of the "video server".
if hf_mirror_hint(reason):
return "HF_MIRROR_UNREACHABLE"
# Video download (#554/#536): a non-downloadable URL shape vs a transient
# network drop — both previously surfaced as a bare yt-dlp string with no
# next step. UNSUPPORTED first (more specific) so "Unable to download video:
@@ -207,12 +402,15 @@ def build_failure(
reason = sanitize(raw) or error_class
docs_topic = classify(raw)
# HF_MIRROR_UNREACHABLE's hint is dynamic (it names the configured mirror)
# so it can't live in the static _HINTS table.
hint = hf_mirror_hint(raw) if docs_topic == "HF_MIRROR_UNREACHABLE" else _HINTS.get(docs_topic, "")
fields: dict[str, Any] = {
"reason": reason,
"error": reason, # backward-compat mirror for older frontends
"error_class": error_class,
"stage": stage,
"hint": _HINTS.get(docs_topic, ""),
"hint": hint,
"docs_topic": docs_topic,
"docs_url": error_docs_map.ERROR_DOCS.get(docs_topic, ""),
"detail": sanitize(raw),
+21
View File
@@ -134,3 +134,24 @@ def scrub_text(text: str | None) -> str:
pass
return s
def scrub_provider_error(detail: object, api_key: str | None = None) -> str:
"""UI-safe text for an LLM/translation provider failure.
Some OpenAI-compatible providers echo the caller's key or a stable
``user_id`` back inside their error bodies, and a raw ``str(exc)`` on the
translate / glossary paths would surface that verbatim. This redacts the
exact resolved ``api_key`` first (in the provider-registry case it isn't a
shaped/known-env secret, so ``scrub_text`` alone can miss it) then runs the
generic secret + home-path scrub. Never raises scrubbing must not mask a
failure with a new one. Mirrors ``settings._scrub_llm_detail`` so every
surface redacts identically.
"""
s = str(detail if detail is not None else "")
try:
if api_key and api_key != "local" and len(api_key) >= _MIN_SECRET_LEN:
s = s.replace(api_key, REDACTED)
except Exception:
pass
return scrub_text(s)
+1 -1
View File
@@ -24,7 +24,7 @@ from pathlib import Path
# tests/test_app_version.py::test_all_version_files_in_lockstep and bumped by
# release.yml's version-bump job, so it stays equal to
# pyproject/tauri.conf/Cargo/package.json.
_FALLBACK_VERSION = "0.3.8"
_FALLBACK_VERSION = "0.3.14"
def _fallback_version() -> str:
+1
View File
@@ -70,6 +70,7 @@ class Supertonic3Backend(SubprocessBackend):
id = "supertonic3"
display_name = "Supertonic-3 (31 langs, CPU ONNX, 7 preset voices, OpenRAIL-M)"
supports_voice_design = False # preset voices only
supports_cloning = False # preset voices only; generate() never reads ref_audio
# TTS-04: honest hardware reporting. Supertonic-3 has no CUDA / MPS
# path in the SDK ‑‑ ONNX Runtime CPU EP only.
gpu_compat: tuple[str, ...] = ("cpu",)
+137 -13
View File
@@ -139,6 +139,24 @@ os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
os.environ.setdefault("HF_HUB_ETAG_TIMEOUT", "15")
os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "30")
# ── OS trust store for TLS (#976) ───────────────────────────────────────────
# Users behind a corporate/antivirus proxy that TLS-inspects HTTPS traffic get
# a raw "[SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] ssl/tls alert handshake failure"
# on every model install — the TCP connection succeeds (a different failure
# mode from #984's TCP-level blocked-host case), but the proxy re-signs the
# certificate with its own root CA, which the OS trusts (Windows CryptoAPI/
# SChannel) and Python's bundled `certifi` CA list does not. `inject_into_ssl`
# patches `ssl.SSLContext` process-wide to verify against the OS trust store
# instead, which is the actual fix (not just a nicer error message). Must run
# here — at MODULE level, before huggingface_hub/requests/httpx do any network
# I/O — not inside lifespan(), which runs too late. Not platform-gated: it's a
# correctness improvement everywhere. Best-effort: never block startup.
try:
import truststore
truststore.inject_into_ssl()
except Exception:
pass
# Prevent torchaudio from lazy-importing torchcodec (broken on some installs).
# Proper fix = exclude torchcodec in pyproject.toml; this is a belt-and-braces guard.
@@ -155,6 +173,16 @@ from logging.handlers import RotatingFileHandler
# written to prefs.json so they survive backend restarts. Read them back
# here — before any user code reads os.environ — so the values are available
# from startup.
#
# Legacy (≤v0.3.7) Translation-LLM rows (env.TRANSLATE_*) must migrate into
# the custom LLM provider's settings store BEFORE the re-import below — once
# TRANSLATE_BASE_URL lands in os.environ it hijacks the LLM provider
# selection for the whole session (#963). Real env vars are untouched.
try:
from services.llm_providers import migrate_legacy_translate_prefs
migrate_legacy_translate_prefs()
except Exception:
pass # never block startup on the migration; it retries next launch
_PERSISTED_ENV_PREFIX = "env."
try:
from core.prefs import _load as _load_all_prefs
@@ -387,6 +415,32 @@ def _env_flag(name: str, default: bool = False) -> bool:
return value.strip().lower() in {"1", "true", "yes", "on"}
def _capture_preload_delay_s() -> float:
"""Seconds after boot before the dictation (capture ASR) model warms.
Late enough that it never competes with startup I/O or the TTS preload;
overridable via OMNIVOICE_CAPTURE_PRELOAD_DELAY (mostly for tests)."""
raw = os.environ.get("OMNIVOICE_CAPTURE_PRELOAD_DELAY", "")
try:
v = float(raw)
if v >= 0:
return v
except (TypeError, ValueError):
pass
return 30.0
def _capture_preload_ram_ok(min_free_bytes: int = 4 * 1024**3) -> bool:
"""RAM guard for the dictation warm-up: skip below 4 GB free so the
background load never pushes a small machine into swap. If free memory
can't be measured, warm anyway (the load path has its own error handling)."""
try:
import psutil
return psutil.virtual_memory().available >= min_free_bytes
except Exception:
return True
def _mcp_start_timeout_s() -> float:
"""Seconds to wait for the MCP session manager to start before giving up
and serving without it (#632). Overridable via OMNIVOICE_MCP_START_TIMEOUT_S."""
@@ -450,6 +504,35 @@ async def _start_mcp_session_manager(session_manager, *, timeout: float):
return task, stop, mounted
async def _cancel_and_await_tasks(*tasks, timeout: float = 3.0) -> None:
"""Cancel each background task and give it a bounded chance to actually
finish before shutdown proceeds ``None`` entries are skipped (a task
that's conditionally created, e.g. ``capture_preload_task``, may not
exist).
``task.cancel()`` alone is not enough for a task awaiting
``run_in_executor()``: once the underlying OS thread is inside blocking
native/import work, cancellation can't stop it, so cancel-and-move-on lets
shutdown finish while that thread is still running invisible to
asyncio, but very much alive when the interpreter starts tearing down
module state under it (#1000 class). Awaiting with a bound (instead of
just cancelling) gives an early-stage task a real chance to exit cleanly
first; a task that's genuinely still deep in blocking work times out here
same as before, and the caller's own GPU-pool reset handles that case.
"""
for t in tasks:
if t is None:
continue
t.cancel()
for t in tasks:
if t is None:
continue
try:
await asyncio.wait_for(t, timeout=timeout)
except (asyncio.CancelledError, asyncio.TimeoutError):
pass
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup watchdog (#632): a silent hang during startup (e.g. a model-load /
@@ -519,11 +602,20 @@ async def lifespan(app: FastAPI):
worker_task = asyncio.create_task(task_manager.worker())
# Warm the TTS model in the background so first /generate is instant.
preload_task = asyncio.create_task(preload_model())
# Capture ASR is useful to keep warm, but it is another large model in
# unified memory on Apple Silicon. Keep launch lean by default; users who
# prefer instant dictation can opt in with OMNIVOICE_PRELOAD_CAPTURE_ASR=1.
if _env_flag("OMNIVOICE_PRELOAD_CAPTURE_ASR"):
# Dictation v2: the capture ASR warms in the background BY DEFAULT — a
# deferred (~30s post-boot) load off the event loop, so startup stays
# lean and the first dictation is instant instead of a cold model load.
# OMNIVOICE_PRELOAD_CAPTURE_ASR=0 opts out; the warm-up is also skipped
# under 4 GB free RAM (checked at warm time, not boot time).
capture_preload_task = None # only assigned when the preload actually runs (#1000 class)
if _env_flag("OMNIVOICE_PRELOAD_CAPTURE_ASR", default=True):
async def _preload_capture_asr():
await asyncio.sleep(_capture_preload_delay_s())
if not _capture_preload_ram_ok():
logger.info(
"Capture ASR preload skipped: <4GB free RAM; "
"dictation ASR will load on first use.")
return
loading_detail = None
prev_loading_detail = None
try:
@@ -584,14 +676,33 @@ async def lifespan(app: FastAPI):
pass
except Exception:
pass
idle_task.cancel()
worker_task.cancel()
# Wait for tasks to finish their current iteration
for t in (idle_task, worker_task):
try:
await asyncio.wait_for(t, timeout=3.0)
except (asyncio.CancelledError, asyncio.TimeoutError):
pass
# preload_task/capture_preload_task matter most here (#1000 class): a quit
# mid-preload used to fall straight through to "Shutdown: done." while the
# model load was still running on a GPU-pool thread — cancel() can't stop
# a thread already inside blocking import/load work, so the process
# reported a clean exit while that background thread was still mid-
# `import transformers`, and got torn down by interpreter finalization
# instead. That surfaced as a misleading "Could not import module
# 'AutoFeatureExtractor'" — transformers' own generic lazy-import wrapper,
# not a real dependency problem. Awaiting here lets an early-stage load
# (still importing, not yet mid weight-download) finish cleanly before we
# report done; a load that's genuinely deep into a multi-GB download still
# times out — _reset_gpu_pool() below abandons it either way.
#
# 20s, not the original 3s (code-review finding post-merge): a cold
# transformers import alone can take longer than 3s on a slow disk or a
# first-ever launch, so the original bound left a real residual window —
# cancellation detaches the asyncio task, but the underlying OS thread
# keeps running past it, and shutdown could still report "done" while
# that thread was alive. Python cannot forcibly kill a running thread, so
# no finite bound eliminates this outright — 20s just shrinks the window
# from "any preload" to "an unusually slow cold-import," which is the
# practical ceiling before a longer shutdown itself becomes the
# complaint. A thread that's still running past 20s was never going to
# finish in a shutdown-appropriate timeframe regardless.
await _cancel_and_await_tasks(
idle_task, worker_task, preload_task, capture_preload_task, timeout=20.0,
)
# Unload the model and free GPU memory
try:
import services.model_manager as mm
@@ -599,6 +710,10 @@ async def lifespan(app: FastAPI):
mm.model = None
logger.info("Shutdown: model unloaded.")
mm.free_vram()
# Abandon a still-running preload's GPU-pool thread (Python can't kill
# a thread mid blocking call) so it can't outlive this shutdown block
# holding a reference into module state that's about to be torn down.
mm._reset_gpu_pool()
except Exception:
pass
# Run GC to release any remaining references
@@ -681,8 +796,17 @@ async def global_exception_handler(request: Request, exc: Exception):
headers["Access-Control-Allow-Origin"] = origin
headers["Access-Control-Allow-Credentials"] = "true"
headers["Vary"] = "Origin"
# #874: a model download that failed because the CONFIGURED Hugging Face
# mirror (HF_ENDPOINT) is unreachable used to leak the raw transformers
# message ("We couldn't connect to 'https://hf-mirror.com' …") as the 500
# detail with no next step. #959: same story for the SOCKS-proxy class
# ("Using SOCKS proxy, but the 'socksio' package is not installed").
# Appending the shared hints HERE covers every route that can leak a
# model-load/download error (generate, dub, archetypes, …), not just TTS
# generate. append_hint is a no-op for every other error and never raises.
from core.failure import append_hint
return JSONResponse(
{"detail": str(exc), "error_class": _entry.get("error_class")},
{"detail": append_hint(str(exc)), "error_class": _entry.get("error_class")},
status_code=500,
headers=headers,
)
+7 -1
View File
@@ -17,7 +17,13 @@ from core.config import DB_PATH # noqa: E402 — backend/ is on sys.path via al
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# `disable_existing_loggers=False` is deliberate: this env runs *inside* the
# live app (startup `alembic upgrade head`), so the default (True) would
# disable every already-created application logger — e.g. silence
# `omnivoice.db.backup`'s "Skipping pre-migration DB backup" line and the
# rest of the app's logging for the remainder of the process. A migration
# must never mute the app (or leak that mute across a test session).
fileConfig(config.config_file_name, disable_existing_loggers=False)
# SQLite file URL. Honour an externally-set URL (tests pass one via
# `cfg.set_main_option("sqlalchemy.url", ...)` to point at a fixture DB),
+399 -64
View File
@@ -27,7 +27,9 @@ import asyncio
import logging
import os
import re
import threading
from abc import ABC, abstractmethod
from typing import Optional
logger = logging.getLogger("omnivoice.asr")
@@ -51,8 +53,95 @@ class ASRTimeoutError(TimeoutError):
"""
def reset_pool_after_wedge(executor, *, what: str = "ASR") -> bool:
"""Abandon a GPU pool whose worker is wedged on a timed-out transcribe (#730).
Python can't kill the stuck thread, but dropping the poisoned pool means the
next submit (a retry, the next chunk, or a concurrent TTS generate) gets a
fresh worker instead of queueing behind the wedged one. This is the ONE
recovery mechanism shared by every transcribe path the whole-file guards
(via :func:`run_transcribe_guarded`) and the chunked dub stream both route
through it, so the semantics can't drift between them again.
Best-effort: an executor without ``reset()`` (a plain ThreadPoolExecutor in
tests) is a no-op, and a failing reset never raises this runs on the very
failure path it's trying to recover from. Returns True when a reset ran.
"""
_reset = getattr(executor, "reset", None)
if not callable(_reset):
return False
try:
_reset()
logger.warning(
"%s transcribe wedged — abandoned the GPU-pool worker to restore "
"capacity (#730).", what,
)
return True
except Exception:
logger.exception("GPU pool reset after %s timeout failed", what)
return False
# ── Consecutive-timeout streak → recommend the crash-isolated engine ────────
# A pool reset restores *capacity*, but the wedged CTranslate2/whisperx thread
# keeps its VRAM until the process exits. When guarded transcribes keep timing
# out back-to-back in one session, resets clearly aren't recovering the
# underlying hang — the durable fix is the crash-isolated sidecar engine
# (services.subprocess_asr, #393), whose child process CAN be hard-killed to
# reclaim the hung call and its VRAM. We only *recommend* it (log + error
# message); we never switch engines automatically (owner rule: no silent
# behavior divergence).
_TIMEOUT_STREAK_FOR_ISOLATED_HINT = 2
_timeout_streak = 0
_timeout_streak_lock = threading.Lock()
def _note_transcribe_timeout() -> int:
global _timeout_streak
with _timeout_streak_lock:
_timeout_streak += 1
return _timeout_streak
def _note_transcribe_success() -> None:
global _timeout_streak
with _timeout_streak_lock:
_timeout_streak = 0
def _isolated_engine_hint(streak: int) -> str:
"""User-facing recommendation once resets stop recovering (streak ≥ 2).
Empty when the streak is below the threshold, or when the user is already
on the isolated engine (recommending it to itself would be noise the
base message's smaller-model/CPU guidance is all that's left)."""
if streak < _TIMEOUT_STREAK_FOR_ISOLATED_HINT:
return ""
try:
if active_backend_id() == "faster-whisper-isolated":
return ""
except Exception: # noqa: BLE001 — the hint must never break the error path
pass
logger.warning(
"%d consecutive ASR transcribe timeouts this session — pool resets are "
"not recovering the hang. Recommend switching the ASR engine to "
"'Faster-Whisper (crash-isolated subprocess)' [faster-whisper-isolated] "
"in Settings → Engines. Not switching automatically (#730).", streak,
)
return (
f"This is {streak} transcribe timeouts in a row this session, so pool "
"resets aren't recovering the underlying hang. Recommended: switch the "
"ASR engine to 'Faster-Whisper (crash-isolated subprocess)' "
"(faster-whisper-isolated) in Settings → Engines — it runs "
"transcription in a separate process that can be force-killed to "
"reclaim a hung transcribe and its VRAM. OmniVoice never switches "
"engines automatically."
)
async def run_transcribe_guarded(executor, fn, *, what: str = "ASR",
timeout: float = ASR_TRANSCRIBE_TIMEOUT_S):
timeout: float = ASR_TRANSCRIBE_TIMEOUT_S,
timeout_env: str = "OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S"):
"""Run a blocking transcribe ``fn`` in ``executor`` with a hard wall-clock
bound. On timeout, raise :class:`ASRTimeoutError` with guidance instead of
letting the request hang forever.
@@ -73,30 +162,30 @@ async def run_transcribe_guarded(executor, fn, *, what: str = "ASR",
loop = asyncio.get_running_loop()
fut = loop.run_in_executor(executor, fn)
try:
return await asyncio.wait_for(fut, timeout=timeout)
result = await asyncio.wait_for(fut, timeout=timeout)
except asyncio.TimeoutError:
# Free the poisoned pool so a hung transcribe can't keep starving TTS /
# other ASR work (the "can't reach backend" symptom, #730).
_reset = getattr(executor, "reset", None)
if callable(_reset):
try:
_reset()
logger.warning(
"%s transcription exceeded %.0fs — abandoned the GPU-pool "
"worker to restore capacity (#730).", what, timeout,
)
except Exception:
logger.exception("GPU pool reset after ASR timeout failed")
raise ASRTimeoutError(
reset_pool_after_wedge(executor, what=what)
streak = _note_transcribe_timeout()
msg = (
f"{what} transcription exceeded {timeout:.0f}s and was abandoned — "
"the backend is running, but the ASR model is too heavy for the "
"available compute. Most often the GPU is VRAM-starved: the resident "
"TTS model and a large ASR model (large-v3) contend for memory. "
"Capacity was restored automatically, but for a durable fix Flush the "
"TTS model to free VRAM, pick a smaller ASR model in Settings → "
"Models, or set ASR to CPU. (Raise OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S "
"for very long single files.)"
f"Models, or set ASR to CPU. (Raise {timeout_env} "
"for very long transcribes.)"
)
hint = _isolated_engine_hint(streak)
if hint:
msg += " " + hint
raise ASRTimeoutError(msg)
# A completed transcribe (even a failed-but-returned one) proves the pool
# isn't hung — only genuine timeouts count toward the consecutive streak.
_note_transcribe_success()
return result
def _compute_type_candidates(device: str) -> list[str]:
@@ -1336,6 +1425,11 @@ class SherpaDictationBackend(ASRBackend):
)
self._spec = spec
self._rec = None # lazy OfflineRecognizer / OnlineRecognizer
# One backend is shared across live-dictation WS sessions (see
# get_sherpa_dictation_backend), so guard the one-time recognizer build
# against two sessions racing to construct it concurrently. Each session
# still owns its own decode stream — only the recognizer is shared.
self._rec_lock = threading.Lock()
@property
def spec(self):
@@ -1353,14 +1447,25 @@ class SherpaDictationBackend(ASRBackend):
def ensure_loaded(self) -> None:
self._ensure_rec()
def warmup(self) -> None:
"""Eagerly build the recognizer so the FIRST live-dictation session
doesn't pay the 1.32.5s ONNX-session load (#888 'instant first
dictation'). Called by the background capture-ASR preload; idempotent,
and the built recognizer is reused across sessions via
get_sherpa_dictation_backend (the same singleton the preload warms)."""
self._ensure_rec()
def _ensure_rec(self):
if self._rec is not None:
return
from services import sherpa_dictation as _sd
if self._spec.streaming:
self._rec = _sd.build_online_recognizer(self._spec)
else:
self._rec = _sd.build_offline_recognizer(self._spec)
with self._rec_lock:
if self._rec is not None:
return
from services import sherpa_dictation as _sd
if self._spec.streaming:
self._rec = _sd.build_online_recognizer(self._spec)
else:
self._rec = _sd.build_offline_recognizer(self._spec)
def transcribe(self, audio_path: str, *, word_timestamps: bool = True) -> dict:
self._ensure_rec()
@@ -1530,6 +1635,161 @@ class FunASRBackend(ASRBackend):
pass
# ── OpenAI-compatible remote transcription (#877 — Qwen3-ASR / FunASR / any
# compatible server, today, without waiting on transformers to catch up) ──
#
# transformers doesn't yet ship a stable Qwen3-ASR integration (issue #877),
# but a self-hosted Qwen3-ASR/FunASR/SenseVoice server exposing an
# OpenAI-compatible `POST /v1/audio/transcriptions` endpoint — or OpenAI's own
# Whisper API — is usable right now. This backend is a pure network client:
# no model runs locally, so it needs no install and claims no GPU.
#
# Settings mirror the LLM-providers convention exactly (services/
# llm_providers.py): base_url/model are plain settings_store text rows; the
# API key is Fernet-encrypted via settings_store.set_secret/get_secret — never
# a .env row, never echoed back to the client. Optional: some self-hosted
# servers (vLLM, LM Studio-style) don't check the key at all.
_ASR_OPENAI_COMPAT_BASE_URL_KEY = "asr.openai_compat.base_url"
_ASR_OPENAI_COMPAT_MODEL_KEY = "asr.openai_compat.model"
_ASR_OPENAI_COMPAT_SECRET_NAME = "asr_openai_compat_key"
def resolve_openai_compat_asr_base_url() -> str:
from services import settings_store
return (
os.environ.get("ASR_OPENAI_COMPAT_BASE_URL")
or settings_store.get_text(_ASR_OPENAI_COMPAT_BASE_URL_KEY)
or ""
)
def resolve_openai_compat_asr_model() -> str:
from services import settings_store
return (
os.environ.get("ASR_OPENAI_COMPAT_MODEL")
or settings_store.get_text(_ASR_OPENAI_COMPAT_MODEL_KEY)
or "whisper-1"
)
def resolve_openai_compat_asr_api_key() -> Optional[str]:
"""Env → encrypted stored key → None. Unlike LLM providers, no 'local'
sentinel: many self-hosted transcription servers accept an empty/omitted
Authorization header outright, so the OpenAI SDK is constructed with
``api_key="not-needed"`` (a non-empty placeholder the SDK requires) when
this returns None, rather than treating a keyless server as unconfigured.
"""
from services import settings_store
return os.environ.get("ASR_OPENAI_COMPAT_API_KEY") or settings_store.get_secret(
_ASR_OPENAI_COMPAT_SECRET_NAME
)
def openai_compat_asr_has_key() -> bool:
"""Whether a key is configured, without ever decrypting it — mirrors
llm_providers.has_key()'s no-plaintext-round-trip contract."""
from services import settings_store
if os.environ.get("ASR_OPENAI_COMPAT_API_KEY"):
return True
return _ASR_OPENAI_COMPAT_SECRET_NAME in settings_store.list_secret_names()
class OpenAICompatASRBackend(ASRBackend):
"""Remote transcription via any OpenAI-compatible server.
Adapts whatever the server returns into this module's expected shape.
Prefers `response_format="verbose_json"` for real per-segment timestamps
(OpenAI's own API and most compatible servers support it); falls back to
plain text with rough single-segment bounds mirroring
MoonshineASRBackend's degraded shape — for minimal servers that reject it.
"""
id = "openai-compat-asr"
display_name = "OpenAI-compatible (remote server)"
gpu_compat = ("cpu",) # network client only — no local compute
def __init__(self):
self._base_url = resolve_openai_compat_asr_base_url()
self._model = resolve_openai_compat_asr_model()
@classmethod
def is_available(cls) -> tuple[bool, str]:
if not resolve_openai_compat_asr_base_url():
return False, "Configure a server endpoint in Settings → Engines"
try:
import openai # noqa: F401
except ImportError:
return False, "openai package not installed. Install with: uv pip install openai"
return True, "ready"
def _client(self):
from openai import OpenAI
api_key = resolve_openai_compat_asr_api_key() or "not-needed"
# max_retries=0: mirrors llm_skills.resolve_skill_client — a
# rate-limited/slow server retrying inside the SDK would blow past
# whatever bounded timeout the caller (dub transcribe, dictation)
# expects from a single call.
return OpenAI(base_url=self._base_url, api_key=api_key, max_retries=0)
def transcribe(self, audio_path: str, *, word_timestamps: bool = True) -> dict:
logger.info(
"OpenAI-compat ASR transcribing %s (base_url=%s, model=%s)",
audio_path, self._base_url, self._model,
)
client = self._client()
try:
with open(audio_path, "rb") as f:
try:
resp = client.audio.transcriptions.create(
file=f, model=self._model, response_format="verbose_json",
)
except Exception:
# Minimal/older compatible servers reject verbose_json
# outright — retry plain before treating it as a real
# failure. Re-open: the SDK may have partially consumed
# the file handle on the first attempt.
f.seek(0)
resp = client.audio.transcriptions.create(
file=f, model=self._model, response_format="json",
)
except Exception as exc:
# Never leak a raw SDK/httpx exception object (auth headers,
# connection internals) straight into a user-facing message —
# same convention as generation.py's _safe_exc_text (#977 class).
raise RuntimeError(
f"OpenAI-compatible ASR server at {self._base_url!r} failed: "
f"{type(exc).__name__}: {exc}"
) from exc
return self._adapt_response(resp)
@staticmethod
def _adapt_response(resp) -> dict:
segments_out = []
# verbose_json: resp.segments is a list of objects with start/end/text.
raw_segments = getattr(resp, "segments", None)
if raw_segments:
for seg in raw_segments:
seg_dict = seg if isinstance(seg, dict) else seg.model_dump()
segments_out.append({
"text": (seg_dict.get("text") or "").strip(),
"start": seg_dict.get("start", 0.0),
"end": seg_dict.get("end", 0.0),
"words": [], # word-level timing isn't part of this API
})
else:
# Plain text response (json/text format) — single-segment shape,
# matching MoonshineASRBackend's degraded fallback exactly.
text = (getattr(resp, "text", None) or "").strip()
if text:
segments_out.append({"text": text, "start": 0.0, "end": None, "words": []})
chunks = [
{"text": seg["text"], "timestamp": (seg["start"], seg["end"])}
for seg in segments_out
]
language = getattr(resp, "language", None) or "en"
return {"chunks": chunks, "segments": segments_out, "language": language}
def _isolated_faster_whisper():
"""Lazy import so the subprocess_asr → subprocess_backend chain isn't
pulled in at registry definition time."""
@@ -1558,7 +1818,13 @@ class _LazyASRRegistry(dict):
def __iter__(self):
seen = set()
for k in dict.__iter__(self):
# Snapshot the live keys before yielding — see _LazyRegistry.__iter__ in
# tts_backend.py. A concurrent lazy __getitem__ inserts into self, and
# list_backends() runs in a FastAPI threadpool, so a *live* dict iterator
# held open across the per-engine is_available() probes would raise
# "dictionary changed size during iteration". list() consumes it
# atomically under the GIL, closing the window.
for k in list(dict.__iter__(self)):
seen.add(k)
yield k
for k in self._LAZY:
@@ -1579,6 +1845,7 @@ _REGISTRY: dict[str, type[ASRBackend]] = _LazyASRRegistry({
"moonshine": MoonshineASRBackend,
"funasr": FunASRBackend,
"sherpa-onnx-asr": SherpaDictationBackend,
"openai-compat-asr": OpenAICompatASRBackend,
# "faster-whisper-isolated": resolved lazily (crash-isolated subprocess).
})
@@ -1590,10 +1857,32 @@ _INSTALL_HINTS: dict[str, str] = {
"faster-whisper": "pip install faster-whisper (CTranslate2; cross-platform, CUDA or CPU)",
"mlx-whisper": "pip install mlx-whisper (Apple Silicon only)",
"pytorch-whisper": "Bundled with transformers — no extra install (CUDA/MPS/CPU)",
"nemo-parakeet": "pip install nemo_toolkit[asr] (NVIDIA Parakeet; CUDA or CPU)",
"nemo-parakeet": (
"No safe install path in this app yet — nemo_toolkit's ASR extras pin "
"transformers>=4.57,<4.58, which conflicts with OmniVoice's own "
"transformers>=5.3 requirement and WILL break the backend "
"(ImportError on startup) if installed into this shared venv. Do NOT "
"install nemo_toolkit here. If you want to try Parakeet TDT, set it "
"up in a separate/dedicated Python environment — not the one "
"OmniVoice manages; in-app isolation for this engine is tracked "
"separately."
),
"moonshine": "pip install useful-moonshine (edge/CPU-optimized ASR)",
"funasr": "pip install funasr (SenseVoiceSmall + FSMN-VAD; CUDA or CPU)",
"sherpa-onnx-asr": "uv add sherpa-onnx (ONNX live dictation; CPU, cross-platform)",
"openai-compat-asr": (
"No install needed — configure a server endpoint in Settings → "
"Engines. Points OmniVoice at any OpenAI-compatible transcription "
"server (a self-hosted Qwen3-ASR/FunASR/SenseVoice server, OpenAI's "
"own Whisper API, or similar) — a path to Qwen3-ASR today, without "
"waiting on a direct transformers integration."
),
"faster-whisper-isolated": (
"No extra install (reuses faster-whisper). Escape hatch for hanging "
"transcribes: runs ASR in a separate process that can be force-killed "
"to reclaim a hung transcribe and its VRAM (#730). Slightly slower per "
"call than in-process faster-whisper."
),
}
# Most-recent failure per backend, so a transient probe error survives between
@@ -1707,6 +1996,14 @@ def active_backend_id() -> str:
return _auto_detect()
# Subprocess-isolated backends must be process-wide singletons: their
# ``__init__`` registers an atexit shutdown hook and the instance owns the
# sidecar child process, so a fresh instance per request would leak handler
# entries and respawn the sidecar (reloading its model) on every transcribe.
# Same rationale as api.routers.engines._ENGINE_INSTANCES.
_ISOLATED_INSTANCES: dict[str, "ASRBackend"] = {}
def get_active_asr_backend(*, asr_pipe=None) -> ASRBackend:
bid = active_backend_id()
if bid == "pytorch-whisper":
@@ -1719,7 +2016,14 @@ def get_active_asr_backend(*, asr_pipe=None) -> ASRBackend:
return WhisperXBackend()
if bid not in _REGISTRY:
raise ValueError(f"Unknown ASR backend: {bid!r}. Known: {list(_REGISTRY)}")
return _REGISTRY[bid]()
cls = _REGISTRY[bid]
if getattr(cls, "_is_subprocess_isolated", False):
inst = _ISOLATED_INSTANCES.get(bid)
if inst is None:
inst = cls()
_ISOLATED_INSTANCES[bid] = inst
return inst
return cls()
def transcribe_reference(audio_path: str) -> str | None:
@@ -1764,6 +2068,34 @@ _capture_backend: ASRBackend | None = None
# The sherpa model id the cached capture backend was built for, so a model
# switch in Settings rebuilds the singleton instead of serving the old model.
_capture_backend_key: str | None = None
# Guards the read-modify-write of the two globals above. Both the background
# capture-ASR preload (runs in the GPU-pool thread) and the live-dictation WS
# handlers (run on the event loop) resolve/replace the singleton, so the
# check-then-build must be atomic to avoid two threads each building a model.
_capture_backend_lock = threading.Lock()
def get_sherpa_dictation_backend(model_id: str) -> "SherpaDictationBackend":
"""Return a shared, warm-cached :class:`SherpaDictationBackend` for
``model_id``, building it at most once and reusing the recognizer across
live-dictation WS sessions.
Live sessions previously constructed a FRESH backend per WebSocket connect,
so every session reloaded the ONNX recognizer (1.32.5s "loading…") and the
#888 background preload was a no-op. This reuses the SAME module-level
``_capture_backend`` singleton the preload warms (when the ids match), and
rebuilds on a model switch identical invalidation to
:func:`get_capture_asr_backend`. Thread-safe: the recognizer is shared;
each session creates its own decode stream (see capture_ws)."""
global _capture_backend, _capture_backend_key
with _capture_backend_lock:
if (isinstance(_capture_backend, SherpaDictationBackend)
and _capture_backend_key == model_id):
return _capture_backend
backend = SherpaDictationBackend(model_id=model_id)
_capture_backend = backend
_capture_backend_key = model_id
return backend
def dictation_model_id() -> str | None:
@@ -1803,49 +2135,52 @@ def get_capture_asr_backend() -> ASRBackend:
"""
global _capture_backend, _capture_backend_key
# 0. Honor an explicit sherpa dictation model selection.
sherpa_id = dictation_model_id()
if sherpa_id:
ok, _ = SherpaDictationBackend.is_available()
# Atomic resolve+build so the preload thread and a WS session (which may
# call get_sherpa_dictation_backend concurrently) can't both build a model.
with _capture_backend_lock:
# 0. Honor an explicit sherpa dictation model selection.
sherpa_id = dictation_model_id()
if sherpa_id:
ok, _ = SherpaDictationBackend.is_available()
if ok:
if not (isinstance(_capture_backend, SherpaDictationBackend)
and _capture_backend_key == sherpa_id):
try:
_capture_backend = SherpaDictationBackend(model_id=sherpa_id)
_capture_backend_key = sherpa_id
except Exception as e: # noqa: BLE001 — fall through to Whisper
logger.warning(
"sherpa dictation model %r unavailable (%s) — falling "
"back to Whisper capture engine", sherpa_id, e,
)
_capture_backend = None
_capture_backend_key = None
if _capture_backend is not None:
return _capture_backend
else:
logger.info(
"dictation.model_id=%r selected but sherpa-onnx not installed — "
"falling back to Whisper capture engine", sherpa_id,
)
if _capture_backend is not None and _capture_backend_key is None:
return _capture_backend
# Prefer MLX Turbo on Apple Silicon
ok, _ = MLXWhisperBackend.is_available()
if ok:
if not (isinstance(_capture_backend, SherpaDictationBackend)
and _capture_backend_key == sherpa_id):
try:
_capture_backend = SherpaDictationBackend(model_id=sherpa_id)
_capture_backend_key = sherpa_id
except Exception as e: # noqa: BLE001 — fall through to Whisper
logger.warning(
"sherpa dictation model %r unavailable (%s) — falling "
"back to Whisper capture engine", sherpa_id, e,
)
_capture_backend = None
_capture_backend_key = None
if _capture_backend is not None:
return _capture_backend
else:
logger.info(
"dictation.model_id=%r selected but sherpa-onnx not installed — "
"falling back to Whisper capture engine", sherpa_id,
)
_capture_backend = MLXWhisperBackend(model_name=_MLX_MODEL_TURBO)
_capture_backend_key = None
return _capture_backend
if _capture_backend is not None and _capture_backend_key is None:
return _capture_backend
# Fall back to faster-whisper (CPU int8 on non-Apple)
ok, _ = FasterWhisperBackend.is_available()
if ok:
_capture_backend = FasterWhisperBackend()
_capture_backend_key = None
return _capture_backend
# Prefer MLX Turbo on Apple Silicon
ok, _ = MLXWhisperBackend.is_available()
if ok:
_capture_backend = MLXWhisperBackend(model_name=_MLX_MODEL_TURBO)
# Last resort
_capture_backend = PyTorchWhisperBackend()
_capture_backend_key = None
return _capture_backend
# Fall back to faster-whisper (CPU int8 on non-Apple)
ok, _ = FasterWhisperBackend.is_available()
if ok:
_capture_backend = FasterWhisperBackend()
_capture_backend_key = None
return _capture_backend
# Last resort
_capture_backend = PyTorchWhisperBackend()
_capture_backend_key = None
return _capture_backend
+23 -18
View File
@@ -1,9 +1,12 @@
"""
Audio DSP pipeline broadcast-grade mastering + configurable effects chain.
The default `apply_mastering()` is the same chain shipped since v0.1.0
(highpass + compressor + light reverb). The new `apply_effects_chain()`
lets callers build custom pipelines from a list of named effects.
`apply_mastering()` is the shared pre-stage that runs before the user's
effect preset: highpass + gentle compression only (see `MASTERING_CHAIN`).
Reverb is deliberately NOT part of it it is preset-declared only (e.g.
cinematic, warm); a hidden reverb here used to bake echo into every non-raw
synthesis, which field reports flagged. `apply_effects_chain()` lets callers
build custom pipelines from a list of named effects.
All effects use Spotify's `pedalboard` library. When pedalboard isn't
installed, every function degrades gracefully (returns audio unmodified).
@@ -97,24 +100,26 @@ def get_effect_chain(preset_id: str) -> list[dict]:
# ── Core DSP functions ──────────────────────────────────────────────────
#: Shared pre-preset mastering stage: highpass + gentle compression ONLY.
#: Reverb must never live here — a hidden Reverb in this chain baked echo
#: into every non-raw synthesis regardless of the chosen preset (field
#: reports of echoey voices; the podcast preset even promises "no reverb").
#: Reverb is preset-declared only (see EFFECT_PRESETS: cinematic, warm).
MASTERING_CHAIN = [
{"type": "highpass", "cutoff_hz": 60},
{"type": "compressor", "threshold_db": -15, "ratio": 1.5, "attack_ms": 2.0, "release_ms": 100},
]
def apply_mastering(audio_tensor, sample_rate=24000):
"""Applies professional Broadcast-grade DSP (EQ, Compressor, light Reverb) to the clone voice."""
"""Applies the broadcast pre-stage (highpass + gentle compression) to the clone voice.
Reverb is intentionally absent only user-chosen effect presets declare
it. Degrades gracefully: pedalboard missing or any DSP error returns the
input unmodified.
"""
try:
from pedalboard import Pedalboard, Compressor, Reverb, HighpassFilter
import numpy as np
board = Pedalboard([
HighpassFilter(cutoff_frequency_hz=60),
Compressor(threshold_db=-15, ratio=1.5, attack_ms=2.0, release_ms=100),
Reverb(room_size=0.10, wet_level=0.08, dry_level=0.95)
])
audio_np = audio_tensor.cpu().numpy()
if audio_np.ndim == 1:
audio_np = audio_np[np.newaxis, :]
effected = board(audio_np, sample_rate, reset=False)
return torch.from_numpy(effected).to(audio_tensor.device)
except ImportError:
return audio_tensor # Fail gracefully if pedalboard isn't installed
return apply_effects_chain(audio_tensor, sample_rate, MASTERING_CHAIN)
except Exception as e:
logger.warning("Mastering DSP Error: %s", e)
return audio_tensor
+21 -8
View File
@@ -104,9 +104,10 @@ def synthesize_chapter(
):
"""Render a chapter's spans to one waveform via an injected ``synth``.
``synth(text, voice_id, speed)`` returns a 1-D float32 audio tensor for a
span of text in the given voice (``speed`` may be ``None`` for the engine
default). Long spans are split with the ``chunked_tts`` splitter and
``synth(text, voice_id, speed)`` returns a float32 audio tensor 1-D
``(samples,)`` or ``(channels, samples)``; real engines emit ``(1, samples)``
per the ``TTSBackend`` contract (#897) — for a span of text in the given
voice (``speed`` may be ``None`` for the engine default). Long spans are split with the ``chunked_tts`` splitter and
crossfaded; inter-span ``pause_ms_after`` becomes silence. ``lexicon`` (when
given) respells each span's text before chunking so the engine pronounces
tricky words correctly; a ``None``/empty lexicon is a no-op pass-through.
@@ -118,23 +119,35 @@ def synthesize_chapter(
from services.chunked_tts import concatenate_audio_chunks, split_text_into_chunks
from services.pronunciation import apply_lexicon
parts: list = []
items: list = [] # ("a", tensor) for audio, ("s", n_samples) for silence
for span in spans:
if span.text:
chunks = split_text_into_chunks(apply_lexicon(span.text, lexicon))
rendered = [synth(c, span.voice_id, span.speed) for c in chunks]
rendered = [r for r in rendered if r is not None and getattr(r, "numel", lambda: 0)()]
if len(rendered) == 1:
parts.append(rendered[0])
items.append(("a", rendered[0]))
elif rendered:
parts.append(concatenate_audio_chunks(rendered, sample_rate, crossfade_ms=crossfade_ms))
items.append(("a", concatenate_audio_chunks(rendered, sample_rate, crossfade_ms=crossfade_ms)))
if span.pause_ms_after > 0:
n = int(sample_rate * span.pause_ms_after / 1000.0)
if n > 0:
parts.append(torch.zeros(n, dtype=torch.float32))
items.append(("s", n))
if not parts:
if not items:
return torch.zeros(0, dtype=torch.float32), 0.0
# Engines return (1, samples) per the TTSBackend contract while a bare
# zeros(n) is 1-D — mixing the two crashed the final concat (#897). So
# materialize inter-span silence AFTER the loop, matching the rendered
# audio's channel dims / dtype / device (same pattern as generation.py's
# _render_with_pauses). A silence-only chapter stays 1-D float32 as before.
ref = next((t for kind, t in items if kind == "a"), None)
parts: list = [
val if kind == "a"
else (torch.zeros(val, dtype=torch.float32) if ref is None
else torch.zeros(*ref.shape[:-1], val, dtype=ref.dtype, device=ref.device))
for kind, val in items
]
# Hard-concat spans + silences (crossfading silence would bleed the gap).
audio = parts[0] if len(parts) == 1 else concatenate_audio_chunks(parts, sample_rate, crossfade_ms=0)
return audio, audio.shape[-1] / float(sample_rate)
+32 -2
View File
@@ -183,14 +183,43 @@ def _safe_hard_cut(segment: str, max_chars: int) -> int:
return cut
def _normalize_chunk_shapes(chunks: list) -> list:
"""Coerce mixed-rank / mixed-channel chunks to one concat-compatible shape.
Engines return ``(1, samples)`` per the ``TTSBackend.generate`` contract,
but silence buffers and some model paths hand over bare ``(samples,)``
tensors ``torch.cat`` then dies with "Tensors must have same number of
dimensions" (#897). Promote lower-rank chunks with leading singleton dims
to the highest rank present, then broadcast singleton channel dims up to
the widest channel count (mono follows stereo). Rank-homogeneous,
channel-homogeneous input is returned untouched, so all-1-D / all-2-D
callers keep their exact output shape; a genuine channel conflict
(e.g. 2 vs 3 channels) still raises, which is the honest outcome.
"""
target = max(c.dim() for c in chunks)
if any(c.dim() != target for c in chunks):
promoted = []
for c in chunks:
while c.dim() < target:
c = c.unsqueeze(0)
promoted.append(c)
chunks = promoted
if target > 1:
lead = tuple(max(c.shape[i] for c in chunks) for i in range(target - 1))
chunks = [c if tuple(c.shape[:-1]) == lead else c.expand(*lead, -1)
for c in chunks]
return chunks
def concatenate_audio_chunks(chunks: list, sample_rate: int,
crossfade_ms: int = DEFAULT_CROSSFADE_MS):
"""Join per-chunk waveforms with a linear crossfade on the sample axis.
``chunks`` are torch tensors as returned by the engine (1-D, or N-D with
samples on the last axis matching what ``_render_with_pauses`` handles).
Crossfade overlap is clamped to the shorter neighbor; ``crossfade_ms=0``
is a hard concat.
Mixed ranks / mono-vs-multichannel chunks are normalized to one shape
first (#897), so no producer can crash the concat. Crossfade overlap is
clamped to the shorter neighbor; ``crossfade_ms=0`` is a hard concat.
"""
import torch
@@ -199,6 +228,7 @@ def concatenate_audio_chunks(chunks: list, sample_rate: int,
return torch.zeros(1, dtype=torch.float32)
if len(chunks) == 1:
return chunks[0]
chunks = _normalize_chunk_shapes(chunks)
crossfade_samples = int(sample_rate * crossfade_ms / 1000)
result = chunks[0]
+8 -1
View File
@@ -26,6 +26,10 @@ from services.llm_backend import get_active_llm_backend, OffBackend
logger = logging.getLogger("omnivoice.director")
# LLM Skills registry id — Settings → LLM Skills can disable the LLM parse
# or route it to a specific provider. Disabled == the heuristic parser.
_SKILL_ID = "direction_parse"
# ── Taxonomy (stable contract) ──────────────────────────────────────────────
# Additive per dimension — multiple values allowed. Unknown tokens are ignored
@@ -147,7 +151,10 @@ def parse(text: str) -> Direction:
if not text or not text.strip():
return Direction(source=text or "")
llm = get_active_llm_backend()
from services import llm_skills
# `active=` forwards this module's (monkeypatch-able) name so the
# no-override path is byte-identical to the pre-skills behavior.
llm = llm_skills.skill_backend(_SKILL_ID, active=lambda: get_active_llm_backend())
if isinstance(llm, OffBackend):
return _heuristic_parse(text)
+10
View File
@@ -187,6 +187,14 @@ def put_job(job_id: str, job: dict) -> None:
def save_job(job_id: str, job: dict, filename: str = "", duration: float = 0.0, content_hash: str = "") -> None:
"""Persist dub job state to SQLite so it survives restarts. Uses UPSERT
on `id` so repeated saves in a session keep the latest snapshot.
language / language_code / content_hash only update when the incoming
value is non-empty: the ingest-time insert runs before the target
language is known (both columns ""), generation sets them on the job
dict, and a later save from a job that lost them (e.g. hydrated from an
old row) must not clobber the healed columns back to "". The frontend
keys history restore off language_code, so a frozen "" hid finished
tracks until the user re-picked a language.
"""
try:
segments = job.get("segments") or []
@@ -200,6 +208,8 @@ def save_job(job_id: str, job: dict, filename: str = "", duration: float = 0.0,
filename=excluded.filename,
duration=excluded.duration,
segments_count=excluded.segments_count,
language=CASE WHEN excluded.language != '' THEN excluded.language ELSE dub_history.language END,
language_code=CASE WHEN excluded.language_code != '' THEN excluded.language_code ELSE dub_history.language_code END,
tracks=excluded.tracks,
job_data=excluded.job_data,
content_hash=CASE WHEN excluded.content_hash != '' THEN excluded.content_hash ELSE dub_history.content_hash END""",
+17 -3
View File
@@ -63,7 +63,21 @@ def resolve_routing(gpu_compat: tuple[str, ...], caps: HostCaps) -> RoutingResul
"routing_reason": _caveat(caps),
}
# 3. Host has an accelerator the engine lacks, but engine supports cpu
# 3. CPU-native engine (declares ONLY cpu) has nothing to fall back FROM,
# so on ANY accelerator host it is benign cpu_only (neutral), never a
# warn-tone "CPU fallback". This must precede the fallback rule below —
# a ("cpu",) engine matches `"cpu" in targets` too, and would otherwise
# be mis-classed cpu_fallback on a GPU/MPS host. (A cpu host reaches
# rule 5 unchanged, keeping its DirectML note.) Engines that *could*
# accelerate elsewhere (e.g. ("cuda", "cpu")) are untouched.
if fam != "cpu" and targets == ("cpu",):
return {
"effective_device": "cpu",
"routing_status": "cpu_only",
"routing_reason": None,
}
# 4. Host has an accelerator the engine lacks, but engine supports cpu
# → the no-silent-fallback signal.
if fam != "cpu" and "cpu" in targets:
if fam == "rocm" and "cuda" in targets and "rocm" not in targets:
@@ -76,7 +90,7 @@ def resolve_routing(gpu_compat: tuple[str, ...], caps: HostCaps) -> RoutingResul
"routing_reason": reason,
}
# 4. Genuine CPU-only host (or DirectML, which the probe reports as cpu)
# 5. Genuine CPU-only host (or DirectML, which the probe reports as cpu)
# and engine supports cpu → benign; must not warn or block.
if fam == "cpu" and "cpu" in targets:
reason = None
@@ -93,7 +107,7 @@ def resolve_routing(gpu_compat: tuple[str, ...], caps: HostCaps) -> RoutingResul
"routing_reason": reason,
}
# 5. Engine needs an accelerator this host lacks and has no cpu path.
# 6. Engine needs an accelerator this host lacks and has no cpu path.
first = targets[0]
return {
"effective_device": first,
+22 -2
View File
@@ -49,7 +49,7 @@ def _canon_value(field: str, value):
return value
def segment_fingerprint(seg: dict) -> str:
def segment_fingerprint(seg: dict, track_lang: str | None = None) -> str:
"""Deterministic hash of the inputs that actually affect TTS output.
Any change to `_GEN_INPUT_FIELDS` flips the hash and the segment becomes
@@ -61,8 +61,20 @@ def segment_fingerprint(seg: dict) -> str:
so a fingerprint computed from the generate request (server defaults
filled in) matches one recomputed later from the client's raw segment
state the root cause of #281's "1 edit re-dubs all N lines".
``track_lang`` (P1.3) is the TRACK's language code (`req.language_code`,
e.g. "es"). It is part of the fingerprint because the same segment text
renders different audio per language without it, a bn hash could
vouch for an es WAV on a multi-track job. It is only mixed in when
provided, so hashes computed by legacy callers (and hashes stored by
previous builds, which never carried a language) keep their old values;
a legacy hash therefore never matches a lang-scoped fingerprint and the
segment reads as stale the safe direction (one clean regen, never a
wrong-language splice).
"""
payload = {k: _canon_value(k, seg.get(k)) for k in _GEN_INPUT_FIELDS}
if track_lang:
payload["track_lang"] = str(track_lang)
blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)
return hashlib.sha1(blob.encode("utf-8"), usedforsecurity=False).hexdigest()[:16]
@@ -120,6 +132,7 @@ def plan_incremental(
segments: list[dict],
*,
stored_hashes: dict[str, str] | None = None,
track_lang: str | None = None,
) -> dict:
"""Return `{stale, fresh, total, fingerprints}` where:
@@ -133,6 +146,13 @@ def plan_incremental(
`stored_hashes` may come from the caller's own bookkeeping (e.g. the
`dub_history.job_data["seg_hashes"]` we'll start writing in Phase 4.5).
When missing, every segment is considered stale (first run).
`track_lang` (P1.3) scopes the plan to ONE dub track: pass the track's
language code together with THAT language's stored hashes
(`job_data["seg_hashes_by_lang"][lang]`) so staleness is judged against
the active track, never against whatever language was generated last.
Must match the language the generate run hashed with, or every segment
reads stale (#281 parity class).
"""
stored = stored_hashes or {}
stale: list[str] = []
@@ -142,7 +162,7 @@ def plan_incremental(
sid = str(seg.get("id", ""))
if not sid:
continue
fp = segment_fingerprint(seg)
fp = segment_fingerprint(seg, track_lang=track_lang)
fingerprints[sid] = fp
prev = stored.get(sid)
if prev == fp:
+33 -7
View File
@@ -54,9 +54,12 @@ class LLMBackend(ABC):
def model_name(self) -> str: ...
@abstractmethod
def chat(self, *, system: str, user: str, timeout: Optional[float] = None) -> str:
def chat(self, *, system: str, user: str, timeout: Optional[float] = None,
temperature: Optional[float] = None) -> str:
"""One-shot chat completion. Returns the assistant content string.
Raises on failure callers decide whether to fallback gracefully.
``temperature`` is only sent to the provider when set callers that
leave it None keep the provider default (existing behavior).
"""
@@ -67,8 +70,18 @@ class OpenAICompatBackend(LLMBackend):
id = "openai-compat"
display_name = "OpenAI-compatible (real OpenAI, Ollama, LM Studio, …)"
def __init__(self):
def __init__(self, provider=None):
"""``provider``: optional ``llm_providers.Provider`` to bind this
instance to (LLM Skills per-skill routing). None keeps the historical
behavior resolve the ACTIVE provider at call time."""
self._client = None
self._provider = provider
def _resolve_provider(self):
if self._provider is not None:
return self._provider
from services import llm_providers
return llm_providers.active_provider()
@classmethod
def is_available(cls) -> tuple[bool, str]:
@@ -97,7 +110,7 @@ class OpenAICompatBackend(LLMBackend):
@property
def model_name(self) -> str:
from services import llm_providers
p = llm_providers.active_provider()
p = self._resolve_provider()
if p is not None:
return llm_providers.resolve_model(p)
return os.environ.get("TRANSLATE_MODEL", "gpt-4o-mini")
@@ -107,7 +120,7 @@ class OpenAICompatBackend(LLMBackend):
return self._client
from openai import OpenAI
from services import llm_providers
p = llm_providers.active_provider()
p = self._resolve_provider()
if p is None:
raise RuntimeError("LLM not configured. See `is_available()` for the hint.")
base_url = llm_providers.resolve_base_url(p)
@@ -117,35 +130,48 @@ class OpenAICompatBackend(LLMBackend):
kw = {"api_key": api_key}
if base_url:
kw["base_url"] = base_url
self._client = OpenAI(**kw)
# max_retries=0 so a 429 + Retry-After can't make one chat() sleep
# through the Autofit fit-pass wall-clock budget (speech_rate).
self._client = OpenAI(max_retries=0, **kw)
return self._client
def chat(self, *, system: str, user: str, timeout: Optional[float] = None) -> str:
def chat(self, *, system: str, user: str, timeout: Optional[float] = None,
temperature: Optional[float] = None) -> str:
return self.chat_messages(
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
timeout=timeout,
temperature=temperature,
)
def chat_messages(self, *, messages: list[dict], timeout: Optional[float] = None) -> str:
def chat_messages(self, *, messages: list[dict], timeout: Optional[float] = None,
temperature: Optional[float] = None) -> str:
"""One-shot completion over a full message list.
Additive surface for callers that need structured few-shot turns
(dictation refinement, Wave 2.1) small local models pattern-match
and echo inline examples, so examples must arrive as prior chat
turns, not inside the system prompt.
``temperature`` is only forwarded when set (Cinematic/Autofit pin 0.2
the provider default of 1.0 makes local models drift and invent);
every other caller leaves it None and keeps the provider default.
"""
if timeout is None:
try:
timeout = float(os.environ.get("OMNIVOICE_LLM_TIMEOUT", "45"))
except ValueError:
timeout = 45.0
kw = {}
if temperature is not None:
kw["temperature"] = temperature
res = self._get_client().chat.completions.create(
model=self.model_name,
timeout=timeout,
messages=messages,
**kw,
)
return (res.choices[0].message.content or "").strip()
+138 -12
View File
@@ -24,10 +24,13 @@ users.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger("omnivoice.llm_providers")
# Settings-store row names (non-secret overrides live in the plaintext table;
# keys live in the encrypted secret table under ``llm_key.<id>``).
_ACTIVE_PROVIDER_KEY = "llm.active_provider"
@@ -172,17 +175,33 @@ def _env_first(names: tuple[str, ...]) -> Optional[str]:
return None
def resolve_base_url(p: Provider) -> str:
def resolve_account_id(p: Provider) -> str:
"""The Cloudflare-style account id: env override → stored → empty."""
from services import settings_store
return (
(p.account_env and os.environ.get(p.account_env))
or settings_store.get_text(f"llm.account.{p.id}")
or ""
)
def resolve_base_url(p: Provider, *, substitute: bool = True) -> str:
"""Resolve a provider's base URL (env → stored override → default).
``substitute`` interpolates ``{account_id}`` for account-scoped providers
(Cloudflare) so the *client* gets a working URL. The UI passes
``substitute=False`` so the field shows/saves the raw template baking the
substituted value back into a stored override would freeze the URL and make
later account-id changes silently no-op (the bug this guards against).
"""
from services import settings_store
val = (
(p.base_url_env and os.environ.get(p.base_url_env))
or settings_store.get_text(_BASE_URL_KEY + p.id)
or p.default_base_url
)
if p.needs_account and val and "{account_id}" in val:
acct = (p.account_env and os.environ.get(p.account_env)) or \
settings_store.get_text(f"llm.account.{p.id}") or ""
val = val.replace("{account_id}", acct)
if substitute and p.needs_account and val and "{account_id}" in val:
val = val.replace("{account_id}", resolve_account_id(p))
return val or ""
@@ -232,6 +251,19 @@ def is_configured(p: Provider) -> bool:
# ── Active provider selection ─────────────────────────────────────────────
def stored_active_provider_id() -> Optional[str]:
"""The user's explicitly-persisted selection ONLY — no env pin, no legacy
TRANSLATE_* fallback, no auto-detect.
``None`` means the user has never chosen a provider. This is what gates
save-activates in the settings router (#963): an explicit save may claim
the *empty* slot, but must never steal it from a made choice.
"""
from services import settings_store
stored = settings_store.get_text(_ACTIVE_PROVIDER_KEY)
return stored if stored and stored in _BY_ID else None
def active_provider_id() -> Optional[str]:
"""The provider Cinematic/Autofit should use.
@@ -239,12 +271,11 @@ def active_provider_id() -> Optional[str]:
configured provider None. Legacy ``TRANSLATE_BASE_URL`` users with no
explicit selection resolve to ``custom`` (its envs are TRANSLATE_*).
"""
from services import settings_store
env_pick = os.environ.get("LLM_DEFAULT_PROVIDER")
if env_pick and env_pick in _BY_ID:
return env_pick
stored = settings_store.get_text(_ACTIVE_PROVIDER_KEY)
if stored and stored in _BY_ID:
stored = stored_active_provider_id()
if stored:
return stored
# Legacy: a lone TRANSLATE_BASE_URL means the old single-endpoint setup.
if os.environ.get("TRANSLATE_BASE_URL"):
@@ -286,26 +317,121 @@ def save_overrides(pid: str, *, base_url: Optional[str] = None,
from services import settings_store
if pid not in _BY_ID:
raise ValueError(f"unknown provider {pid!r}")
p = _BY_ID[pid]
if base_url is not None:
settings_store.set_text(_BASE_URL_KEY + pid, base_url.strip())
bu = base_url.strip()
# Never freeze an override that equals the built-in default. Critical
# for account-templated URLs (Cloudflare): persisting the shown value
# would pin the base_url and stop later account-id edits from taking
# effect. Clearing (→ empty) falls the resolver back to the default
# template so substitution stays live. Also self-heals a stale override
# if a provider's default URL changes in a future release.
settings_store.set_text(_BASE_URL_KEY + pid, "" if bu == p.default_base_url else bu)
if model is not None:
settings_store.set_text(_MODEL_KEY + pid, model.strip())
if account_id is not None:
settings_store.set_text(f"llm.account.{pid}", account_id.strip())
def _active_env_pin() -> Optional[str]:
"""The provider id pinned by ``LLM_DEFAULT_PROVIDER`` (if set + valid)."""
pick = os.environ.get("LLM_DEFAULT_PROVIDER")
return pick if pick and pick in _BY_ID else None
def describe(p: Provider) -> dict:
"""Client-safe provider descriptor — NEVER includes the key material."""
return {
"""Client-safe provider descriptor — NEVER includes the key material.
The ``*_from_env`` booleans mirror ``key_from_env`` so the UI can disable an
env-pinned field (and the make-active button) with an explainer instead of
letting the user edit a value the resolver will silently override. ``base_url``
is the RAW template (``substitute=False``) so an account-scoped default shows
``{account_id}`` rather than a baked-in value; ``account_id`` is returned
separately for account-scoped providers so the field can round-trip.
"""
d = {
"id": p.id,
"display_name": p.display_name,
"local": p.local,
"needs_account": p.needs_account,
"signup_url": p.signup_url,
"notes": p.notes,
"base_url": resolve_base_url(p),
"base_url": resolve_base_url(p, substitute=False),
"model": resolve_model(p),
"has_key": has_key(p),
"key_from_env": bool(_env_first(p.key_envs)),
"base_url_from_env": bool(p.base_url_env and os.environ.get(p.base_url_env)),
"model_from_env": bool(p.model_env and os.environ.get(p.model_env)),
"active_from_env": _active_env_pin() is not None,
"configured": is_configured(p),
}
if p.needs_account:
d["account_id"] = resolve_account_id(p)
d["account_from_env"] = bool(p.account_env and os.environ.get(p.account_env))
return d
# ── Legacy TRANSLATE_* prefs migration (#963) ──────────────────────────────
# prefs.json row → the custom-provider field it becomes.
_LEGACY_TRANSLATE_PREFS: tuple[tuple[str, str], ...] = (
("env.TRANSLATE_BASE_URL", "base_url"),
("env.TRANSLATE_MODEL", "model"),
("env.TRANSLATE_API_KEY", "api_key"),
)
def migrate_legacy_translate_prefs() -> bool:
"""Move the retired (≤v0.3.7) Translation-LLM panel's prefs rows into the
``custom`` provider's own settings-store rows, then delete them.
Those ``env.TRANSLATE_*`` rows in prefs.json are re-imported into
``os.environ`` on every launch (main.py), and a live ``TRANSLATE_BASE_URL``
makes :func:`active_provider_id` resolve to ``custom`` ahead of the stored
selection fallbacks silently hijacking the active slot on every restart
(issue #963, "Ollama works until I restart"). Must run BEFORE main.py's
prefsenv import so the rows never reach the environment.
Semantics:
* Each value is copied only where the store has no value yet a user's
later edit of the custom provider always wins over legacy leftovers.
* The prefs row is deleted afterwards either way, so it can never be
re-imported as env again (the migration is one-shot per row).
* Real process env vars are NEVER touched a shell/.env
``TRANSLATE_BASE_URL`` keeps its documented override behavior.
* A row whose store write fails is kept in prefs (it still works via the
env import this launch and the migration retries next launch).
Returns True if any prefs row was migrated/removed.
"""
from core import prefs
from services import settings_store
changed = False
for prefs_key, field in _LEGACY_TRANSLATE_PREFS:
try:
raw = prefs.get(prefs_key)
except Exception:
logger.exception("legacy TRANSLATE prefs read failed (%s)", prefs_key)
return changed
if raw is None:
continue
val = str(raw).strip()
try:
if val:
if field == "base_url":
if not settings_store.get_text(_BASE_URL_KEY + "custom"):
save_overrides("custom", base_url=val)
elif field == "model":
if not settings_store.get_text(_MODEL_KEY + "custom"):
save_overrides("custom", model=val)
else: # api_key — encrypted store, never overwrite an existing one
if not _key_in_store("custom"):
save_key("custom", val)
prefs.delete(prefs_key)
changed = True
except Exception:
# Store not ready (e.g. settings table missing) — keep the prefs
# row so the legacy env import still works and we retry next boot.
logger.exception("legacy TRANSLATE prefs migration failed (%s)", prefs_key)
return changed
+323
View File
@@ -0,0 +1,323 @@
"""LLM Skills registry — per-feature enable/route control for every LLM call.
Every LLM-powered capability ("skill") in the backend is registered here, so
the Settings LLM Skills panel can (a) toggle it and (b) route it to a
specific provider (a local Ollama/LM Studio vs a remote key) instead of
everything riding the one global active provider.
The six consumption points today:
dub_translation api/routers/dub_translate.py (the Dub tab's direct
"LLM" translation engine; provider=openai branch)
cinematic_translation services/translator.py (Cinematic + Autofit
REFLECT/ADAPT rewrite; dub_translate quality gate)
slot_fitting services/speech_rate.py (trim/expand a line to its
time slot; Autofit strict pass + /tools/rate-fit)
glossary_extract api/routers/glossary.py auto-extract
direction_parse services/director.py (natural-language direction
taxonomy tokens; /tools/direction + dub generate)
dictation_refinement services/refinement.py (dictation transcript
cleanup on finals)
Design rules:
* **Disabled == unconfigured.** A disabled skill degrades through the exact
same path the feature takes today when no LLM is configured (Fast
translation fallback, refinement pass-through, heuristic direction parse,
no-llm slot fit, 503 on glossary auto-extract). No new degradation modes.
* **Override > active > none.** A per-skill provider override (persisted in
settings_store) wins over the global active provider. No override the
active provider, resolved exactly as before (so existing setups see zero
behavior change; all skills default to enabled with no override).
* **Persistence** is two plaintext settings rows per skill:
``llm_skill.<id>.enabled`` ("1"/"0", absent = enabled) and
``llm_skill.<id>.provider`` (provider id, absent/empty = active provider).
Keys stay in the provider registry (encrypted) nothing secret here.
* ``OMNIVOICE_LLM_BACKEND=off`` remains the global kill switch: it also
silences skills routed through a per-skill override.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from typing import Any, Callable, Optional
logger = logging.getLogger("omnivoice.llm_skills")
_ENABLED_KEY = "llm_skill.{sid}.enabled"
_PROVIDER_KEY = "llm_skill.{sid}.provider"
_UNSET = object()
@dataclass(frozen=True)
class LLMSkill:
"""A registered LLM consumption point. name/description resolve via the
frontend i18n layer (localization hard rule no hardcoded UI text)."""
id: str
name_key: str
description_key: str
def _skill(sid: str) -> LLMSkill:
return LLMSkill(
id=sid,
name_key=f"settings.llmskills_{sid}_name",
description_key=f"settings.llmskills_{sid}_desc",
)
# Display order in the settings panel: the dub pipeline first (translation →
# refine → fit → glossary → direction), then dictation.
_SKILLS: tuple[LLMSkill, ...] = (
_skill("dub_translation"),
_skill("cinematic_translation"),
_skill("slot_fitting"),
_skill("glossary_extract"),
_skill("direction_parse"),
_skill("dictation_refinement"),
)
_BY_ID: dict[str, LLMSkill] = {s.id: s for s in _SKILLS}
def all_skills() -> tuple[LLMSkill, ...]:
return _SKILLS
def get_skill(skill_id: str) -> Optional[LLMSkill]:
return _BY_ID.get(skill_id)
# ── Persistence (settings_store text rows) ─────────────────────────────────
def is_enabled(skill_id: str) -> bool:
"""Skill toggle. Absent row = enabled (all skills default on)."""
from services import settings_store
raw = settings_store.get_text(_ENABLED_KEY.format(sid=skill_id))
return raw != "0"
def provider_override(skill_id: str) -> Optional[str]:
"""The per-skill provider id, or None when the skill follows the active
provider. A stored id that no longer exists in the registry reads as None
(stale override resolution falls back to the active provider)."""
from services import llm_providers, settings_store
raw = (settings_store.get_text(_PROVIDER_KEY.format(sid=skill_id)) or "").strip()
if not raw:
return None
if llm_providers.get_provider(raw) is None:
logger.warning("llm_skills: stale provider override %r on %s — ignoring",
raw, skill_id)
return None
return raw
def configure_skill(skill_id: str, *, enabled: Optional[bool] = None,
provider_override: Any = _UNSET) -> None:
"""Persist a skill's toggle and/or provider routing.
``provider_override``: omit to leave unchanged; ``None``/``""`` clears it
(skill follows the active provider); a provider id routes the skill there.
Raises KeyError for an unknown skill, ValueError for an unknown provider.
"""
if skill_id not in _BY_ID:
raise KeyError(f"unknown LLM skill {skill_id!r}. Known: {sorted(_BY_ID)}")
from services import llm_providers, settings_store
if enabled is not None:
settings_store.set_text(_ENABLED_KEY.format(sid=skill_id),
"1" if enabled else "0")
if provider_override is not _UNSET:
pid = (provider_override or "").strip()
if pid and llm_providers.get_provider(pid) is None:
raise ValueError(f"unknown provider {pid!r}")
settings_store.set_text(_PROVIDER_KEY.format(sid=skill_id), pid)
# ── Resolution (override > active > none) ──────────────────────────────────
@dataclass(frozen=True)
class SkillResolution:
skill: LLMSkill
enabled: bool
provider: Optional[Any] # llm_providers.Provider or None
source: str # "override" | "active" | "none"
ready: bool
reason: Optional[str] # None | "disabled" | "no_provider" | "unconfigured"
def resolve_skill(skill_id: str) -> SkillResolution:
"""Resolve a skill's effective provider + ready status.
Precedence: per-skill override global active provider none. Ready
means enabled AND the effective provider is configured end-to-end.
Raises KeyError for an unknown skill.
"""
skill = _BY_ID.get(skill_id)
if skill is None:
raise KeyError(f"unknown LLM skill {skill_id!r}. Known: {sorted(_BY_ID)}")
from services import llm_providers
enabled = is_enabled(skill_id)
override = provider_override(skill_id)
if override:
provider = llm_providers.get_provider(override)
source = "override"
else:
provider = llm_providers.active_provider()
source = "active" if provider is not None else "none"
if not enabled:
ready, reason = False, "disabled"
elif provider is None:
ready, reason = False, "no_provider"
elif not llm_providers.is_configured(provider):
ready, reason = False, "unconfigured"
else:
ready, reason = True, None
return SkillResolution(skill=skill, enabled=enabled, provider=provider,
source=source, ready=ready, reason=reason)
def effective_provider(skill_id: str) -> Optional[Any]:
"""The provider a skill would call (override or active), or None."""
return resolve_skill(skill_id).provider
# ── Client / backend construction ───────────────────────────────────────────
@dataclass(frozen=True)
class SkillClient:
"""A ready-to-call OpenAI-compatible client bound to the skill's provider."""
client: Any # openai.OpenAI
model: str
provider_id: str
timeout: float
def _default_timeout() -> float:
try:
return float(os.environ.get("OMNIVOICE_LLM_TIMEOUT", "45"))
except ValueError:
return 45.0
def resolve_skill_client(skill_id: str) -> Optional[SkillClient]:
"""OpenAI-compat client + model for a skill, or None.
None when the skill is disabled, no provider resolves, the provider is
unconfigured, or the openai package is missing callers treat None
exactly like "no LLM configured" (their existing degradation path).
Raises KeyError for an unknown skill (programming error, not user state).
"""
res = resolve_skill(skill_id)
if not res.ready:
return None
try:
from openai import OpenAI
except ImportError:
logger.warning("openai package not installed — LLM skill %s unavailable.",
skill_id)
return None
from services import llm_providers
api_key = llm_providers.resolve_api_key(res.provider)
if not api_key:
return None
kw: dict[str, Any] = {"api_key": api_key}
base_url = llm_providers.resolve_base_url(res.provider)
if base_url:
kw["base_url"] = base_url
# max_retries=0: a rate-limited provider returning 429 + a long Retry-After
# would otherwise let the SDK sleep+retry inside a single call, blowing the
# skill's wall-clock budget (the cinematic pass budget, the glossary call
# timeout) from inside one request. Fail fast — the per-call timeout and the
# pass-level budget are the only bounds we want. Mirrors OpenAICompatBackend.
#
# #959 class guard: OpenAI() eagerly builds its httpx client, which can
# raise AT CONSTRUCTION for environment-shaped reasons — the reported one
# is httpx's ImportError under ALL_PROXY/HTTPS_PROXY=socks5:// without
# socksio; a malformed proxy URL or broken cert bundle fails the same way.
# The contract here is already "None == LLM unavailable, degrade" — a bad
# proxy env must degrade the skill, never 500 the calling feature.
try:
client = OpenAI(max_retries=0, **kw)
except Exception as exc:
logger.warning(
"LLM client construction failed for skill %s (provider %s): %s"
"treating the skill as unavailable.",
skill_id, res.provider.id, exc,
)
return None
return SkillClient(
client=client,
model=llm_providers.resolve_model(res.provider),
provider_id=res.provider.id,
timeout=_default_timeout(),
)
def skill_backend(skill_id: str, active: Optional[Callable[[], Any]] = None):
"""LLMBackend for a skill — the drop-in for ``get_active_llm_backend()``.
* disabled skill OffBackend (same object the no-LLM path returns today,
so every caller's ``id == "off"`` / ``isinstance(…, OffBackend)`` check
degrades identically);
* no override the ``active`` callable (callers pass their module-local
``get_active_llm_backend`` so existing monkeypatch seams keep working),
defaulting to ``llm_backend.get_active_llm_backend`` the exact legacy
path, env/prefs overrides included;
* override an OpenAICompatBackend bound to that provider, or OffBackend
when the provider is unconfigured, openai is missing, or the global
``OMNIVOICE_LLM_BACKEND=off`` kill switch is set.
"""
from services.llm_backend import OffBackend, OpenAICompatBackend
res = resolve_skill(skill_id)
if not res.enabled:
return OffBackend()
if res.source != "override":
if active is not None:
return active()
from services import llm_backend
return llm_backend.get_active_llm_backend()
if os.environ.get("OMNIVOICE_LLM_BACKEND") == "off":
return OffBackend()
if not res.ready:
return OffBackend()
try:
import openai # noqa: F401
except ImportError:
return OffBackend()
return OpenAICompatBackend(provider=res.provider)
# ── API descriptor ──────────────────────────────────────────────────────────
def describe(skill_id: str) -> dict:
"""Client-safe skill descriptor for GET /api/settings/llm-skills."""
res = resolve_skill(skill_id)
p = res.provider
return {
"id": res.skill.id,
"name_key": res.skill.name_key,
"description_key": res.skill.description_key,
"enabled": res.enabled,
"provider_override": provider_override(skill_id),
"provider": p.id if p is not None else None,
"provider_display_name": p.display_name if p is not None else None,
"provider_local": p.local if p is not None else None,
"provider_source": res.source,
"ready": res.ready,
"reason": res.reason,
}
+24
View File
@@ -45,11 +45,33 @@ def _asr_device() -> str:
return "cpu"
def _active_tts_id() -> Optional[str]:
"""Configured TTS engine id, or None if it can't be resolved. Attribution
is advisory a prefs/import hiccup must never break /model/loaded."""
try:
from services.tts_backend import active_backend_id
return active_backend_id()
except Exception:
return None
def _tts_attribution(engine_id: str, active: Optional[str]) -> dict:
"""Per-entry engine attribution for TTS-family models. A model can stay
resident in VRAM after the user switches engines (freed only by unload/
idle-evict), so the panel needs to know which entry synthesis actually
routes to. ``is_active_engine`` is None when the active id is unknown."""
return {
"engine_id": engine_id,
"is_active_engine": (engine_id == active) if active is not None else None,
}
def list_loaded() -> dict:
"""Enumerate every currently-loaded model. Shape: ``{"models": [...],
"count": n}`` with per-model id/name/checkpoint/device/vram_mb/unloadable
(+ optional ``note``)."""
models: list[dict] = []
active_tts = _active_tts_id()
# 1. In-process TTS model (OmniVoice)
if mm.model is not None:
@@ -64,6 +86,7 @@ def list_loaded() -> dict:
"device": device,
"vram_mb": round(_tts_vram_mb(), 1),
"unloadable": True,
**_tts_attribution("omnivoice", active_tts),
})
# 2. ASR (WhisperX) — co-loaded with and released alongside the TTS model.
@@ -105,6 +128,7 @@ def list_loaded() -> dict:
"device": get_best_device(),
"vram_mb": round(float(s.get("vram_mb") or 0), 1),
"unloadable": True,
**_tts_attribution(s["id"], active_tts),
})
except Exception:
pass
+117 -17
View File
@@ -249,15 +249,40 @@ async def run_on_gpu_pool_guarded(fn, *, what: str = "GPU job",
)
except Exception:
logger.exception("GPU pool reset after %s timeout failed", what)
raise GpuJobTimeoutError(
f"{what} exceeded {timeout:.0f}s and was abandoned — the backend is "
"running, but the job was too heavy for the available compute. Most "
"often the GPU is VRAM-starved (a resident model and this job contend "
"for memory). Capacity was restored automatically; for a durable fix "
"try shorter text, a lighter engine, or set the engine to CPU in "
"Settings → Models. (Raise OMNIVOICE_GENERATE_TIMEOUT_S for very long "
"single generations.)"
raise GpuJobTimeoutError(_timeout_guidance(what, timeout))
def _timeout_guidance(what: str, timeout: float) -> str:
"""Device-aware timeout message (#896): a CPU-only host must never be told
to "set the engine to CPU" or blamed on VRAM on CPU the job is simply
compute-bound. GPU hosts keep the VRAM-contention guidance."""
family = "cuda" # conservative default: GPU wording if the probe fails
try:
from core.device_caps import detect_host_caps
family = detect_host_caps().family
except Exception: # noqa: BLE001 — guidance must never mask the timeout
pass
common = (
f"{what} exceeded {timeout:.0f}s and was abandoned — the backend is "
"running, but the job was too heavy for the available compute. "
"Capacity was restored automatically; "
)
if family == "cpu":
return common + (
"this machine renders on CPU, where long generations are "
"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."
)
return common + (
"most often the GPU is VRAM-starved (a resident model and this job "
"contend for memory). For a durable fix, Flush caches / Unload the "
"resident model (top toolbar or Settings → Models) before retrying, "
"try shorter text, a lighter engine, or set the engine to CPU in "
"Settings → Models. (Raise OMNIVOICE_GENERATE_TIMEOUT_S for very "
"long single generations.)"
)
model = None # type: ignore
@@ -632,6 +657,29 @@ def _hf_offline() -> bool:
return _env_flag("HF_HUB_OFFLINE") or _env_flag("TRANSFORMERS_OFFLINE")
# Why the LAST _repair_model_cache run failed ("" when it succeeded / hasn't
# run). #886: the "could not be auto-repaired" message used to drop the cause
# entirely, so a mirror outage, offline mode, or a full disk all read the same.
_last_repair_error: str = ""
def _repair_failure_detail() -> str:
"""One sanitized clause naming why auto-repair failed, or "" (#886).
Feeds user-facing messages (the generate 500 detail / model status), so it
goes through core.failure.sanitize and because the cause text is now part
of the surfaced error, the shared HF-mirror hint (#874) fires on it when
the repair failed against an unreachable configured mirror."""
if not _last_repair_error:
return ""
try:
from core.failure import sanitize
cause = sanitize(_last_repair_error)
except Exception:
cause = _last_repair_error
return f" Auto-repair failed with: {cause}."
def _repair_model_cache(checkpoint: str, *, force: bool = False) -> bool:
"""Re-fetch a checkpoint's missing files in place and report success.
@@ -648,16 +696,22 @@ def _repair_model_cache(checkpoint: str, *, force: bool = False) -> bool:
size won't be re-fetched by the default resume (#739). It re-downloads the
whole snapshot, so it's the last resort the load path only reaches after a
plain resume-repair didn't fix the cache."""
global _last_repair_error
_last_repair_error = ""
if _hf_offline():
logger.warning(
"Model cache for %s is incomplete but HF offline mode is set — "
"cannot auto-repair.", checkpoint,
)
_last_repair_error = (
"Hugging Face offline mode is enabled (HF_HUB_OFFLINE/TRANSFORMERS_OFFLINE)"
)
return False
try:
from huggingface_hub import snapshot_download
except Exception as imp_err: # pragma: no cover - huggingface_hub is a hard dep
logger.warning("Cannot import snapshot_download to repair cache: %s", imp_err)
_last_repair_error = f"{type(imp_err).__name__}: {imp_err}"
return False
dl_kwargs: dict = {"repo_id": checkpoint}
endpoint = os.environ.get("HF_ENDPOINT")
@@ -710,6 +764,7 @@ def _repair_model_cache(checkpoint: str, *, force: bool = False) -> bool:
"Auto-repair of %s attempt %d/%d failed: %s",
checkpoint, attempt, retries, e,
)
_last_repair_error = f"{type(e).__name__}: {e}"
if attempt < retries and backoff:
time.sleep(backoff * attempt)
return False
@@ -801,7 +856,8 @@ def _load_model_sync():
if not _repair_model_cache(checkpoint):
raise RuntimeError(
f"The TTS model cache for {checkpoint} is incomplete "
"(weights missing — usually an interrupted download). "
"(weights missing — usually an interrupted download)."
f"{_repair_failure_detail()} "
"Open Settings → Models, delete the OmniVoice TTS model, "
"and install it again."
) from e
@@ -830,8 +886,9 @@ def _load_model_sync():
else:
raise RuntimeError(
f"The TTS model cache for {checkpoint} is incomplete and "
"could not be auto-repaired. Open Settings → Models, "
"delete the OmniVoice TTS model, and install it again."
f"could not be auto-repaired.{_repair_failure_detail()} "
"Open Settings → Models, delete the OmniVoice TTS model, "
"and install it again."
) from e2
else:
raise RuntimeError(
@@ -900,7 +957,13 @@ def _load_model_sync():
except Exception: # never let failure-formatting mask the real error
err_msg = str(exc)
_set_loading("error", "Model loading failed", error=err_msg)
logger.error("Model loading failed: %s", str(exc))
# #1000 class: transformers' lazy-import machinery wraps ANY disruption
# to an inner import (including one interrupted by process teardown)
# in a generic "Could not import module X. Are this object's
# requirements defined correctly?" — logging only str(exc) discarded
# the real cause in __cause__/__context__ and made a shutdown race
# look like a broken install. exc_info surfaces the full chain.
logger.error("Model loading failed: %s", str(exc), exc_info=exc)
raise
finally:
unregister_listener(lid)
@@ -967,6 +1030,22 @@ async def get_model():
return model
def _checkpoint_in_local_cache(checkpoint: str) -> bool:
"""True when ``checkpoint`` is loadable with NO network: an existing local
directory, or a COMPLETE HF cache snapshot. ``snapshot_download(...,
local_files_only=True)`` never constructs an HTTP session, so a broken
proxy env (#959: ``ALL_PROXY``/``HTTPS_PROXY=socks5://`` without socksio)
can't false-negative this probe. Never raises."""
if os.path.isdir(checkpoint):
return True
try:
from huggingface_hub import snapshot_download
snapshot_download(checkpoint, local_files_only=True)
return True
except Exception:
return False
async def preload_model():
"""Background model warm-up — call from lifespan startup.
@@ -987,10 +1066,27 @@ async def preload_model():
try:
from huggingface_hub import model_info
model_info(checkpoint, timeout=5)
except Exception:
# Model not downloaded yet — skip preload
logger.info("Preload skipped: %s not available locally.", checkpoint)
return
except Exception as probe_err:
# The probe failing does NOT mean the model isn't installed — it
# means the Hub API wasn't reachable from this process. The #959
# class: under ALL_PROXY/HTTPS_PROXY=socks5:// without socksio,
# hf_hub's get_session() raises ImportError AT CLIENT CONSTRUCTION;
# same story for offline mode, DNS, or firewall failures. Fall back
# to a cache-only probe (no HTTP session involved) and warm up
# anyway when the model is locally present, instead of silently
# skipping and letting the first /generate eat the full load.
if not _checkpoint_in_local_cache(checkpoint):
logger.info(
"Preload skipped: %s not available locally (network probe "
"failed: %s: %s).",
checkpoint, type(probe_err).__name__, probe_err,
)
return
logger.warning(
"Network probe for %s failed (%s: %s) — model found in the "
"local cache; warming up from cache.",
checkpoint, type(probe_err).__name__, probe_err,
)
logger.info("Preloading TTS model in background…")
_last_used = time.time()
@@ -999,7 +1095,11 @@ async def preload_model():
model = await _load_model_with_timeout()
logger.info("Preload complete — model ready.")
except Exception as e:
logger.warning("Model preload failed (non-fatal): %s", e)
# See the matching exc_info note on the _load_model_sync handler above
# (#1000 class) — the full chain, not just str(e), is what actually
# distinguishes a real dependency problem from a shutdown-interrupted
# import.
logger.warning("Model preload failed (non-fatal): %s", e, exc_info=e)
def get_model_status():
is_loaded = model is not None
+111 -7
View File
@@ -10,8 +10,8 @@ plays the moment the video begins and everything feels desynchronised.
``snap_segment_starts`` post-processes segments against the actual audio
(ideally the Demucs-isolated vocals track, which the dub pipeline already
produces): for each segment it scans the waveform inside ``[start, end]``
for the first frame whose RMS rises above an adaptive threshold and moves
``start`` forward to just before that onset.
for the first *sustained* rise of frame RMS above an adaptive threshold
and moves ``start`` forward to just before that onset.
Design constraints:
@@ -23,6 +23,27 @@ Design constraints:
(no frame above the absolute floor) are left untouched.
* **Pure NumPy.** No model, no platform-specific code identical
behaviour on macOS / Windows / Linux, trivially unit-testable.
Robustness against non-speech onsets (#963): a field report showed dubbed
lines starting seconds off because "when a noise is heard (a sigh or
footsteps), it's interpreted as the start of the conversation". Three
guards address that class of failure:
* **Sustained energy.** A frame only counts as an onset when the energy
stays up for a speech-like duration (``SUSTAIN_MIN_S`` within the
following ``SUSTAIN_WINDOW_S``). Footsteps/door thuds/clicks light up
one or two 20 ms frames and die; syllables keep the energy up.
* **Bounded snap distance.** Shifts beyond ``MAX_SNAP_S`` are only
trusted when everything being skipped is (near-)silence the genuine
#280 whisper start-stretch, where Demucs removed the leading music and
left real silence on the vocals track. Jumping far over *audible*
content (e.g. quiet speech sitting under the relative threshold) would
play the dub seconds late, so it is refused.
* **Source-aware.** Snapping only runs on a separated vocals track
(``separated_vocals=True``). On mixed/original audio Demucs skipped
or failed music, ambience and room tone are all legitimate sustained
energy, so any detected "onset" is as likely the score as the speaker;
whisper's own timestamps beat a confidently wrong snap.
"""
from __future__ import annotations
@@ -51,6 +72,23 @@ RELATIVE_THRESHOLD = 0.10
# the whole window is treated as silence and left alone (we'd only be
# snapping to noise).
ABS_RMS_FLOOR = 1e-3
# An onset must be *sustained* to count as speech (#963): within the
# SUSTAIN_WINDOW_S that follows a candidate frame, at least SUSTAIN_MIN_S
# worth of frames must also sit above the threshold. A ~100 ms footstep
# burst fails this; real speech (syllables every few hundred ms) passes.
SUSTAIN_WINDOW_S = 0.30
SUSTAIN_MIN_S = 0.16
# Snaps larger than this are only trusted when the skipped span is
# (near-)silence — see _region_mostly_silent (#963).
MAX_SNAP_S = 1.5
# The skipped span counts as "mostly silent" when at most this fraction of
# its frames is audible. Non-zero so an isolated transient bleeding through
# separation (a footstep) doesn't block a genuine long silence-trim…
SKIPPED_AUDIBLE_FRAC = 0.10
# …where "audible" = above max(ABS_RMS_FLOOR, this fraction of the span's
# own peak); the relative term keeps a slightly raised residual noise floor
# from reading as content.
SKIPPED_FLOOR_PEAK_FRAC = 0.02
def _frame_rms(x: np.ndarray, frame_len: int) -> np.ndarray:
@@ -70,6 +108,15 @@ def detect_speech_onset(
) -> float | None:
"""Return the absolute time (s) of the first speech-like frame inside
``[start_s, end_s]``, or ``None`` when the window is empty / silent.
"Speech-like" requires *sustained* energy (#963): within the
``SUSTAIN_WINDOW_S`` look-ahead after a candidate frame, at least
``SUSTAIN_MIN_S`` worth of frames must also exceed the threshold.
Short broadband transients footsteps, door thuds, mouse clicks
light up one or two 20 ms frames and then die, so they no longer read
as "the conversation started here"; real speech keeps the energy up
across syllables. A candidate too close to the window's end to prove
sustain is rejected (conservative: the ASR timestamp stands).
"""
if sr <= 0 or end_s <= start_s:
return None
@@ -86,10 +133,21 @@ def detect_speech_onset(
if peak < ABS_RMS_FLOOR:
return None # whole window is effectively silent
threshold = max(RELATIVE_THRESHOLD * peak, ABS_RMS_FLOOR)
above = np.nonzero(rms >= threshold)[0]
if above.size == 0:
above = rms >= threshold
candidates = np.nonzero(above)[0]
if candidates.size == 0:
return None
return start_s + float(above[0]) * (frame_len / sr)
frame_s = frame_len / sr
win_frames = max(1, int(round(SUSTAIN_WINDOW_S / frame_s)))
need_frames = max(1, int(round(SUSTAIN_MIN_S / frame_s)))
# counts[k] = above-threshold frames within rms[c : c + win_frames]
# for candidate c — O(n) via a cumulative sum, no per-candidate scan.
cum = np.concatenate(([0], np.cumsum(above)))
counts = cum[np.minimum(candidates + win_frames, above.size)] - cum[candidates]
sustained = candidates[counts >= need_frames]
if sustained.size == 0:
return None # only transient bursts in this window
return start_s + float(sustained[0]) * frame_s
# Hysteresis for full-track onset listing: after a frame crosses the
@@ -138,21 +196,60 @@ def detect_speech_onsets(audio: np.ndarray, sr: int) -> list[float]:
return onsets
def _region_mostly_silent(
audio: np.ndarray,
sr: int,
start_s: float,
end_s: float,
) -> bool:
"""True when ``[start_s, end_s]`` contains (almost) no audible content.
Gates long snaps (> ``MAX_SNAP_S``, #963): jumping far forward is only
trustworthy when everything being skipped is silence the genuine
whisper start-stretch of #280, where Demucs stripped the leading music
and left real silence on the vocals track. A small fraction of audible
frames is tolerated so an isolated transient bleeding through
separation (a footstep) doesn't block the trim; *sustained* audible
content e.g. quiet speech sitting below the relative onset
threshold does block it, because skipping past it would desync the
dub by the full jump.
"""
i0 = max(0, int(start_s * sr))
i1 = min(len(audio), int(end_s * sr))
if i1 <= i0:
return True
rms = _frame_rms(audio[i0:i1], max(1, int(FRAME_S * sr)))
if rms.size == 0:
return True
floor = max(ABS_RMS_FLOOR, SKIPPED_FLOOR_PEAK_FRAC * float(rms.max()))
return float((rms >= floor).mean()) <= SKIPPED_AUDIBLE_FRAC
def snap_segment_starts(
segments: Sequence[dict],
audio: np.ndarray,
sr: int,
*,
min_shift_s: float = MIN_SHIFT_S,
separated_vocals: bool = True,
) -> int:
"""Snap each segment's ``start`` forward to the actual speech onset.
Mutates the segment dicts in place (the shape the dub pipeline passes
around). Returns the number of segments adjusted.
``audio`` should be mono float; the Demucs vocals track gives the best
signal but the mixed track still beats nothing.
``audio`` should be the mono-float **separated vocals** track. When the
caller only has mixed/original audio (Demucs skipped or failed), pass
``separated_vocals=False``: snapping is then disabled entirely (#963) —
on a mixed track music, ambience and footsteps are all sustained energy,
so a detected "onset" is as likely the score as the speaker, and
whisper's own timestamps beat a confidently wrong snap.
"""
if not separated_vocals:
logger.info(
"onset-align: skipped — audio is not a separated vocals track "
"(Demucs unavailable/failed); keeping ASR timestamps as-is")
return 0
if sr <= 0 or audio is None or len(audio) == 0:
return 0
if audio.ndim > 1:
@@ -174,6 +271,13 @@ def snap_segment_starts(
shift = new_start - start
if shift < min_shift_s:
continue
if shift > MAX_SNAP_S and not _region_mostly_silent(audio, sr, start, onset):
# Long jump over audible content (#963): the "onset" is more
# likely a louder late event than the true start — quiet speech
# under the relative threshold would be skipped wholesale and
# the dub would play seconds LATE. Bounded corrections are fine;
# unbounded ones only over true silence (the #280 case).
continue
# Preserve a minimum playable duration.
new_start = min(new_start, end - MIN_SEG_DUR_S)
if new_start - start < min_shift_s:
+139 -16
View File
@@ -19,13 +19,69 @@ Two tiers, both applied only to FINAL transcripts (never partials):
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import time
from dataclasses import dataclass
logger = logging.getLogger("omnivoice.refinement")
# Hard wall-clock budget (seconds) for a single dictation refinement LLM call.
# The dictation FINAL must never be delayed longer than this by a slow or dead
# LLM endpoint — refinement is best-effort and falls back to the unrefined
# (but polished) text on timeout. 4s keeps a healthy local model (Ollama /
# LM Studio, sub-second on the tiny cleanup prompt) fully usable while turning
# the old worst case — a placeholder/dead endpoint blocking the send ~51s until
# the widget's 15s fallback fired — into a bounded ~4s at most. Env-tunable so
# power users on a slow local LLM can raise it. Guarded by the regression tests
# in tests/backend/services/test_refinement_llm.py and tests/test_capture_ws.py.
_DEFAULT_REFINE_TIMEOUT_S = 4.0
def _refine_timeout_s() -> float:
"""The refinement LLM budget in seconds (OMNIVOICE_REFINE_TIMEOUT_S).
Falls back to :data:`_DEFAULT_REFINE_TIMEOUT_S` on an unset/invalid/non-
positive value so a bad env var can never disable the bound."""
raw = os.environ.get("OMNIVOICE_REFINE_TIMEOUT_S", "")
try:
v = float(raw)
if v > 0:
return v
except (TypeError, ValueError):
pass
return _DEFAULT_REFINE_TIMEOUT_S
# Most-recent refinement outcome, so the Settings panel can tell the user when a
# configured LLM is actually failing/timing out (the honesty layer behind the
# `llm_ready` flag, which only means "an endpoint is configured"). Best-effort,
# process-local, cleared on success.
_last_refine_status: dict | None = None
def _note_refine_status(*, ok: bool, reason: str | None = None) -> None:
global _last_refine_status
_last_refine_status = {"ok": bool(ok), "reason": reason, "at": time.time()}
def get_last_refine_status() -> dict | None:
"""The last refinement outcome as ``{ok, reason, at}`` or None if refinement
hasn't run this session. ``ok=False`` with ``reason`` ("timeout" or a short
error string) means a configured LLM failed the most recent final."""
return dict(_last_refine_status) if _last_refine_status else None
def _short_reason(exc: Exception) -> str:
"""A compact, non-leaky label for a refinement failure (for the UI hint)."""
name = type(exc).__name__
if "Timeout" in name or "timeout" in str(exc).lower():
return "timeout"
return name
# A token (or unit) must repeat at least this many times consecutively to be
# treated as an STT artifact. Rhetorical repetition ("no, no, no, no, no" —
# five repeats) stays below the threshold and survives.
@@ -248,6 +304,19 @@ REFINEMENT_EXAMPLES: list[tuple[str, str]] = [
# settings_store key holding the user's refinement config (plain JSON).
_SETTINGS_KEY = "dictation_refinement"
# LLM Skills registry id — Settings → LLM Skills can disable refinement's LLM
# use or route it to a specific provider. Disabled == identical pass-through
# (the same path as "no LLM configured").
_SKILL_ID = "dictation_refinement"
def _skill_llm():
"""The skill-resolved backend (OffBackend when disabled/unconfigured)."""
from services import llm_skills
from services.llm_backend import get_active_llm_backend
return llm_skills.skill_backend(_SKILL_ID, active=get_active_llm_backend)
def get_refinement_config() -> dict:
"""Read the persisted config: {auto, smart_cleanup, self_correction,
@@ -274,43 +343,97 @@ def set_refinement_config(cfg: dict) -> dict:
return merged
def refine_transcript(transcript: str, flags: RefinementFlags | None = None) -> str:
def refine_transcript(
transcript: str,
flags: RefinementFlags | None = None,
*,
timeout_s: float | None = None,
) -> str:
"""Run the transcript through the configured LLM. Raises on failure —
callers decide the fallback (maybe_refine swallows into pass-through)."""
from services.llm_backend import get_active_llm_backend
callers decide the fallback (maybe_refine swallows into pass-through).
The LLM HTTP call is bounded by ``timeout_s`` (default: the refinement
budget) so a dead/slow endpoint can't tie the call up for the client's full
45s LLM timeout the class of stall this whole module guards against."""
flags = flags or RefinementFlags()
backend = get_active_llm_backend()
backend = _skill_llm()
messages = [{"role": "system", "content": build_refinement_prompt(flags)}]
for user_turn, assistant_turn in REFINEMENT_EXAMPLES:
messages.append({"role": "user", "content": user_turn})
messages.append({"role": "assistant", "content": assistant_turn})
messages.append({"role": "user", "content": transcript})
return backend.chat_messages(messages=messages).strip()
budget = timeout_s if timeout_s is not None else _refine_timeout_s()
return backend.chat_messages(messages=messages, timeout=budget).strip()
def maybe_refine(transcript: str) -> str | None:
def maybe_refine(transcript: str, *, timeout_s: float | None = None) -> str | None:
"""Best-effort refinement for the dictation final path.
Returns the refined text, or None when refinement is off, no LLM
backend is configured, the result is empty, or anything fails the
raw transcript always stands. Never raises.
raw transcript always stands. Never raises. Records the outcome via
:func:`get_last_refine_status` so the UI can flag a failing LLM.
Blocking (network I/O); the WS/REST callers run it off-thread. Prefer
:func:`maybe_refine_async` on the live-dictation path it adds the hard
wall-clock bound so a slow endpoint can never delay the ``final`` send.
"""
if not transcript or not transcript.strip():
return None
cfg = get_refinement_config()
if not cfg.get("auto", True):
return None
backend = _skill_llm()
if backend.id == "off":
# No LLM configured — or the dictation_refinement skill is disabled /
# routed to an unconfigured provider — is not a failure. Leave the last
# status untouched (same pass-through as today).
return None
try:
cfg = get_refinement_config()
if not cfg.get("auto", True):
return None
from services.llm_backend import get_active_llm_backend
backend = get_active_llm_backend()
if backend.id == "off":
return None
refined = refine_transcript(transcript, RefinementFlags.from_dict(cfg))
refined = refine_transcript(
transcript, RefinementFlags.from_dict(cfg), timeout_s=timeout_s
)
if not refined:
return None
_note_refine_status(ok=True)
return refined
except Exception as e: # noqa: BLE001 — pass-through is the contract
logger.warning("Dictation refinement skipped: %s", e)
_note_refine_status(ok=False, reason=_short_reason(e))
return None
async def maybe_refine_async(
transcript: str, *, timeout_s: float | None = None
) -> str | None:
"""Async, hard-time-bounded refinement for the live-dictation final path.
Runs :func:`maybe_refine` off-thread under a hard ``OMNIVOICE_REFINE_TIMEOUT_S``
(~4s) budget so a slow or dead LLM endpoint can NEVER block the caller and
therefore the dictation ``final`` send longer than the budget. On timeout
(or any failure) it returns None and the raw, already-polished transcript
stands. Never raises.
``asyncio.wait_for`` can't cancel the worker thread, but the LLM call it runs
is itself bounded to the same budget (see :func:`refine_transcript`), so an
orphaned thread unwinds shortly after rather than lingering the full 45s.
"""
if not transcript or not transcript.strip():
return None
budget = timeout_s if timeout_s is not None else _refine_timeout_s()
try:
return await asyncio.wait_for(
asyncio.to_thread(maybe_refine, transcript, timeout_s=budget),
timeout=budget,
)
except asyncio.TimeoutError:
logger.warning(
"Dictation refinement exceeded its %.1fs budget — sending the "
"unrefined final (set OMNIVOICE_REFINE_TIMEOUT_S to adjust).", budget,
)
_note_refine_status(ok=False, reason="timeout")
return None
except Exception as e: # noqa: BLE001 — best-effort; the raw final stands
logger.warning("Dictation refinement failed: %s", e)
_note_refine_status(ok=False, reason=_short_reason(e))
return None
+33 -6
View File
@@ -532,14 +532,41 @@ def assign_speakers_from_turns(
return segments
def assign_speakers_heuristic(segments: List[dict]) -> List[dict]:
"""Two-speaker alternation based on silence gaps."""
current = 1
def assign_speakers_heuristic(
segments: List[dict], num_speakers: Optional[int] = None
) -> List[dict]:
"""Silence-gap speaker assignment (used when no diarization model runs).
Base signal: a gap > SPEAKER_GAP seconds between consecutive segments is
treated as a speaker change. Without a ``num_speakers`` hint this keeps
the legacy behavior alternate between exactly two labels. With a hint:
* ``num_speakers=1`` every segment gets ``"Speaker 1"``.
* ``num_speakers>=2`` labels round-robin across N speakers at each
gap boundary, so the user's requested count is represented instead of
being silently capped at 2.
Limits (be honest with callers): this honors the *count*, not voice
identity. The rotation order is arbitrary (a returning speaker gets the
next label in the cycle, not their own), rapid exchanges with no
> SPEAKER_GAP pause still collapse into one label, and N is an upper
bound audio with fewer gap boundaries than N yields fewer labels.
Real per-speaker attribution needs pyannote (or an inline-diarizing ASR
backend); callers should warn the user accordingly (see dub_core).
Invalid hints (non-int, < 1) fall back to the legacy two-speaker cycle.
"""
try:
n = int(num_speakers) if num_speakers is not None else 2
except (TypeError, ValueError):
n = 2
if n < 1:
n = 2
current = 0 # zero-based rotation index; rendered one-based below
last_end = 0.0
for i, s in enumerate(segments):
if i > 0 and (s["start"] - last_end) > SPEAKER_GAP:
current = 2 if current == 1 else 1
s["speaker_id"] = f"Speaker {current}"
if i > 0 and n > 1 and (s["start"] - last_end) > SPEAKER_GAP:
current = (current + 1) % n
s["speaker_id"] = f"Speaker {current + 1}"
last_end = s["end"]
return segments
+22 -4
View File
@@ -34,6 +34,23 @@ _PROVIDER = os.environ.get("OMNIVOICE_SHERPA_ASR_PROVIDER", "cpu")
_NUM_THREADS = int(os.environ.get("OMNIVOICE_SHERPA_ASR_THREADS", "2"))
def _endpoint_rules() -> tuple[float, float]:
"""Trailing-silence endpoint rules (seconds) for streaming recognizers.
Wispr-Flow-speed defaults (dictation v2): rule2 commits ~0.6s after speech
stops, rule1 flushes after 1.0s of trailing non-speech down from the
upstream 2.4/1.2, which made every committed sentence feel laggy. Read at
call time so the env overrides apply without a restart.
"""
def _f(env: str, default: float) -> float:
try:
return float(os.environ.get(env, "") or default)
except (TypeError, ValueError):
return default
return (_f("OMNIVOICE_DICTATION_ENDPOINT_R1", 1.0),
_f("OMNIVOICE_DICTATION_ENDPOINT_R2", 0.6))
@dataclass(frozen=True)
class SherpaModelSpec:
"""One downloadable sherpa-onnx dictation model.
@@ -282,6 +299,7 @@ def build_online_recognizer(spec: SherpaModelSpec, *, download: bool = True):
import sherpa_onnx
d = _resolve_model_dir(spec, download=download)
rule1, rule2 = _endpoint_rules()
def p(role: str) -> str:
return os.path.join(d, spec.files[role])
@@ -296,8 +314,8 @@ def build_online_recognizer(spec: SherpaModelSpec, *, download: bool = True):
provider=_PROVIDER,
decoding_method="greedy_search",
enable_endpoint_detection=True,
rule1_min_trailing_silence=2.4,
rule2_min_trailing_silence=1.2,
rule1_min_trailing_silence=rule1,
rule2_min_trailing_silence=rule2,
rule3_min_utterance_length=20,
)
if spec.kind == "online-paraformer":
@@ -309,8 +327,8 @@ def build_online_recognizer(spec: SherpaModelSpec, *, download: bool = True):
provider=_PROVIDER,
decoding_method="greedy_search",
enable_endpoint_detection=True,
rule1_min_trailing_silence=2.4,
rule2_min_trailing_silence=1.2,
rule1_min_trailing_silence=rule1,
rule2_min_trailing_silence=rule2,
rule3_min_utterance_length=20,
)
raise ValueError(f"{spec.id} is not a streaming model (kind={spec.kind})")
+155 -13
View File
@@ -42,11 +42,25 @@ IDEAL_REF_DURATION_S = 8.0 # target window — long enough for prosody, short e
# is the empirical floor below which our zero-shot clone gets unstable.
MIN_SEGMENT_REF_DURATION_S = 3.0
# Clone-purity guards (speaker-hint fix): a per-speaker reference cut from
# mislabeled or boundary-adjacent audio mixes two people's voices and the
# resulting clone sounds "made up".
# * A slice below MIN_SLICE_DURATION_S is too short to be a reliable
# single-speaker sample (and diarization boundary jitter dominates it).
# * A slice whose edges come within ADJACENT_TURN_GUARD_S of a *different*
# speaker's turn risks bleeding that speaker's audio across the imprecise
# boundary — deprioritized (scoring preference, not a hard filter, so
# extraction still succeeds on dense dialogue).
MIN_SLICE_DURATION_S = 1.5
ADJACENT_TURN_GUARD_S = 0.3
def extract_speaker_clones(
vocals_path: str,
segments: list[dict],
out_dir: str,
*,
labels_source: str | None = None,
) -> dict[str, dict]:
"""Build a per-speaker reference sample from `vocals_path` + `segments`.
@@ -63,7 +77,20 @@ def extract_speaker_clones(
Speakers whose segments total < MIN_REF_DURATION_S are skipped we'd
rather fall back to the default TTS voice than ship a bad clone.
``labels_source`` records where the ``speaker_id`` labels came from
(``"pyannote"`` | ``"turns"`` | ``"heuristic"``; ``None`` = unknown,
treated as trusted for backward compatibility). ``"heuristic"`` labels
are silence-gap *estimates*, not voice identity a reference cut from
them routinely concatenates two people's audio, so extraction is skipped
entirely (the caller warns the user and falls back to the default voice).
"""
if labels_source == "heuristic":
logger.info(
"speaker_clone: skipping auto-clone extraction — speaker labels "
"are gap-based heuristic estimates, not voice identity"
)
return {}
if not vocals_path or not os.path.exists(vocals_path):
logger.info("speaker_clone: no vocals track at %s; skipping", vocals_path)
return {}
@@ -88,7 +115,12 @@ def extract_speaker_clones(
out: dict[str, dict] = {}
for speaker_id, items in by_speaker.items():
chosen = _pick_reference_slices(items)
chosen = _pick_reference_slices(
items,
speaker_id=speaker_id,
all_segments=segments,
labels_source=labels_source,
)
if not chosen:
logger.info(
"speaker_clone: %s has <%ss of usable audio; will fall back to default voice",
@@ -191,34 +223,144 @@ def extract_segment_refs(
return out
def refine_ref_text(ref_audio_path: str, asr_backend, fallback_text: str) -> str:
"""Re-transcribe a written reference clip and return that transcript.
`extract_speaker_clones`/`extract_segment_refs` pair each audio slice with
the ASR segment's OWN text field, on the assumption that the segment's
timestamps and its transcribed text agree. They routinely don't — Whisper
(and friends) frequently drift on segment boundaries: a trailing word
audible in `[start, end]` but missing from `text`, or vice versa. When the
(ref_audio, ref_text) pair disagrees, zero-shot TTS prompt-priming breaks
down and the clone can speak the mismatched reference text itself instead
of the target-language text it was given to synthesize (issue #1004).
Re-transcribing the *actual written clip* guarantees the pair matches by
construction the model doesn't care whether the original ASR text was
right, only that ref_text is what's really in ref_audio. `asr_backend` is
the caller's already-loaded active backend (duck-typed:
`.transcribe(path, word_timestamps=...) -> dict` with a `chunks` list of
`{"text": ...}`); the model is already warm, so this costs one more short
transcribe call, not a fresh load. Falls back to `fallback_text` never
raises so a re-transcribe failure is a strict no-op, never a regression
from the original (matching) behavior.
"""
if asr_backend is None:
return fallback_text
try:
result = asr_backend.transcribe(ref_audio_path, word_timestamps=False)
text = " ".join(
(c.get("text") or "").strip() for c in (result.get("chunks") or [])
).strip()
return text or fallback_text
except Exception as e:
logger.warning(
"speaker_clone: re-transcribe of %s failed, keeping original ref_text: %s",
ref_audio_path, e,
)
return fallback_text
def refine_ref_texts(clones: dict[str, dict], asr_backend) -> dict[str, dict]:
"""Apply `refine_ref_text` to every entry's `ref_text` in place.
Batches the whole dict (per-speaker `clones` from `extract_speaker_clones`
or per-segment `seg_clones` from `extract_segment_refs`) into the single
executor round-trip the caller submits to the GPU pool, rather than one
dispatch per reference. Mutates and returns `clones` for a convenient
call-and-reassign at the call site.
"""
for entry in clones.values():
entry["ref_text"] = refine_ref_text(
entry["ref_audio"], asr_backend, entry.get("ref_text", "")
)
return clones
# ── Internals ───────────────────────────────────────────────────────────────
def _pick_reference_slices(items: list[tuple[int, dict]]) -> list[tuple[int, dict]]:
def _adjacent_to_other_speaker(
seg: dict, speaker_id: str, all_segments: list[dict] | None
) -> bool:
"""True when `seg`'s edges come within ADJACENT_TURN_GUARD_S of (or
overlap) a segment attributed to a *different* speaker a boundary where
imprecise diarization timestamps risk bleeding the other voice into the
reference slice."""
if not all_segments:
return False
s0 = float(seg.get("start", 0.0))
s1 = float(seg.get("end", 0.0))
for other in all_segments:
if other is seg:
continue
if (other.get("speaker_id") or "Speaker 1") == speaker_id:
continue
o0 = float(other.get("start", 0.0))
o1 = float(other.get("end", 0.0))
# Signed gap between the two spans; negative = overlap.
if max(o0 - s1, s0 - o1) < ADJACENT_TURN_GUARD_S:
return True
return False
def _pick_reference_slices(
items: list[tuple[int, dict]],
*,
speaker_id: str | None = None,
all_segments: list[dict] | None = None,
labels_source: str | None = None,
) -> list[tuple[int, dict]]:
"""Select the subset of a speaker's segments to use as reference audio.
Strategy: take the single longest segment; if it's short, accumulate the
next longest ones in original order until we clear IDEAL_REF_DURATION_S.
Cap at MAX_REF_DURATION_S. Return [] if we can't reach MIN_REF_DURATION_S.
Strategy: rank candidates clean-first (not temporally adjacent to a
different speaker's turn — see ``_adjacent_to_other_speaker``), longest
first within each tier, and accumulate until IDEAL_REF_DURATION_S is
cleared. Adjacency is a scoring preference, NOT a hard filter on dense
dialogue where every slice borders another speaker, extraction still
succeeds using the adjacent ones. Two hard guards protect clone purity:
* slices shorter than MIN_SLICE_DURATION_S are rejected outright
(boundary jitter dominates them, so they're the likeliest to carry a
second speaker's audio);
* ``labels_source="heuristic"`` returns [] gap-based labels are not
voice identity, so no slice of them is safe to clone from.
Cap at MAX_REF_DURATION_S. Return [] if we can't reach
MIN_REF_DURATION_S. When ``all_segments``/``speaker_id`` are not
provided (legacy callers), adjacency scoring degrades to duration-only
the pre-guard behavior.
"""
if not items:
return []
if labels_source == "heuristic":
return []
if speaker_id is None:
speaker_id = items[0][1].get("speaker_id") or "Speaker 1"
# Longest-first candidates. Keep original indices so we can preserve order.
by_dur = sorted(
def _dur(pair) -> float:
return max(0.0, float(pair[1].get("end", 0.0)) - float(pair[1].get("start", 0.0)))
# Rank: clean (non-adjacent) before adjacent, longest first within each
# tier. Keep original indices so we can restore transcript order below.
ranked = sorted(
items,
key=lambda pair: (pair[1].get("end", 0.0) - pair[1].get("start", 0.0)),
reverse=True,
key=lambda pair: (
_adjacent_to_other_speaker(pair[1], speaker_id, all_segments),
-_dur(pair),
),
)
picked: list[tuple[int, dict]] = []
total = 0.0
for idx, seg in by_dur:
dur = max(0.0, float(seg.get("end", 0.0)) - float(seg.get("start", 0.0)))
if dur <= 0.0:
for idx, seg in ranked:
dur = _dur((idx, seg))
if dur < MIN_SLICE_DURATION_S:
continue
if total + dur > MAX_REF_DURATION_S and picked:
break
# Ranking is no longer duration-monotonic, so a later (shorter or
# adjacent) slice may still fit — skip, don't stop.
continue
picked.append((idx, seg))
total += dur
if total >= IDEAL_REF_DURATION_S:
+143 -11
View File
@@ -18,9 +18,17 @@ import logging
from typing import Iterable, Optional
from services.llm_backend import get_active_llm_backend, OffBackend
# Shared LLM-output divergence guard (length window + target-script +
# critique-echo). Lives in translator; translator never imports this module,
# so there is no import cycle.
from services.translator import refine_output_ok
logger = logging.getLogger("omnivoice.speech_rate")
# LLM Skills registry id — Settings → LLM Skills can disable the slot-fit
# LLM pass or route it to a specific provider. Disabled == the no-llm path.
_SKILL_ID = "slot_fitting"
# Per-language read-speed estimates (chars/sec at natural pace, counting
# Python `len()` codepoints — not phonemes or graphemes). These are
# rough; real speakers vary wildly. Numbers below come from a mix of
@@ -87,9 +95,15 @@ _EXPAND_PROMPT = """\
You are a dubbing writer. The user will give you a translated line + the exact
time slot it must fit. The current line is TOO SHORT add natural filler or
gently flesh out the thought while keeping the meaning the same. Aim for a
reading duration that matches the slot.
reading duration that matches the slot. Never invent new information, names,
or dialogue that is not already in the line; do not more than double the line.
Reply with ONLY the new line. No quotes, no commentary."""
# Below this predicted rate ratio a line can never honestly fill its slot —
# any LLM "expansion" that far would be fabricated dialogue. Skip the expand
# pass entirely and keep the short line (slot-aware TTS absorbs the silence).
_MIN_EXPANDABLE_RATIO = 0.15
def adjust_for_slot(
text: str,
@@ -103,19 +117,41 @@ def adjust_for_slot(
Falls back to the input text if the LLM is off or the loop gives up.
``strict`` (Autofit mode) caps the accepted upper bound at 1.0 instead of
``TOL_HIGH`` i.e. the line must fit *within* the slot, never overrun it
so the target-language reading time can't exceed the segment and push the
video timing out. A too-short line is still accepted down to ``TOL_LOW`` (we
don't pad just to fill silence). Best-effort: after ``MAX_ATTEMPTS`` it
returns the closest candidate seen, so a stubborn line degrades gracefully.
``strict`` (Autofit mode) changes exactly one thing: the accepted upper
bound is 1.0 instead of ``TOL_HIGH`` the line must fit *within* the
slot, never overrun it so the target-language reading time can't exceed
the segment and push the video timing out. Lines under ``TOL_LOW`` still
go through the LLM expand pass in strict mode too (same as loose mode);
padding is bounded by the divergence guard below, and a line under
``_MIN_EXPANDABLE_RATIO`` is never expanded at all it could only "fill"
the slot with fabricated dialogue, so it stays short. Best-effort: after
``MAX_ATTEMPTS`` it returns the closest candidate seen, so a stubborn line
degrades gracefully.
Every LLM reply is validated with ``translator.refine_output_ok`` against
the ORIGINAL input ``text`` (not the previous candidate divergence
compounds across attempts otherwise). A reply that fails the guard is
discarded: the attempt is burned, ``current``/``best`` stay put, and if
nothing valid ever came back the input text is returned with
``error="fit-diverged"`` a hallucinating model can no longer invent the
dub line (v0.3.9 field report).
"""
tol_high = 1.0 if strict else TOL_HIGH
initial_ratio = rate_ratio(text, slot_seconds, target_lang)
if TOL_LOW <= initial_ratio <= tol_high:
return {"text": text, "rate_ratio": initial_ratio, "attempts": 0}
if initial_ratio < _MIN_EXPANDABLE_RATIO:
return {
"text": text,
"rate_ratio": initial_ratio,
"attempts": 0,
"error": "fit-skip-short",
}
llm = get_active_llm_backend()
from services import llm_skills
# `active=` forwards this module's (monkeypatch-able) name so the
# no-override path is byte-identical to the pre-skills behavior.
llm = llm_skills.skill_backend(_SKILL_ID, active=lambda: get_active_llm_backend())
if isinstance(llm, OffBackend):
return {
"text": text,
@@ -126,6 +162,7 @@ def adjust_for_slot(
current = text
best = (current, initial_ratio)
diverged = False
for attempt in range(1, MAX_ATTEMPTS + 1):
r = rate_ratio(current, slot_seconds, target_lang)
if TOL_LOW <= r <= tol_high:
@@ -143,23 +180,43 @@ def adjust_for_slot(
user_lines.append(f"Source line (for meaning): {source_text}")
try:
next_text = llm.chat(system=system, user="\n".join(user_lines))
next_text = llm.chat(
system=system, user="\n".join(user_lines),
temperature=0.2, # pinned like the Fast path — default 1.0 drifts/invents
)
except Exception as e:
logger.warning("speech-rate attempt %d failed: %s", attempt, e)
return {"text": best[0], "rate_ratio": best[1], "attempts": attempt - 1, "error": str(e)}
if next_text and next_text.strip():
current = next_text.strip()
candidate = next_text.strip()
# Divergence guard — validate against the ORIGINAL text, not
# `current`: each accepted reply becomes the next prompt's input,
# so per-step checks would let drift compound across attempts.
ok, reason = refine_output_ok(text, candidate, target_lang)
if not ok:
diverged = True
logger.warning(
"speech-rate attempt %d rejected (%s) — discarding candidate",
attempt, reason,
)
continue # attempt burned; current/best untouched
current = candidate
new_r = rate_ratio(current, slot_seconds, target_lang)
# Keep the best candidate seen so far in case we exhaust retries.
if abs(new_r - 1.0) < abs(best[1] - 1.0):
best = (current, new_r)
return {
out = {
"text": best[0],
"rate_ratio": best[1],
"attempts": MAX_ATTEMPTS,
}
# Every usable reply diverged and the input text survived unchanged —
# surface it on the row (rate_error in dub_translate, like fit-budget).
if diverged and best[0] == text:
out["error"] = "fit-diverged"
return out
def adjust_many(pairs: Iterable[tuple[str, float, str, Optional[str]]]) -> list[dict]:
@@ -171,3 +228,78 @@ def adjust_many(pairs: Iterable[tuple[str, float, str, Optional[str]]]) -> list[
adjust_for_slot(t, slot_seconds=s, target_lang=tl, source_text=src)
for (t, s, tl, src) in pairs
]
async def adjust_for_slot_many(
items: Iterable[tuple],
*,
executor=None,
concurrency: Optional[int] = None,
deadline: Optional[float] = None,
loop=None,
) -> dict:
"""Fan `adjust_for_slot` out across many segments concurrently, bounded by a
shared wall-clock ``deadline``.
``items``: iterable of ``(key, text, slot_seconds, target_lang,
source_text_or_None, strict)``. Returns ``{key: adjust_for_slot_result}``.
Why this exists: the Autofit fit pass used to run one `adjust_for_slot` per
segment *sequentially* and *outside* any budget, so a 50-segment dub against
a slow/rate-limited LLM spun ~50×(per-call timeout) unbounded. Here every
segment runs on the executor under a bounded ``asyncio.Semaphore``, and any
segment still running when the shared ``deadline`` passes degrades to a
no-fit result (input text kept, predicted ``rate_ratio``, ``error`` =
``"fit-budget"``) instead of hanging the translate. ``deadline`` is an
absolute ``loop.time()``; ``None`` disables the bound (run to completion).
"""
import asyncio
import os
loop = loop or asyncio.get_running_loop()
items = list(items)
if not items:
return {}
sem = asyncio.Semaphore(concurrency or int(os.environ.get("OMNIVOICE_LLM_CONCURRENCY", "6")))
async def _one(key, text, slot, tgt, src, strict):
async with sem:
res = await loop.run_in_executor(
executor,
lambda: adjust_for_slot(
text, slot_seconds=slot, target_lang=tgt,
source_text=src, strict=strict,
),
)
return key, res
def _degraded(text, slot, tgt) -> dict:
return {
"text": text,
"rate_ratio": rate_ratio(text, slot, tgt),
"attempts": 0,
"error": "fit-budget",
}
tasks = [asyncio.ensure_future(_one(*it)) for it in items]
if deadline is None:
pairs_out = await asyncio.gather(*tasks)
return dict(pairs_out)
timeout = max(0.0, deadline - loop.time())
done, _pending = await asyncio.wait(tasks, timeout=timeout)
out: dict = {}
for task, it in zip(tasks, items):
key, text, slot, tgt = it[0], it[1], it[2], it[3]
if task in done and not task.cancelled():
try:
k, res = task.result()
out[k] = res
continue
except Exception as e: # noqa: BLE001 — one slow seg must not sink the pass
logger.warning("fit segment %s failed: %s", key, e)
else:
task.cancel() # stop awaiting; the executor thread is abandoned (#730 pattern)
out[key] = _degraded(text, slot, tgt)
return out
+471
View File
@@ -0,0 +1,471 @@
"""Storage usage report for Settings → Storage.
Computes, for everything the app owns on disk:
* per-volume totals (total / used / free, grouped by ``st_dev`` so two
roots on the same disk are reported once),
* per-category directory sizes the HF model cache (with the largest
model dirs), the app data dir (broken into voices / outputs / dub_jobs /
batch / preview / database / logs / other subtotals), the per-engine
venvs under ``backend/engines/*/.venv`` (+ the app venv), and any
``omnivoice*`` entries in the OS temp dir,
* server-side ``warnings`` (low disk, volume pressure, unreadable paths)
so every client renders the same guidance.
Directory walks are **bounded**: each top-level category gets a deadline
(default 10 s) and returns a partial total (``complete: false`` + an
``unreadable`` warning with ``reason: "timeout"``) when it expires. Results
are cached in-process for 5 minutes; ``refresh`` bypasses the cache. The API
layer runs the whole build in a worker thread so the event loop never blocks.
"""
from __future__ import annotations
import glob
import os
import shutil
import sys
import tempfile
import threading
import time
from pathlib import Path
CACHE_TTL_SECONDS = 300.0
CATEGORY_TIMEOUT_SECONDS = 10.0
TOP_MODEL_COUNT = 10
VOLUME_PRESSURE_PERCENT = 90.0
DEFAULT_MIN_FREE_GB = 10 # callers pass setup.wizard.MIN_FREE_GB — this is the standalone fallback
# DATA_DIR children we know by name (core.config constants + routers that
# write there). Anything else lands in the "other" subtotal so the numbers
# always add up to the real on-disk footprint.
_DATA_CHILD_DIRS = ("voices", "outputs", "dub_jobs", "batch", "preview")
_DB_PREFIX = "omnivoice.db" # omnivoice.db + -wal / -shm / -journal
_LOG_FILES = ("crash_log.txt", "error_journal.jsonl")
_LOG_PREFIX = "omnivoice.log" # rolling log + rotations
_GB = 1024 ** 3
def default_engines_dir() -> str:
"""``backend/engines`` — where per-engine venvs live (`<id>/.venv`)."""
return str(Path(__file__).resolve().parents[1] / "engines")
def default_app_venv() -> str | None:
"""The venv this backend runs from, when it is one (None for system python)."""
if sys.prefix != getattr(sys, "base_prefix", sys.prefix):
return sys.prefix
return None
def _existing_ancestor(path: str) -> str:
"""Deepest existing ancestor of ``path`` (for disk_usage on missing dirs)."""
p = os.path.abspath(path)
while p and not os.path.exists(p):
parent = os.path.dirname(p)
if parent == p:
break
p = parent
return p
def _mount_point(path: str) -> str:
"""Mount point of the volume holding ``path`` (best-effort, cheap)."""
p = _existing_ancestor(path)
try:
while p and not os.path.ismount(p):
parent = os.path.dirname(p)
if parent == p:
break
p = parent
except OSError:
pass
return p or os.path.abspath(os.sep)
def _dir_size(path: str, deadline: float) -> tuple[int, bool, str | None]:
"""du-style size of ``path``: ``(bytes, complete, first_unreadable_path)``.
Never follows symlinks (lstat + walk default), never raises. Stops early
and reports ``complete=False`` once ``deadline`` (time.monotonic) passes.
"""
err_path: str | None = None
def _onerror(e: OSError) -> None:
nonlocal err_path
if err_path is None:
err_path = getattr(e, "filename", None) or path
try:
if not os.path.exists(path):
return 0, True, None
if not os.path.isdir(path):
return os.lstat(path).st_size, True, None
except OSError:
return 0, True, path
total = 0
complete = True
for root, _dirs, files in os.walk(path, onerror=_onerror):
if time.monotonic() > deadline:
complete = False
break
for name in files:
fp = os.path.join(root, name)
try:
total += os.lstat(fp).st_size
except OSError:
if err_path is None:
err_path = fp
return total, complete, err_path
def _sum_files(paths: list[str]) -> int:
total = 0
for p in paths:
try:
total += os.lstat(p).st_size
except OSError:
pass
return total
def _hf_model_dirs(cache_dir: str) -> list[str]:
"""`models--org--name` dirs in the cache root and its `hub/` child.
HF_HUB_CACHE points straight at the hub dir; HF_HOME needs `/hub`
appended scanning both covers either env resolution.
"""
out: list[str] = []
for base in (cache_dir, os.path.join(cache_dir, "hub")):
try:
with os.scandir(base) as it:
out.extend(
e.path for e in it
if e.name.startswith("models--") and e.is_dir(follow_symlinks=False)
)
except OSError:
continue
return out
def _model_display_name(dir_name: str) -> str:
return dir_name.removeprefix("models--").replace("--", "/")
def build_report(
*,
data_dir: str,
hf_cache_dir: str,
engines_dir: str | None = None,
app_venv: str | None = None,
temp_root: str | None = None,
min_free_gb: float = DEFAULT_MIN_FREE_GB,
category_timeout: float = CATEGORY_TIMEOUT_SECONDS,
) -> dict:
"""Build the full storage report (synchronous; call from a worker thread)."""
engines_dir = engines_dir if engines_dir is not None else default_engines_dir()
temp_root = temp_root if temp_root is not None else tempfile.gettempdir()
warnings: list[dict] = []
categories: list[dict] = []
def _warn_unreadable(category_id: str, path: str, reason: str) -> None:
warnings.append({
"kind": "unreadable",
"severity": "warning",
"category_id": category_id,
"path": path,
"reason": reason,
})
def _finish(category_id: str, cat: dict, complete: bool, err_path: str | None) -> None:
cat["complete"] = complete
if not complete:
_warn_unreadable(category_id, cat["path"], "timeout")
if err_path is not None:
_warn_unreadable(category_id, err_path, "permission")
# ── 1. HF model cache (+ top model dirs) ───────────────────────────────
deadline = time.monotonic() + category_timeout
hf_total = 0
hf_complete = True
hf_err: str | None = None
models: list[dict] = []
model_dirs = set(_hf_model_dirs(hf_cache_dir))
seen: set[str] = set()
for mdir in sorted(model_dirs):
size, ok, err = _dir_size(mdir, deadline)
hf_total += size
hf_complete = hf_complete and ok
hf_err = hf_err or err
models.append({"name": _model_display_name(os.path.basename(mdir)), "bytes": size})
seen.add(os.path.realpath(mdir))
# Non-model remainder of the cache (datasets, xet chunks, token file, …):
# walk the top-level entries that aren't model dirs so the category total
# reflects the whole cache, not just models.
try:
with os.scandir(hf_cache_dir) as it:
entries = list(it)
except OSError:
entries = []
if os.path.exists(hf_cache_dir):
hf_err = hf_err or hf_cache_dir
for e in entries:
if os.path.realpath(e.path) in seen:
continue
if e.name == "hub":
# hub/ holds the model dirs (already counted) + misc; count the rest.
try:
with os.scandir(e.path) as hub_it:
for h in hub_it:
if os.path.realpath(h.path) in seen:
continue
size, ok, err = _dir_size(h.path, deadline)
hf_total += size
hf_complete = hf_complete and ok
hf_err = hf_err or err
except OSError:
hf_err = hf_err or e.path
continue
size, ok, err = _dir_size(e.path, deadline)
hf_total += size
hf_complete = hf_complete and ok
hf_err = hf_err or err
models.sort(key=lambda m: m["bytes"], reverse=True)
hf_cat = {
"id": "hf_cache",
"path": hf_cache_dir,
"exists": os.path.isdir(hf_cache_dir),
"bytes": hf_total,
"items": models[:TOP_MODEL_COUNT],
}
_finish("hf_cache", hf_cat, hf_complete, hf_err)
categories.append(hf_cat)
# ── 2. App data dir, broken into subtotals ─────────────────────────────
deadline = time.monotonic() + category_timeout
data_complete = True
data_err: str | None = None
children: list[dict] = []
claimed: set[str] = set()
for name in _DATA_CHILD_DIRS:
p = os.path.join(data_dir, name)
size, ok, err = _dir_size(p, deadline)
data_complete = data_complete and ok
data_err = data_err or err
claimed.add(name)
children.append({"id": name, "path": p, "bytes": size, "complete": ok})
db_files = sorted(glob.glob(os.path.join(glob.escape(data_dir), _DB_PREFIX + "*")))
claimed.update(os.path.basename(p) for p in db_files)
children.append({
"id": "database",
"path": os.path.join(data_dir, _DB_PREFIX),
"bytes": _sum_files(db_files),
"complete": True,
})
log_files = sorted(glob.glob(os.path.join(glob.escape(data_dir), _LOG_PREFIX + "*")))
log_files += [os.path.join(data_dir, n) for n in _LOG_FILES]
claimed.update(os.path.basename(p) for p in log_files)
children.append({
"id": "logs",
"path": data_dir,
"bytes": _sum_files(log_files),
"complete": True,
})
other_bytes = 0
try:
with os.scandir(data_dir) as it:
for e in it:
if e.name in claimed:
continue
if e.is_dir(follow_symlinks=False):
size, ok, err = _dir_size(e.path, deadline)
other_bytes += size
data_complete = data_complete and ok
data_err = data_err or err
else:
try:
other_bytes += e.stat(follow_symlinks=False).st_size
except OSError:
data_err = data_err or e.path
except OSError:
if os.path.exists(data_dir):
data_err = data_err or data_dir
children.append({"id": "other", "path": data_dir, "bytes": other_bytes, "complete": True})
data_cat = {
"id": "data",
"path": data_dir,
"exists": os.path.isdir(data_dir),
"bytes": sum(c["bytes"] for c in children),
"children": children,
}
_finish("data", data_cat, data_complete, data_err)
categories.append(data_cat)
# ── 3. Engine venvs (+ the app venv) ───────────────────────────────────
deadline = time.monotonic() + category_timeout
venv_total = 0
venv_complete = True
venv_err: str | None = None
venv_items: list[dict] = []
try:
with os.scandir(engines_dir) as it:
engine_dirs = sorted(e.path for e in it if e.is_dir(follow_symlinks=False))
except OSError:
engine_dirs = []
for edir in engine_dirs:
venv_dir = os.path.join(edir, ".venv")
if not os.path.isdir(venv_dir):
continue
size, ok, err = _dir_size(venv_dir, deadline)
venv_total += size
venv_complete = venv_complete and ok
venv_err = venv_err or err
venv_items.append({"name": os.path.basename(edir), "bytes": size})
if app_venv:
size, ok, err = _dir_size(app_venv, deadline)
venv_total += size
venv_complete = venv_complete and ok
venv_err = venv_err or err
venv_items.append({"name": "app", "bytes": size})
venv_items.sort(key=lambda m: m["bytes"], reverse=True)
venv_cat = {
"id": "engine_venvs",
"path": engines_dir,
"exists": os.path.isdir(engines_dir),
"bytes": venv_total,
"items": venv_items,
}
_finish("engine_venvs", venv_cat, venv_complete, venv_err)
categories.append(venv_cat)
# ── 4. Temp/working files the app owns (omnivoice* in the OS temp dir) ─
deadline = time.monotonic() + category_timeout
tmp_total = 0
tmp_complete = True
tmp_err: str | None = None
for p in sorted(glob.glob(os.path.join(glob.escape(temp_root), "omnivoice*"))):
size, ok, err = _dir_size(p, deadline)
tmp_total += size
tmp_complete = tmp_complete and ok
tmp_err = tmp_err or err
tmp_cat = {
"id": "temp",
"path": temp_root,
"exists": os.path.isdir(temp_root),
"bytes": tmp_total,
"items": [],
}
_finish("temp", tmp_cat, tmp_complete, tmp_err)
categories.append(tmp_cat)
# ── Volumes: group category roots by device, disk_usage once each ──────
roots = {"hf_cache": hf_cache_dir, "data": data_dir, "engine_venvs": engines_dir, "temp": temp_root}
by_dev: dict[object, dict] = {}
for cid, root in roots.items():
anchor = _existing_ancestor(root)
try:
dev: object = os.stat(anchor).st_dev
except OSError:
dev = anchor
if dev not in by_dev:
try:
usage = shutil.disk_usage(anchor)
except OSError:
continue
by_dev[dev] = {
"path": _mount_point(anchor),
"total_bytes": usage.total,
"used_bytes": usage.used,
"free_bytes": usage.free,
"used_percent": round(usage.used / usage.total * 100.0, 1) if usage.total else 0.0,
"roots": [],
}
by_dev[dev]["roots"].append(cid)
volumes = list(by_dev.values())
# ── Server-side warnings ────────────────────────────────────────────────
for v in volumes:
free_gb = v["free_bytes"] / _GB
base = {
"path": v["path"],
"free_gb": round(free_gb, 1),
"min_free_gb": min_free_gb,
"roots": v["roots"],
}
if free_gb < min_free_gb:
warnings.append({"kind": "low_disk", "severity": "critical", **base})
elif free_gb < 2 * min_free_gb:
warnings.append({"kind": "low_disk", "severity": "low", **base})
if v["used_percent"] > VOLUME_PRESSURE_PERCENT and ({"hf_cache", "data"} & set(v["roots"])):
warnings.append({
"kind": "volume_pressure",
"severity": "warning",
"path": v["path"],
"used_percent": v["used_percent"],
"roots": v["roots"],
})
# Order: critical first, then the rest in computed order (stable sort).
warnings.sort(key=lambda w: 0 if w["severity"] == "critical" else 1)
return {
"generated_at": time.time(),
"min_free_gb": min_free_gb,
"volumes": volumes,
"categories": categories,
"warnings": warnings,
}
# ── In-process cache (5-minute TTL, refresh bypasses) ──────────────────────
_cache_lock = threading.Lock()
_cache: dict = {"key": None, "ts": 0.0, "report": None}
def get_report(
*,
data_dir: str,
hf_cache_dir: str,
engines_dir: str | None = None,
app_venv: str | None = None,
temp_root: str | None = None,
min_free_gb: float = DEFAULT_MIN_FREE_GB,
category_timeout: float = CATEGORY_TIMEOUT_SECONDS,
refresh: bool = False,
ttl: float = CACHE_TTL_SECONDS,
) -> dict:
"""Cached ``build_report``. ``refresh=True`` forces a rescan."""
key = (data_dir, hf_cache_dir, engines_dir, app_venv, temp_root, min_free_gb)
if not refresh:
with _cache_lock:
fresh = (
_cache["report"] is not None
and _cache["key"] == key
and (time.monotonic() - _cache["ts"]) < ttl
)
if fresh:
return {**_cache["report"], "cached": True}
report = build_report(
data_dir=data_dir,
hf_cache_dir=hf_cache_dir,
engines_dir=engines_dir,
app_venv=app_venv,
temp_root=temp_root,
min_free_gb=min_free_gb,
category_timeout=category_timeout,
)
with _cache_lock:
_cache.update(key=key, ts=time.monotonic(), report=report)
return {**report, "cached": False}
def clear_cache() -> None:
"""Testing hook — drop the in-process cache."""
with _cache_lock:
_cache.update(key=None, ts=0.0, report=None)
+4
View File
@@ -132,6 +132,10 @@ class IsolatedFasterWhisperBackend(SubprocessASRBackend):
id = "faster-whisper-isolated"
display_name = "Faster-Whisper (crash-isolated subprocess)"
# Same engine as FasterWhisperBackend, so the same device support — the
# sidecar picks cuda/cpu itself via `_device()`. Without this the registry
# default ("cpu",) would dishonestly report cpu_only routing on CUDA hosts.
gpu_compat = ("cuda", "cpu")
@classmethod
def is_available(cls) -> tuple[bool, str]:
+79
View File
@@ -0,0 +1,79 @@
"""
Deterministic polish for dictation finals (dictation v2).
Every ``final`` that leaves ``/ws/transcribe`` passes through
:func:`polish_text` so pasted dictation reads like typed text:
* leading capital -- Latin scripts only (CJK/Cyrillic/etc. untouched),
* terminal punctuation -- a period is appended unless the text already
ends with sentence-terminal punctuation (incl. the CJK fullwidth forms),
* doubled spaces collapsed, leading/trailing whitespace stripped.
Purely rule-based -- no model, no locale detection, no network -- so it is
byte-for-byte reproducible and idempotent (``polish(polish(x)) == polish(x)``).
CJK codepoints below are ``\\u``-escaped on purpose: this is functional
punctuation handling (allowed), and the escapes keep this file outside the
literal-CJK scan in ``tests/test_no_hardcoded_cjk.py`` without growing its
allowlist.
"""
from __future__ import annotations
import re
# Sentence-terminal punctuation that already "closes" a final -- Latin plus
# the CJK fullwidth forms (U+3002 ideographic full stop, U+FF01 !, U+FF1F ?)
# and ellipsis. A trailing closing quote/bracket after one of these still
# counts as terminated ("He said \"hi.\"").
_TERMINAL = ".!?\u2026\u3002\uff01\uff1f"
_CLOSERS = "\"'\u201d\u2019\u00bb\u203a)]}\u300d\u300f\uff09\u3011"
# A dangling clause separator at the very end (ASR often stops mid-breath on
# a comma) is swapped for a stop instead of stacking ",." punctuation.
# Latin , ; : plus the CJK forms U+3001 U+FF0C U+FF1B U+FF1A.
_DANGLING = ",;:\u3001\uff0c\uff1b\uff1a"
# CJK codepoints (kana, unified ideographs, compatibility + halfwidth forms)
# -- used to pick the fullwidth stop U+3002 over "." for CJK sentences.
_CJK = re.compile(
"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff66-\uff9f]"
)
_MULTISPACE = re.compile(r"[ \t]{2,}")
def _is_latin_lower(ch: str) -> bool:
"""Lowercase letter in a Latin block (ASCII, Latin-1, Latin Extended-A/B).
Capitalization is meaningless (CJK) or presumptuous (Cyrillic, Greek --
the model's casing is trusted) outside Latin scripts.
"""
return ch.islower() and ord(ch) <= 0x024F
def polish_text(text: str) -> str:
"""Normalise one dictation final. Empty/whitespace-only input -> ``""``."""
if not text:
return ""
out = _MULTISPACE.sub(" ", text).strip()
if not out:
return ""
# Leading capital (Latin scripts only).
if _is_latin_lower(out[0]):
out = out[0].upper() + out[1:]
# Already terminated -- possibly behind a closing quote/bracket?
body = out.rstrip(_CLOSERS)
if body and body[-1] in _TERMINAL:
return out
# Swap a dangling comma/colon for the stop instead of stacking ",.".
if out[-1] in _DANGLING:
out = out[:-1].rstrip()
if not out:
return ""
# Script-matched stop: fullwidth U+3002 when the sentence ends in CJK.
out += "\u3002" if _CJK.search(out[-1]) else "."
return out
+35 -5
View File
@@ -92,9 +92,11 @@ REGISTRY: dict[str, dict] = {
"category": "llm",
"needs_key": True,
"notes": (
"Any OpenAI-compatible endpoint: GPT-4/5 (OpenAI), Claude (via OpenRouter), "
"Gemini (OpenAI-compat mode), DeepSeek, Qwen, Ollama, LM Studio. "
"Set TRANSLATE_BASE_URL + TRANSLATE_API_KEY + TRANSLATE_MODEL."
"Uses the LLM provider you configure in Settings → LLM Providers "
"(route it via the 'Dub translation' skill in Settings → LLM Skills): "
"GPT (OpenAI), Claude (via OpenRouter), Gemini, DeepSeek, Qwen, "
"Ollama, LM Studio. Power-user env override: TRANSLATE_BASE_URL + "
"TRANSLATE_API_KEY + TRANSLATE_MODEL."
),
},
}
@@ -137,17 +139,45 @@ def install_command(engine: "str | dict | None") -> str | None:
return f"uv pip install {pkg}" if pkg else None
def _llm_configured() -> tuple[bool, "str | None"]:
"""Whether the LLM translation engine has something to call, and via what.
Resolution mirrors the translate-time path in dub_translate.py: the
"dub_translation" LLM skill (per-skill override active provider from
Settings LLM Providers) first, then the TRANSLATE_* env override. Lets
the Engine dropdown say "ready via <provider>" / "needs setup" up front
instead of a per-segment failure after the user clicks Translate.
"""
try:
from services import llm_skills
res = llm_skills.resolve_skill("dub_translation")
if res.ready and res.provider is not None:
return True, res.provider.display_name
except Exception: # noqa: BLE001 — a probe must never break list_engines()
logger.debug("dub_translation skill probe failed", exc_info=True)
if os.environ.get("TRANSLATE_BASE_URL") or os.environ.get("TRANSLATE_API_KEY"):
return True, "env"
return False, None
def list_engines() -> list[dict]:
"""Return a UI-ready list with per-engine availability stamped in."""
out = []
for e in REGISTRY.values():
installed, reason = _probe(e)
out.append({
entry = {
**e,
"installed": installed,
"availability_reason": reason,
"install_command": install_command(e),
})
}
# LLM engines additionally need a provider/key — surface configured-ness
# so the UI can distinguish "importable" from "actually ready to call".
if e.get("category") == "llm":
configured, via = _llm_configured()
entry["configured"] = configured
entry["configured_via"] = via
out.append(entry)
return out
+151 -44
View File
@@ -59,8 +59,9 @@ _ADAPT_PROMPT = """\
You are a cinematic dubbing writer. Rewrite the literal translation using the
editor's critique so it sounds natural, in-character, and fits the speaker's
time slot. Keep meaning faithful but prefer native idiom over word-for-word
accuracy. The output MUST be written in the same target language and script
as the literal translation never switch language or transliterate.
accuracy. Never introduce facts, names, or dialogue that are not present in
the source line. The output MUST be written in the same target language and
script as the literal translation never switch language or transliterate.
Reply ONLY with the adapted translation no quotes, no headers, no code
fences, no commentary."""
@@ -93,38 +94,136 @@ def _looks_like_target_script(text: str, code: str, threshold: float = 0.5) -> b
return (inside / len(letters)) >= threshold
def _llm_client():
"""Lazy-build the OpenAI-compatible client for the ACTIVE LLM provider.
# ── Divergence guard (shared with speech_rate's Autofit fit pass) ────────────
# For every Latin-script target `_looks_like_target_script` passes ANY text
# unconditionally (no `_SCRIPT_RANGES` entry), so it was the only — and for
# es/de/fr/… a no-op — gate on the ADAPT/fit LLM output. These checks close
# that gap for the whole class: runaway length (hallucinated dialogue,
# refusals, commentary) and the REFLECT critique echoed back as the "line".
Resolves through the LLM Providers registry (Settings LLM Providers) so a
provider configured there actually powers Cinematic/Autofit previously this
only read ``TRANSLATE_*``/``OPENAI_*`` directly, so the registry-configured
provider was ignored (the "LLM not wired" bug). The registry's ``custom``
provider still maps ``TRANSLATE_BASE_URL``/``TRANSLATE_API_KEY``, so legacy
env setups keep working. Returns None if no provider is configured.
"""
_SHORT_REF_CHARS = 20 # below this, a length *ratio* is meaningless
_SHORT_REF_ABS_SLACK = 120 # …use an absolute cap instead: ref + this many chars
def _refine_ratio_bounds() -> tuple[float, float]:
"""Accepted ``len(candidate)/len(reference)`` window for LLM refine output.
Anything outside is treated as divergence and the caller degrades to its
input text. Defaults [0.4, 2.5]; env-tunable like the cinematic budget."""
try:
from openai import OpenAI
except ImportError:
logger.warning("openai package not installed — cinematic mode unavailable.")
return None
from services import llm_providers
p = llm_providers.active_provider()
if p is None:
return None
base_url = llm_providers.resolve_base_url(p)
api_key = llm_providers.resolve_api_key(p)
if not api_key:
return None
kw = {"api_key": api_key}
if base_url:
kw["base_url"] = base_url
return OpenAI(**kw)
lo = float(os.environ.get("OMNIVOICE_REFINE_RATIO_MIN", "0.4"))
except ValueError:
lo = 0.4
try:
hi = float(os.environ.get("OMNIVOICE_REFINE_RATIO_MAX", "2.5"))
except ValueError:
hi = 2.5
return lo, hi
def _norm_overlap_text(s: str) -> str:
return " ".join(s.lower().split())
def _echoes_critique(candidate: str, critique: str) -> bool:
"""True when the "adaptation" is really the REFLECT critique leaking through.
Deterministic on purpose (no fuzzy matching): exact match after
case/whitespace normalization; containment the full critique inside the
candidate always counts, the candidate inside the critique only when it
covers most of it (critiques legitimately quote short phrases from the
line); or >0.8 token-set overlap.
"""
c = _norm_overlap_text(candidate)
k = _norm_overlap_text(critique)
if not c or not k:
return False
if c == k:
return True
if k in c: # critique embedded in the output
return True
if c in k and len(c) >= 0.6 * len(k): # output ≈ a big chunk of the critique
return True
ct, kt = set(c.split()), set(k.split())
union = ct | kt
return bool(union) and len(ct & kt) / len(union) > 0.8
def refine_output_ok(
reference: str,
candidate: str,
target_lang: str,
*,
critique: str | None = None,
max_ratio: float | None = None,
) -> tuple[bool, str | None]:
"""Sanity-check one LLM refine output against the text it was rewriting.
Shared by the Cinematic ADAPT step here and by ``speech_rate``'s Autofit
fit pass (speech_rate imports this; translator never imports speech_rate,
so there is no cycle). Returns ``(ok, reason)`` ``reason`` is ``None``
when ok, otherwise a short machine-readable tag for logs/error mapping.
Checks, in order:
script candidate must look like the target language's script
(``_looks_like_target_script``; Latin-script targets pass, as before);
length ``len(candidate)/len(reference)`` must sit inside
[``OMNIVOICE_REFINE_RATIO_MIN``, ``OMNIVOICE_REFINE_RATIO_MAX``]
(default 0.42.5; ``max_ratio`` overrides the upper bound). References
shorter than ~20 chars use an absolute cap (reference + 120 chars)
instead a two-word line legitimately doubles or halves;
critique echo the candidate must not be the critique itself.
"""
cand = (candidate or "").strip()
ref = (reference or "").strip()
if not cand:
return False, "empty"
if not _looks_like_target_script(cand, target_lang):
return False, f"wrong-script:{target_lang}"
lo, hi = _refine_ratio_bounds()
if max_ratio is not None:
hi = max_ratio
if ref:
if len(ref) < _SHORT_REF_CHARS:
if len(cand) > len(ref) + _SHORT_REF_ABS_SLACK:
return False, f"length-abs:{len(cand)}>{len(ref)}+{_SHORT_REF_ABS_SLACK}"
else:
ratio = len(cand) / len(ref)
if not (lo <= ratio <= hi):
return False, f"length-ratio:{ratio:.2f}"
if critique and _echoes_critique(cand, critique):
return False, "critique-echo"
return True, None
# The LLM Skills registry entry this pipeline resolves through — lets the
# user disable Cinematic/Autofit's LLM use or route it to a specific provider
# (Settings → LLM Skills) independently of the other LLM features.
_SKILL_ID = "cinematic_translation"
def _llm_client():
"""Lazy-build the OpenAI-compatible client for the Cinematic skill.
Resolves through the LLM Skills registry: per-skill provider override
global active provider (Settings LLM Providers). The registry's
``custom`` provider still maps ``TRANSLATE_BASE_URL``/``TRANSLATE_API_KEY``,
so legacy env setups keep working. Returns None if the skill is disabled
or no provider is configured the callers' Fast-fallback path.
The registry builds the client with ``max_retries=0`` (see
``llm_skills.resolve_skill_client``) so a 429 + long Retry-After can't make
one call sleep+retry past the cinematic wall-clock budget from inside a
single request. The pass-level budget (``cinematic_refine_many``) and the
per-call timeout stay the only bounds.
"""
from services import llm_skills
handle = llm_skills.resolve_skill_client(_SKILL_ID)
return handle.client if handle is not None else None
def _llm_model() -> str:
from services import llm_providers
p = llm_providers.active_provider()
from services import llm_providers, llm_skills
p = llm_skills.effective_provider(_SKILL_ID)
if p is not None:
return llm_providers.resolve_model(p)
return os.environ.get("TRANSLATE_MODEL", "gpt-4o-mini")
@@ -176,6 +275,7 @@ def _chat(client, *, system: str, user: str) -> str:
res = client.chat.completions.create(
model=_llm_model(),
timeout=_llm_timeout(),
temperature=0.2, # pinned like the Fast path — default 1.0 drifts/invents
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
@@ -277,21 +377,28 @@ def cinematic_refine_sync(
}
final = (adapted or "").strip() or literal_text
# Refuse adaptations that drifted off the target script (e.g. local LLM
# rewrote a Devanagari line in Latin/German). Caller still gets the
# critique so the UI can show what happened, but the live text falls
# back to the literal translation rather than corrupting the dub.
if final is not literal_text and not _looks_like_target_script(final, target_lang):
logger.warning(
"cinematic adapt produced wrong-script output for %s — falling back to literal",
target_lang,
)
return {
"text": literal_text,
"literal": literal_text,
"critique": critique,
"error": f"adapt-wrong-script:{target_lang}",
}
# Refuse adaptations that diverged from the line they were rewriting:
# wrong script (e.g. a local LLM rewrote a Devanagari line in
# Latin/German), runaway length (hallucinated dialogue, refusals,
# commentary — the script check alone passes ANY text for Latin-script
# targets), or the critique echoed back as the "adaptation". Caller still
# gets the critique so the UI can show what happened, but the live text
# falls back to the literal translation rather than corrupting the dub.
if final is not literal_text:
ok, reason = refine_output_ok(literal_text, final, target_lang, critique=critique)
if not ok:
logger.warning(
"cinematic adapt diverged for %s (%s) — falling back to literal",
target_lang, reason,
)
wrong_script = (reason or "").startswith("wrong-script")
return {
"text": literal_text,
"literal": literal_text,
"critique": critique,
"error": (f"adapt-wrong-script:{target_lang}" if wrong_script
else "adapt-diverged"),
}
return {
"text": final,
"literal": literal_text,
+374 -12
View File
@@ -55,6 +55,59 @@ def _mask_hf_tokens(value):
return _HF_TOKEN_MASK_RE.sub(_HF_TOKEN_MASK, value)
# ── HF Hub closed-client recovery (#880) ────────────────────────────────────
#
# huggingface_hub ≥1.x shares ONE global httpx client across every download.
# If anything closes it mid-lifecycle, every later hub call — e.g. an engine's
# first-use model download inside the generate path — dies with httpx's
# "Cannot send a request, as the client has been closed". The client is
# recoverable: ``close_session()`` drops it and the next hub call builds a
# fresh one, so the correct handling is a single targeted retry, not a
# user-facing failure.
def _is_closed_client_error(e) -> bool:
"""True iff ``e`` (or anything in its __cause__/__context__ chain) is
httpx's closed-client lifecycle error. Cycle-safe."""
seen, stack = set(), [e]
while stack:
exc = stack.pop()
if exc is None or id(exc) in seen:
continue
seen.add(id(exc))
low = str(exc).lower()
if "client has been closed" in low or "cannot send a request" in low:
return True
stack.append(exc.__cause__)
stack.append(exc.__context__)
return False
def _retry_once_with_fresh_hf_client(loader, what: str):
"""Run ``loader()`` — a model constructor that may download from the HF
Hub on first use. On the specific closed-client failure above, reset the
hub's shared client and retry exactly ONCE. Any other failure (and a
repeat closed-client failure) propagates untouched, where the generation
error classifier labels it as a network problem (#880)."""
try:
return loader()
except Exception as e:
if not _is_closed_client_error(e):
raise
logger.warning(
"%s: HF Hub httpx client was closed mid-download (%s); "
"retrying once with a fresh client.", what, e,
)
try:
from huggingface_hub.utils import close_session
close_session()
except Exception: # pragma: no cover — hub too old / API renamed
logger.warning(
"%s: couldn't reset the HF Hub client; retrying anyway.", what,
)
return loader()
# ── Protocol ────────────────────────────────────────────────────────────────
@@ -90,12 +143,19 @@ class TTSBackend(ABC):
supports_voice_design: bool = False
#: Whether this engine already emits mastered, studio-grade audio and should
#: therefore skip the shared apply_mastering() chain (Compressor + Reverb,
#: therefore skip the shared apply_mastering() chain (highpass + Compressor,
#: tuned for OmniVoice's 24 kHz output). Studio engines like VoxCPM2 (native
#: 48 kHz) set this True so their clean output isn't pumped/reverbed. Loudness
#: 48 kHz) set this True so their clean output isn't pumped. Loudness
#: normalisation is applied regardless — it's a benign peak scale.
applies_own_mastering: bool = False
#: Whether this engine can clone an arbitrary voice from reference audio
#: (`ref_audio=`), as opposed to only offering a fixed set of preset
#: voices. Default True — most engines clone. Dub/batch gate on this
#: (issue #312 class) before committing to a job that needs it, instead
#: of silently falling back to OmniVoice or mis-cloning per segment.
supports_cloning: bool = True
#: GPU/accelerator targets the engine can run on. Surfaced via the
#: Engine Compatibility Matrix (Plan 02-04 / ENGINE-06) so users can
#: tell at a glance which engines will use their hardware. Defaults to
@@ -550,6 +610,7 @@ class KittenTTSBackend(TTSBackend):
display_name = "KittenTTS (English, 8 preset voices, CPU realtime)"
# KittenTTS ships as an ONNX CPU graph; no CUDA/MPS path today.
gpu_compat = ("cpu",)
supports_cloning = False # fixed preset voices only; ref_audio is ignored
PRESET_VOICES = [
"expr-voice-2-m", "expr-voice-2-f",
@@ -587,7 +648,13 @@ class KittenTTSBackend(TTSBackend):
"OMNIVOICE_KITTENTTS_MODEL", "KittenML/kitten-tts-mini-0.8"
)
logger.info("Loading KittenTTS from %s", checkpoint)
self._model = KittenTTS(checkpoint)
# #880: the first-use load downloads ~80 MB from the HF Hub inside the
# generate path; if the hub's shared httpx client was closed
# mid-lifecycle, retry once with a fresh client instead of failing
# the whole generation.
self._model = _retry_once_with_fresh_hf_client(
lambda: KittenTTS(checkpoint), what="KittenTTS"
)
def generate(self, text: str, **kw) -> torch.Tensor:
import numpy as np
@@ -624,6 +691,54 @@ class KittenTTSBackend(TTSBackend):
# ── MLX-Audio (mac-ARM engine multiplexer) ──────────────────────────────────
# #977: Kokoro's own ALIASES table (mlx_audio.tts.models.kokoro.pipeline) only
# recognizes ISO-ish tokens ("en", "es", "fr-fr", "pt-br", …) — it has no idea
# what a full language name is. OmniVoice's `language` kwarg is normally a
# full display name from frontend/src/languages.json (e.g. "Dutch",
# "Spanish"), forwarded verbatim by the frontend and by
# `OmniVoiceBackend.generate()`. Translate the subset Kokoro actually
# supports to the ISO token its own ALIASES expects; a caller that already
# passes an ISO code (or one of Kokoro's own single-letter codes) is
# resolved unchanged by `resolve_kokoro_lang_code()` below.
_KOKORO_ISO_BY_FULL_NAME = {
"english": "en",
"spanish": "es",
"french": "fr",
"hindi": "hi",
"italian": "it",
"portuguese": "pt",
"japanese": "ja",
"chinese": "zh",
}
def resolve_kokoro_lang_code(language: str) -> str:
"""Map a full language name / ISO code to Kokoro's single-letter
`lang_code`, against the AUTHORITATIVE table read from the installed
mlx-audio package (never a hardcoded guess the vendored table is the
only source of truth and can change across mlx-audio versions).
Raises ``ValueError`` for anything Kokoro doesn't support, naming what
it *does* support instead of forwarding a bogus code into Kokoro's
`assert lang_code in LANG_CODES`, which crashes with an unreadable
``(lang_code, LANG_CODES)`` tuple/dict repr (#977).
"""
from mlx_audio.tts.models.kokoro.pipeline import ALIASES, LANG_CODES
key = language.strip().lower()
iso = _KOKORO_ISO_BY_FULL_NAME.get(key, key)
code = ALIASES.get(iso, iso)
if code not in LANG_CODES:
supported = ", ".join(sorted(name.title() for name in _KOKORO_ISO_BY_FULL_NAME))
raise ValueError(
f"mlx-audio's Kokoro model (mlx-community/Kokoro-82M-bf16) doesn't "
f"support language={language!r}. Kokoro supports: {supported}. "
f"Pick one of those, leave language as 'Auto', or switch to a "
f"multilingual engine (e.g. OmniVoice) for other languages."
)
return code
class MLXAudioBackend(TTSBackend):
"""Blaizzy/mlx-audio — Apple-Silicon-only wrapper over 14+ TTS engines
(Kokoro, CSM, Dia, Qwen3-TTS, Chatterbox, MeloTTS, OuteTTS, Spark,
@@ -662,7 +777,16 @@ class MLXAudioBackend(TTSBackend):
def __init__(self):
self._model = None
self._sr = 24000 # most mlx-audio engines emit 24 kHz mono
key = os.environ.get("OMNIVOICE_MLX_AUDIO_MODEL", self.DEFAULT_MODEL_KEY)
# Env var > persisted UI choice (#981 — Settings → Engines curated-
# model picker) > default. Mirrors active_backend_id()'s resolution
# order exactly so power-users can still pin a model without the UI
# silently undoing it.
from core import prefs
key = prefs.resolve(
"mlx_audio_model_id",
env="OMNIVOICE_MLX_AUDIO_MODEL",
default=self.DEFAULT_MODEL_KEY,
)
# Accept either a curated key ("kokoro") or a full HF repo id
# ("mlx-community/Kokoro-82M-bf16") — flexibility for power users.
self._model_id = self.CURATED_MODELS.get(key, key)
@@ -700,6 +824,18 @@ class MLXAudioBackend(TTSBackend):
# silently ignores languages it doesn't know.
return ["multi"]
@property
def supports_cloning(self) -> bool:
"""Model-dependent — this adapter multiplexes 7+ curated models and
only some take a reference-audio speaker prompt. `generate()` passes
`ref_audio` through when present (~kwargs below) but silently retries
without it on a TypeError, so an engine picked for cloning that's
actually running Kokoro/Qwen3-TTS/etc. would clone nothing. Of the
curated set, only CSM (`mlx-community/csm-1b-8bit`) is confirmed to
accept a reference prompt default False for every other model,
curated or user-supplied, until positively confirmed."""
return self._model_id == self.CURATED_MODELS.get("csm")
def _ensure_loaded(self):
if self._model is not None:
return
@@ -713,6 +849,7 @@ class MLXAudioBackend(TTSBackend):
voice = kw.get("voice")
ref_audio = kw.get("ref_audio")
ref_text = kw.get("ref_text")
language = kw.get("language")
speed = float(kw.get("speed", 1.0))
@@ -723,7 +860,31 @@ class MLXAudioBackend(TTSBackend):
kwargs = {"text": text, "speed": speed}
if voice: kwargs["voice"] = voice
if ref_audio: kwargs["ref_audio"] = ref_audio
if language: kwargs["lang_code"] = language[:2].lower()
# CSM (sesame.py) only builds its cloning context when BOTH ref_audio
# AND ref_text are present — with ref_text missing, its context list
# stays empty and indexing into it raises an opaque
# "IndexError: list index out of range" deep inside mlx-audio,
# instead of ever attempting the clone. Community-diagnosed (#1012).
if ref_audio and ref_text: kwargs["ref_text"] = ref_text
if language and language != "Auto":
if self._model_id == self.CURATED_MODELS.get("kokoro"):
# Kokoro's vendored pipeline hard-asserts `lang_code` against
# its own single-letter table — a bogus code crashes with an
# unreadable AssertionError instead of failing cleanly
# (#977). Resolve against the authoritative installed table
# instead of guessing via `language[:2]`.
kwargs["lang_code"] = resolve_kokoro_lang_code(language)
else:
# `lang_code`-as-2-letter-truncation is Kokoro's own
# convention, not mlx-audio's in general — other curated
# models either ignore unrecognized kwargs (CSM/Dia/OuteTTS
# accept **kwargs and drop it) or expect something else
# entirely (Qwen3-TTS's own docstring: "lang_code: Language
# code (auto, chinese, english, etc.)" — a full name, not a
# 2-letter code). Kokoro's strict validation doesn't apply to
# them, so don't reject a language that's valid for whatever
# model is actually active.
kwargs["lang_code"] = language[:2].lower()
pieces = []
try:
@@ -1052,6 +1213,7 @@ class SherpaOnnxBackend(TTSBackend):
# Sherpa-ONNX uses the onnxruntime providers — CPU is the universal
# baseline; CUDA provider is available on Linux/Windows installs.
gpu_compat = ("cuda", "cpu")
supports_cloning = False # VITS speaker-id only; no ref_audio support
def __init__(self):
self._tts = None
@@ -1061,13 +1223,34 @@ class SherpaOnnxBackend(TTSBackend):
def is_available(cls) -> tuple[bool, str]:
try:
import sherpa_onnx # noqa: F401
return True, "ready"
except ImportError as e:
return False, (
f"sherpa-onnx not installed: {e}. "
"Install with: pip install sherpa-onnx. "
"Download models from https://github.com/k2-fsa/sherpa-onnx/releases"
)
# #919: sherpa-onnx ships no bundled default model — it can only
# synthesize once OMNIVOICE_SHERPA_MODEL points at a downloaded model
# directory. Gate on it here (like the other path-configured opt-in
# engines: Confucius4/dots/MOSS) so the picker marks it unavailable-
# with-a-reason instead of letting a user select it, generate, and hit
# a config error that used to be mislabeled as out-of-memory.
model_dir = os.environ.get("OMNIVOICE_SHERPA_MODEL", "").strip()
if not model_dir:
return False, (
"OMNIVOICE_SHERPA_MODEL not set. Point it to a sherpa-onnx TTS "
"model directory (containing model.onnx + tokens.txt), then "
"restart OmniVoice. Download models from "
"https://github.com/k2-fsa/sherpa-onnx/releases"
)
if not os.path.isfile(os.path.join(model_dir, "model.onnx")):
return False, (
f"No model.onnx in OMNIVOICE_SHERPA_MODEL ({model_dir}). Point "
"it at a sherpa-onnx TTS model directory containing model.onnx "
"+ tokens.txt. Download models from "
"https://github.com/k2-fsa/sherpa-onnx/releases"
)
return True, "ready"
@property
def sample_rate(self) -> int:
@@ -1200,7 +1383,13 @@ class _LazyRegistry(dict):
# effect on every list_backends() call — we keep iteration light
# and let the caller's __getitem__ trigger the import.
seen: set[str] = set()
for k in dict.__iter__(self):
# Snapshot the live keys before yielding. A concurrent thread's lazy
# __getitem__ inserts into self (self[key] = cls), and list_backends()
# runs in a FastAPI threadpool — so holding a *live* dict iterator open
# across the per-engine is_available() probes would raise
# "dictionary changed size during iteration". list() consumes the
# iterator atomically under the GIL, closing that window.
for k in list(dict.__iter__(self)):
seen.add(k)
yield k
for k in _LAZY_REGISTRY:
@@ -1263,6 +1452,38 @@ _INSTALL_HINTS: dict[str, str] = {
}
# Copy-paste-ready setup line for opt-in engines gated behind a filesystem-path
# env var (issue #498 / #590). The install_hint tells users a var exists; this
# is the *exact* `export VAR=...` line to run, so they don't have to reconstruct
# it from the docs. Surfaced verbatim in the Compat Matrix's "Why unavailable?"
# disclosure with a Copy button. Single-sourced here so it can't drift from the
# var each engine's is_available() actually reads. bash/zsh form (the dominant
# clone-and-run workflow for these engines; dots.tts is *nix-only anyway).
_SETUP_SNIPPETS: dict[str, str] = {
"indextts2": "export OMNIVOICE_INDEXTTS_DIR=/path/to/index-tts",
"moss-tts-v15": "export OMNIVOICE_MOSS_TTS_V15_DIR=/path/to/MOSS-TTS",
"dots-tts": "export OMNIVOICE_DOTS_TTS_DIR=/path/to/dots.tts",
"confucius4-tts": "export OMNIVOICE_CONFUCIUS4_TTS_DIR=/path/to/Confucius4-TTS",
# #919: sherpa-onnx gates on a downloaded model dir (model.onnx + tokens.txt).
"sherpa-onnx": "export OMNIVOICE_SHERPA_MODEL=/path/to/sherpa-onnx-model",
}
# Short, readable labels for mlx-audio's curated models (#981) — surfaced in
# the Settings → Engines model picker so users see more than a bare key.
# Single-sourced here rather than on MLXAudioBackend.CURATED_MODELS itself so
# the class dict stays a plain key → repo-id map (what __init__ needs).
_MLX_AUDIO_MODEL_LABELS: dict[str, str] = {
"kokoro": "Kokoro (default, fast)",
"csm": "CSM (voice cloning)",
"qwen3-tts": "Qwen3-TTS (voice design)",
"dia": "Dia",
"chatterbox": "Chatterbox",
"melotts": "MeloTTS (lightweight)",
"outetts": "OuteTTS",
}
def list_backends() -> list[dict]:
"""Enumerate every registered backend with its availability state.
@@ -1274,6 +1495,7 @@ def list_backends() -> list[dict]:
"available": bool,
"reason": Optional[str], # message when not available
"install_hint": Optional[str],
"setup_snippet": Optional[str], # exact `export VAR=...` for path-gated opt-in engines
"last_error": Optional[str], # cached most-recent failure
"isolation_mode": "in-process" | "subprocess",
"gpu_compat": list[str], # subset of {cuda, rocm, mps, xpu, cpu}
@@ -1337,12 +1559,30 @@ def list_backends() -> list[dict]:
"available": ok,
"reason": None if ok else _mask_hf_tokens(msg),
"install_hint": _INSTALL_HINTS.get(bid),
# Exact `export VAR=...` line for path-gated opt-in engines, or None.
"setup_snippet": _SETUP_SNIPPETS.get(bid),
"last_error": _LAST_ERRORS.get(bid),
"isolation_mode": isolation,
"gpu_compat": list(gpu_compat),
# effective_device / routing_status / routing_reason (scrubbed):
**routing_fields(gpu_compat, caps),
})
# #981: mlx-audio multiplexes 7+ curated models behind one backend id
# — surface the roster + the currently-active pick so Settings can
# render a model picker instead of always defaulting to Kokoro.
# mlx-audio ONLY; every other backend loads a single fixed model.
if bid == "mlx-audio":
from core import prefs
active_model = prefs.resolve(
"mlx_audio_model_id",
env="OMNIVOICE_MLX_AUDIO_MODEL",
default=cls.DEFAULT_MODEL_KEY,
)
out[-1]["curated_models"] = [
{"key": key, "label": _MLX_AUDIO_MODEL_LABELS.get(key, key), "repo_id": repo_id}
for key, repo_id in cls.CURATED_MODELS.items()
]
out[-1]["active_model_id"] = active_model
return out
@@ -1352,6 +1592,29 @@ def get_backend_class(backend_id: str) -> type[TTSBackend]:
return _REGISTRY[backend_id]
def cloning_capable_engine_ids() -> list[str]:
"""Engine ids that support reference-audio voice cloning — used to build
an actionable error when the active engine can't (dub/batch gating).
Iterates the same registry ``list_backends()`` uses, via ``.items()`` so
lazy entries resolve through ``_LazyRegistry``'s snapshot-safe iteration
(see ``_LazyRegistry.__iter__``) exactly like every other registry scan
in this module.
A class-level ``getattr`` on a *property* returns the descriptor object
itself (always truthy) rather than its computed value so a
model-dependent adapter like ``MLXAudioBackend`` (only some of its 7+
curated models can clone) would always show up here regardless of which
model is actually configured. Excluded rather than falsely recommended:
``isinstance(..., bool)`` is False for a descriptor, True for a plain
class attribute.
"""
return [
bid for bid, cls in _REGISTRY.items()
if isinstance((v := getattr(cls, "supports_cloning", True)), bool) and v
]
def active_routing() -> dict | None:
"""Routing verdict for the currently-active TTS engine, or ``None`` if it
can't be determined (no engine / probe failure).
@@ -1420,15 +1683,21 @@ def active_backend_id() -> str:
# call the outgoing engine's unload() before switching.
_active_instance: "TTSBackend | None" = None
_active_instance_id: "str | None" = None
# mlx-audio multiplexes 7+ curated models behind one backend id — a model-only
# switch (same "mlx-audio" id, different curated model) must also invalidate
# the cache, or picking a different model in Settings has no effect until the
# app restarts (#981). Only meaningful when _active_instance_id == "mlx-audio".
_active_mlx_model_key: "str | None" = None
def reset_active_backend() -> None:
"""Unload + clear the cached active backend. For app shutdown and tests.
Idempotent and best-effort a raising unload() never propagates."""
global _active_instance, _active_instance_id
global _active_instance, _active_instance_id, _active_mlx_model_key
inst = _active_instance
_active_instance = None
_active_instance_id = None
_active_mlx_model_key = None
if inst is not None:
try:
inst.unload()
@@ -1446,13 +1715,32 @@ def get_active_tts_backend(*, model=None) -> TTSBackend:
``model=`` (caller already holds a loaded model), we return a fresh view
over the shared singleton rather than caching it but a switch *away from*
a different engine still triggers that engine's unload().
For mlx-audio specifically, the backend id alone doesn't capture *which*
curated model is loaded (#981) — so we also track the resolved model key
and treat a model-only change as a switch, reusing the exact same
unload-and-reconstruct path as an id switch.
"""
global _active_instance, _active_instance_id
global _active_instance, _active_instance_id, _active_mlx_model_key
bid = active_backend_id()
# Switching engines: release the outgoing one first. Best-effort so a bad
# unload() can never block the switch.
if _active_instance is not None and _active_instance_id != bid:
mlx_model_key = None
if bid == "mlx-audio":
from core import prefs
mlx_model_key = prefs.resolve(
"mlx_audio_model_id",
env="OMNIVOICE_MLX_AUDIO_MODEL",
default=MLXAudioBackend.DEFAULT_MODEL_KEY,
)
# Switching engines (or, for mlx-audio, switching curated models): release
# the outgoing one first. Best-effort so a bad unload() can never block
# the switch.
switching = _active_instance is not None and (
_active_instance_id != bid
or (bid == "mlx-audio" and mlx_model_key != _active_mlx_model_key)
)
if switching:
try:
_active_instance.unload()
except Exception as exc: # noqa: BLE001
@@ -1460,6 +1748,7 @@ def get_active_tts_backend(*, model=None) -> TTSBackend:
type(_active_instance).__name__, exc)
_active_instance = None
_active_instance_id = None
_active_mlx_model_key = None
cls = get_backend_class(bid)
if cls is OmniVoiceBackend and model is not None:
@@ -1471,9 +1760,82 @@ def get_active_tts_backend(*, model=None) -> TTSBackend:
if _active_instance is None or _active_instance_id != bid:
_active_instance = OmniVoiceBackend(model=model) if cls is OmniVoiceBackend else cls()
_active_instance_id = bid
_active_mlx_model_key = mlx_model_key
return _active_instance
# ── Shared generation-time engine resolution (issue #312 class) ───────────
#
# dub_generate.py and batch.py used to call services.model_manager.get_model()
# directly, hardcoding OmniVoice regardless of the engine selected in
# Settings → Engines — a SILENT fallback: pick VoxCPM2, dub anyway with
# OmniVoice, no error. This is the single resolution path both routers now
# call instead, mirroring generation.py's /generate resolution (engine id →
# is_available() → routing gate) plus a voice-cloning capability gate that
# /generate doesn't need (OmniVoice's native path always clones).
async def resolve_generation_backend(
*, require_cloning: bool = False, cloning_purpose: str = "dubbing",
) -> TTSBackend:
"""Resolve + validate the active TTS engine for a generation call.
Returns the live backend instance (:func:`get_active_tts_backend`)
cached, and properly unload()ed on an engine switch. Raises ``ValueError``
with an actionable message (never silently falls back to OmniVoice) when:
* the configured engine id is unknown (bad env var / stale pref),
* the engine reports itself unavailable (``is_available()``),
* the engine needs an accelerator this host lacks and has no CPU path
(``routing_status == "unavailable"``),
* ``require_cloning`` is True and the resolved backend can't clone
from reference audio (``supports_cloning`` False) checked on the
live *instance*, not the class, so a model-dependent adapter like
MLX-Audio (Kokoro vs. CSM) is judged by what's actually loaded.
"""
engine_id = active_backend_id()
try:
backend_cls = get_backend_class(engine_id)
except ValueError as e:
raise ValueError(
f"Active TTS engine '{engine_id}' is not a recognized backend ({e}). "
"Check Settings → Engines or the OMNIVOICE_TTS_BACKEND env var."
) from e
try:
ok, msg = backend_cls.is_available()
except Exception as exc: # noqa: BLE001 — surface as an actionable ValueError
ok, msg = False, f"{type(exc).__name__}: {exc}"
if not ok:
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())
if routing["routing_status"] == "unavailable":
raise ValueError(routing["routing_reason"])
_model = None
if backend_cls is OmniVoiceBackend:
# OmniVoice needs its model pre-loaded before construction: called
# from an async context, OmniVoiceBackend._ensure_loaded() refuses to
# bootstrap its own event loop (see its docstring) — same reason
# generation.py's /generate special-cases this backend.
from services.model_manager import get_model
_model = await get_model()
backend = get_active_tts_backend(model=_model)
if require_cloning and not getattr(backend, "supports_cloning", True):
raise ValueError(
f"The active TTS engine '{engine_id}' doesn't support voice cloning, "
f"so {cloning_purpose} can't preserve speaker voices. Switch to one "
f"of: {', '.join(cloning_capable_engine_ids())} in Settings → "
"Engines, or use OmniVoice for this job."
)
return backend
# ── PEP 562 lazy attribute re-export ───────────────────────────────────────
#
# Allows ``from services.tts_backend import IndexTTS2Backend`` to keep
@@ -17,14 +17,25 @@ import pytest
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from services import asr_backend # noqa: E402
from services.asr_backend import ( # noqa: E402
ASRTimeoutError,
ASR_TRANSCRIBE_TIMEOUT_S,
reset_pool_after_wedge,
run_transcribe_guarded,
)
from concurrent.futures import ThreadPoolExecutor # noqa: E402
@pytest.fixture(autouse=True)
def _fresh_timeout_streak(monkeypatch):
"""The consecutive-timeout streak (#730 residual B) is process-global
session state; zero it per test so ordering can't leak recommendations,
and pin the active engine so a dev box's prefs can't flip the hint."""
monkeypatch.setattr(asr_backend, "_timeout_streak", 0)
monkeypatch.setattr(asr_backend, "active_backend_id", lambda: "whisperx")
def test_default_timeout_is_env_overridable(monkeypatch):
# The constant is read at import; just assert it's a sane positive default.
assert ASR_TRANSCRIBE_TIMEOUT_S > 0
@@ -113,3 +124,115 @@ def test_timeout_without_reset_capable_pool_does_not_crash():
asyncio.run(_go())
pool.shutdown(wait=False)
# ── Residual B on #730: consecutive timeouts recommend the isolated engine ──
def _hang_forever():
time.sleep(5)
return "never"
async def _timeout_once(pool, timeout=0.1) -> str:
with pytest.raises(ASRTimeoutError) as ei:
await run_transcribe_guarded(pool, _hang_forever, what="Dub", timeout=timeout)
return str(ei.value)
def test_second_consecutive_timeout_recommends_isolated_engine():
"""When guarded timeouts hit twice in a row in one session, pool resets
clearly aren't recovering the hang — the error the user sees must name the
crash-isolated escape-hatch engine (and make clear we never auto-switch)."""
pool = ThreadPoolExecutor(max_workers=2)
async def _go():
first = await _timeout_once(pool)
assert "faster-whisper-isolated" not in first # one timeout ≠ a pattern
second = await _timeout_once(pool)
assert "faster-whisper-isolated" in second
assert "Settings → Engines" in second
assert "never switches engines automatically" in second
asyncio.run(_go())
pool.shutdown(wait=False)
def test_successful_transcribe_resets_the_timeout_streak():
"""'Consecutive' must mean consecutive: a transcribe that completes between
two timeouts proves the pool recovered, so the recommendation must not fire."""
pool = ThreadPoolExecutor(max_workers=3)
async def _go():
await _timeout_once(pool)
out = await run_transcribe_guarded(pool, lambda: "ok", what="Dub", timeout=5.0)
assert out == "ok"
second = await _timeout_once(pool)
assert "faster-whisper-isolated" not in second
asyncio.run(_go())
pool.shutdown(wait=False)
def test_no_recommendation_when_already_on_isolated_engine(monkeypatch):
"""Recommending the isolated engine to a user already running it is noise —
the base message's smaller-model/CPU guidance is all that's left."""
monkeypatch.setattr(
asr_backend, "active_backend_id", lambda: "faster-whisper-isolated"
)
pool = ThreadPoolExecutor(max_workers=2)
async def _go():
await _timeout_once(pool)
second = await _timeout_once(pool)
assert "faster-whisper-isolated) in Settings" not in second
assert "never switches engines automatically" not in second
asyncio.run(_go())
pool.shutdown(wait=False)
def test_timeout_env_name_is_parameterized():
"""The chunked dub path passes its own knob; the message must name IT, not
the whole-file env var (actionable errors point at the right dial)."""
pool = ThreadPoolExecutor(max_workers=1)
async def _go():
with pytest.raises(ASRTimeoutError) as ei:
await run_transcribe_guarded(
pool, _hang_forever, what="Dub chunk 1/3", timeout=0.1,
timeout_env="OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S",
)
msg = str(ei.value)
assert "OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S" in msg
assert "OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S" not in msg
asyncio.run(_go())
pool.shutdown(wait=False)
def test_reset_pool_after_wedge_is_shared_and_best_effort():
"""One reset mechanism for every transcribe path (#730 residual A): it
resets a reset-capable pool, no-ops a plain executor, and never raises."""
class _Pool:
resets = 0
def reset(self):
self.resets += 1
p = _Pool()
assert reset_pool_after_wedge(p, what="Dub chunk 1/2") is True
assert p.resets == 1
plain = ThreadPoolExecutor(max_workers=1)
try:
assert reset_pool_after_wedge(plain) is False
finally:
plain.shutdown(wait=False)
class _Broken:
def reset(self):
raise RuntimeError("reset blew up")
assert reset_pool_after_wedge(_Broken()) is False # must not raise
Regular → Executable
View File
+527 -2
View File
@@ -16,7 +16,7 @@
},
"frontend": {
"name": "omnivoice-studio",
"version": "0.3.8",
"version": "0.3.11",
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/source-serif-4": "^5.2.9",
@@ -31,6 +31,7 @@
"@radix-ui/react-toggle": "^1.1.12",
"@radix-ui/react-toggle-group": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.10",
"@scalar/api-reference-react": "^0.9.52",
"@tailwindcss/vite": "^4.3.1",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-table": "^8.21.3",
@@ -84,6 +85,14 @@
"packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.13", "", { "dependencies": { "@ai-sdk/provider": "3.0.2", "@ai-sdk/provider-utils": "4.0.5", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-g7nE4PFtngOZNZSy1lOPpkC+FAiHxqBJXqyRMEG7NUrEVZlz5goBdtHg1YgWRJIX776JTXAmbOI5JreAKVAsVA=="],
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.2", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-HrEmNt/BH/hkQ7zpi2o6N3k1ZR1QTb7z85WYhYygiTxOQuaml4CMtHCWRbric5WPU+RNsYI7r1EpyVQMKO1pYw=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.5", "", { "dependencies": { "@ai-sdk/provider": "3.0.2", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Ow/X/SEkeExTTc1x+nYLB9ZHK2WUId8+9TlkamAx7Tl9vxU+cKzWx2dwjgMHeCN6twrgwkLrrtqckQeO4mxgVA=="],
"@ai-sdk/vue": ["@ai-sdk/vue@3.0.33", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.5", "ai": "6.0.33", "swrv": "^1.0.4" }, "peerDependencies": { "vue": "^3.3.4" } }, "sha512-czM9Js3a7f+Eo35gjEYEeJYUoPvMg5Dfi4bOLyDBghLqn0gaVg8yTmTaSuHCg+3K/+1xPjyXd4+2XcQIohWWiQ=="],
"@antfu/ni": ["@antfu/ni@30.2.0", "", { "dependencies": { "fzf": "^0.5.2", "package-manager-detector": "^1.6.0", "tinyexec": "^1.2.4", "tinyglobby": "^0.2.17" }, "bin": { "ni": "bin/ni.mjs", "nci": "bin/nci.mjs", "nr": "bin/nr.mjs", "nup": "bin/nup.mjs", "nd": "bin/nd.mjs", "nlx": "bin/nlx.mjs", "na": "bin/na.mjs", "nun": "bin/nun.mjs" } }, "sha512-/FOdAP1w8COnANVD3TtNj/tnpt/36RkU/ysKZTqx86x9acdhCqTFjDXNYVDyBg6UzcrTwWPUeY75ng7CWLNr+g=="],
"@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=="],
@@ -130,6 +139,30 @@
"@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=="],
"@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.10.4", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg=="],
"@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="],
"@codemirror/lang-html": ["@codemirror/lang-html@6.4.11", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw=="],
"@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="],
"@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="],
"@codemirror/lang-xml": ["@codemirror/lang-xml@6.1.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/xml": "^1.0.0" } }, "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg=="],
"@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.3", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ=="],
"@codemirror/language": ["@codemirror/language@6.12.4", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A=="],
"@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="],
"@codemirror/state": ["@codemirror/state@6.7.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-Zbl9NyscLMZkfXPQnNAIIAFftidrA1UbcJEIMp24C0Bukc2I5T8wJS0wsXYsnDOqCFJUeJ1BITGNs5CqPDSmSg=="],
"@codemirror/view": ["@codemirror/view@6.43.4", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-YImu23iyKfncJzT7sRy+rEqEhSc8RhOHqDxwy4WzXRKJwYm6iwf/9OJk5ctCAdZ6yi2ZqaGEvmf55fSVqMDrgg=="],
"@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
"@csstools/css-calc": ["@csstools/css-calc@3.2.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w=="],
@@ -170,7 +203,9 @@
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@floating-ui/vue": ["@floating-ui/vue@1.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.7.4", "@floating-ui/utils": "^0.2.10", "vue-demi": ">=0.13.0" } }, "sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ=="],
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="],
@@ -190,6 +225,10 @@
"@hapi/topo": ["@hapi/topo@6.0.2", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg=="],
"@headlessui/tailwindcss": ["@headlessui/tailwindcss@0.2.2", "", { "peerDependencies": { "tailwindcss": "^3.0 || ^4.0" } }, "sha512-xNe42KjdyA4kfUKLLPGzME9zkH7Q3rOZ5huFihWNWOQFxnItxPB3/67yBI8/qBfY8nwBRx5GHn4VprsoluVMGw=="],
"@headlessui/vue": ["@headlessui/vue@1.7.23", "", { "dependencies": { "@tanstack/vue-virtual": "^3.0.0-beta.60" }, "peerDependencies": { "vue": "^3.2.0" } }, "sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg=="],
"@henrygd/queue": ["@henrygd/queue@1.2.0", "", {}, "sha512-jW/BLSTpcvExDhqJGxtIPgGr2O0IFF8XUNDwEbfCfhrXT8a4xztQ9Lv6U/vbYzYC0xVWn+3zv6YnLUh3bEFUKA=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
@@ -200,6 +239,10 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@internationalized/date": ["@internationalized/date@3.12.2", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw=="],
"@internationalized/number": ["@internationalized/number@3.6.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -210,8 +253,30 @@
"@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=="],
"@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="],
"@lezer/css": ["@lezer/css@1.3.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q=="],
"@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
"@lezer/html": ["@lezer/html@1.3.13", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="],
"@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="],
"@lezer/json": ["@lezer/json@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="],
"@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="],
"@lezer/xml": ["@lezer/xml@1.0.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww=="],
"@lezer/yaml": ["@lezer/yaml@1.0.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.4.0" } }, "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw=="],
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.3", "", {}, "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
"@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.137.0", "", { "os": "android", "cpu": "arm" }, "sha512-KDs+0VPdEmasOkpuJHW9V5WCF+cvYdMQv2Jd+aJXt+cxIx12NToRQRbXaRwUEDsZw+/jMk81Ve8ZFbjUkJTOwA=="],
"@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.137.0", "", { "os": "android", "cpu": "arm64" }, "sha512-WhALNzfy3x/RfC6bsqX+csavuUY0yHHE7XfgPE5M542uhoBZUUoGTPG+nkMbGoG4+gcfss5s7urMyn5QBHu0sw=="],
@@ -368,6 +433,8 @@
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.71.0", "", { "os": "win32", "cpu": "x64" }, "sha512-D2kyEIPHk/G/wiZLnwTVC/sVst+T/lKldVOjAFpgTIBUAOlry72e5OiapDbDBF4LfJLkN5ypJb/8Eu6yJzkveQ=="],
"@phosphor-icons/core": ["@phosphor-icons/core@2.1.1", "", {}, "sha512-v4ARvrip4qBCImOE5rmPUylOEK4iiED9ZyKjcvzuezqMaiRASCHKcRIuvvxL/twvLpkfnEODCOJp5dM4eZilxQ=="],
"@playwright/test": ["@playwright/test@1.61.0", "", { "dependencies": { "playwright": "1.61.0" }, "bin": { "playwright": "cli.js" } }, "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA=="],
"@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
@@ -446,6 +513,8 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="],
"@replit/codemirror-css-color-picker": ["@replit/codemirror-css-color-picker@6.3.0", "", { "peerDependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-19biDANghUm7Fz7L1SNMIhK48tagaWuCOHj4oPPxc7hxPGkTVY2lU/jVZ8tsbTKQPVG7BO2CBDzs7CBwb20t4A=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="],
@@ -478,12 +547,64 @@
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@scalar/agent-chat": ["@scalar/agent-chat@0.12.16", "", { "dependencies": { "@ai-sdk/vue": "3.0.33", "@scalar/api-client": "3.13.1", "@scalar/components": "0.27.4", "@scalar/helpers": "0.9.0", "@scalar/icons": "0.7.3", "@scalar/json-magic": "0.12.17", "@scalar/openapi-types": "0.9.1", "@scalar/schemas": "0.7.1", "@scalar/themes": "0.16.2", "@scalar/types": "0.16.1", "@scalar/use-toasts": "0.10.2", "@scalar/validation": "0.6.0", "@scalar/workspace-store": "0.55.2", "@vueuse/core": "13.9.0", "ai": "6.0.33", "js-base64": "^3.7.8", "neverpanic": "0.0.8", "truncate-json": "3.0.1", "vue": "^3.5.30" } }, "sha512-YETdit7xhWpqdu4PMDM6AVQXID6GtJ1MSS0pmdyUFpR1xG02ABFWPsZHJ8+LQvFQXTC9ZOowtjgP8G9gTKA+KQ=="],
"@scalar/api-client": ["@scalar/api-client@3.13.1", "", { "dependencies": { "@headlessui/tailwindcss": "^0.2.2", "@headlessui/vue": "1.7.23", "@scalar/blocks": "0.1.2", "@scalar/components": "0.27.4", "@scalar/helpers": "0.9.0", "@scalar/icons": "0.7.3", "@scalar/oas-utils": "0.19.3", "@scalar/openapi-types": "0.9.1", "@scalar/sidebar": "0.9.27", "@scalar/snippetz": "0.9.20", "@scalar/themes": "0.16.2", "@scalar/typebox": "^0.1.3", "@scalar/types": "0.16.1", "@scalar/use-codemirror": "0.14.12", "@scalar/use-hooks": "0.4.7", "@scalar/use-toasts": "0.10.2", "@scalar/workspace-store": "0.55.2", "@vueuse/core": "13.9.0", "@vueuse/integrations": "13.9.0", "focus-trap": "^7.8.0", "fuse.js": "^7.1.0", "js-base64": "^3.7.8", "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.30", "yaml": "^2.8.3", "zod": "^4.3.5" } }, "sha512-+UWLBGY2dFFrbCYtEBHpjezZvSg/7eJ+CZs3oWo3Rr6JmVIJ/CKN31Ev5KAfce0w5Xmf7vbaWdExZheW+3ff7Q=="],
"@scalar/api-reference": ["@scalar/api-reference@1.62.3", "", { "dependencies": { "@headlessui/vue": "1.7.23", "@scalar/agent-chat": "0.12.16", "@scalar/api-client": "3.13.1", "@scalar/blocks": "0.1.2", "@scalar/code-highlight": "0.4.0", "@scalar/components": "0.27.4", "@scalar/helpers": "0.9.0", "@scalar/icons": "0.7.3", "@scalar/oas-utils": "0.19.3", "@scalar/schemas": "0.7.1", "@scalar/sidebar": "0.9.27", "@scalar/snippetz": "0.9.20", "@scalar/themes": "0.16.2", "@scalar/types": "0.16.1", "@scalar/use-hooks": "0.4.7", "@scalar/use-toasts": "0.10.2", "@scalar/validation": "0.6.0", "@scalar/workspace-store": "0.55.2", "@unhead/vue": "^2.1.4", "@vueuse/core": "13.9.0", "fuse.js": "^7.1.0", "microdiff": "^1.5.0", "nanoid": "^5.1.6", "vue": "^3.5.30", "yaml": "^2.8.3" } }, "sha512-0Q4NGSVK34tR/YMeQGxDD+v2NfH87/vDPxaBXxSyxfjg3lx/pmidt+YW2vcpxpoQdaPOVVwm8iuzcg0cxFjkCg=="],
"@scalar/api-reference-react": ["@scalar/api-reference-react@0.9.52", "", { "dependencies": { "@scalar/api-reference": "1.62.3", "@scalar/types": "0.16.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-+BkephxdTM7VFH9U/joHjOHvKadfOB9sxTCrYnWoURevz6LkrvnAGKvoALbAZBS4jbvbHabfpu6uL3sqDnV1EQ=="],
"@scalar/asyncapi-upgrader": ["@scalar/asyncapi-upgrader@0.1.2", "", { "dependencies": { "@scalar/helpers": "0.9.0" } }, "sha512-h6NUhsctrhucrbO2XHWbTXp9EWt7eL5vvlsooUEw2bB014KSPd41JrBQ6t0h/dFdmhwxdbBI7Hmg9eZa/mlCXA=="],
"@scalar/blocks": ["@scalar/blocks@0.1.2", "", { "dependencies": { "@scalar/components": "0.27.4", "@scalar/helpers": "0.9.0", "@scalar/icons": "0.7.3", "@scalar/snippetz": "0.9.20", "@scalar/themes": "0.16.2", "@scalar/types": "0.16.1", "@scalar/workspace-store": "0.55.2", "@types/har-format": "^1.2.16", "js-base64": "^3.7.8", "vue": "^3.5.30" } }, "sha512-vJQFlEckV4NTn5x95Q1Ff5HeeHwAqaX/8MLOX3O63F/XFXotIAp+MS8BQG2jnlVxzQn5omRSCNug2Y5J5ETZEw=="],
"@scalar/code-highlight": ["@scalar/code-highlight@0.4.0", "", { "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-ajUQ9oq5MwVHrXGze0SZVatgSXP/WbN4qgE0usYDFt1XuJ+O56TaBsCWb043r3Y9ZnHSnK9GHreVaLOxXsbf1A=="],
"@scalar/components": ["@scalar/components@0.27.4", "", { "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.0", "@scalar/helpers": "0.9.0", "@scalar/icons": "0.7.3", "@scalar/themes": "0.16.2", "@scalar/use-hooks": "0.4.7", "@vueuse/core": "13.9.0", "cva": "1.0.0-beta.4", "radix-vue": "^1.9.17", "vue": "^3.5.30", "vue-component-type-helpers": "^3.2.6" } }, "sha512-1sbsMYGJcWiQ+5GouI1hHCn1VrytHPhzhHhCAZTXxi1Tw4SzsPSQuACARehXca+w2p7RK9ayGRYQ+K5bX18SaQ=="],
"@scalar/helpers": ["@scalar/helpers@0.9.0", "", {}, "sha512-M34CLRCttqC1bXthI/QSzQj0s5C6nrU2PFWf/vOT3RpycbiGDGQbqR+5RfFzpOIQvRqbHfNdcRbeiZBw+vCbkQ=="],
"@scalar/icons": ["@scalar/icons@0.7.3", "", { "dependencies": { "@phosphor-icons/core": "^2.1.1", "@types/node": "^24.1.0", "chalk": "^5.6.2", "vue": "^3.5.30" } }, "sha512-5uSUvumj6yJEAZT7/MKpgrkNl76waDXUpu0kUBBFJ83GhZirBIK+Z9SktShEvJT0+rk/j9Zaer3BHoEhYwAEBQ=="],
"@scalar/json-magic": ["@scalar/json-magic@0.12.17", "", { "dependencies": { "@scalar/helpers": "0.9.0", "pathe": "^2.0.3", "yaml": "^2.8.3" } }, "sha512-Vw2nrUDIjhvMP6vxFtkiiFlabJ6SyTtfn1BsOxgnr1hIB+/rkngMguiDzl5em21VjyfFGIoADia+QWKM2hdcdA=="],
"@scalar/oas-utils": ["@scalar/oas-utils@0.19.3", "", { "dependencies": { "@scalar/helpers": "0.9.0", "@scalar/themes": "0.16.2", "@scalar/types": "0.16.1", "@scalar/workspace-store": "0.55.2", "flatted": "^3.4.0", "vue": "^3.5.30", "yaml": "^2.8.3" } }, "sha512-+h/vLMfGj/mpr5FYgDLFIc+X75sYH2rc2MJNSvFTmmjMeerJDVINEYs+aYOx1Mn9Z3HnoP+qQohoWTJpg2gh7A=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.9.1", "", {}, "sha512-gkGhSkxSzADaBiNg+ZAbJuwj+ZUmzP2Pg9CWZ7ZP+0fck2WjPeDDM7aAbouAm0aQQMF9xBjSPXSA9a/qTHYaTw=="],
"@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.2.9", "", { "dependencies": { "@scalar/openapi-types": "0.9.1" } }, "sha512-D5b0rGLLZgmkO9mdW2j/ND1KBlH1u3RCpr87HPxv9P9ZSr6PtM5iLqFOJq0ACiaHjY2mikCrxgDmnUEhTzRpHQ=="],
"@scalar/schemas": ["@scalar/schemas@0.7.1", "", { "dependencies": { "@scalar/helpers": "0.9.0", "@scalar/validation": "0.6.0" } }, "sha512-80bxEp4ZOWxOm8kqhPi3kdJ2gipz2lZBSEHStAh3Z6NZPkFAOlZZYnDFwodhY0LwTihgvv4FVNmi64gziM0F1g=="],
"@scalar/sidebar": ["@scalar/sidebar@0.9.27", "", { "dependencies": { "@scalar/components": "0.27.4", "@scalar/helpers": "0.9.0", "@scalar/icons": "0.7.3", "@scalar/themes": "0.16.2", "@scalar/use-hooks": "0.4.7", "@scalar/workspace-store": "0.55.2", "vue": "^3.5.30" } }, "sha512-Y2HBOuUuRSJtB4sf9T5sqZZrsT8QNYP8kWuK8lqzsujf3bv7c4ixUFRNS568EMMjr0U7Zv9xa+LP0RHem3rt4g=="],
"@scalar/snippetz": ["@scalar/snippetz@0.9.20", "", { "dependencies": { "@scalar/helpers": "0.9.0", "@scalar/types": "0.16.1", "js-base64": "^3.7.8", "stringify-object": "^6.0.0" } }, "sha512-A3gSBYtoTmW3m511d02vUyl/W/eabX5y/EPAwz/u88LTzdwuDrKWbZ8iY+5dkz5e5EErhNOwQDwngmOCAA91hA=="],
"@scalar/themes": ["@scalar/themes@0.16.2", "", { "dependencies": { "nanoid": "^5.1.6" } }, "sha512-4mAn7z2W5/ASi1OF06dqf04xjxTHnMzESC2E+qVMukJsEsV4kDlYSIfm/g5hwWxhqWsBMjorF9Dtlqd0ycJ92g=="],
"@scalar/typebox": ["@scalar/typebox@0.1.3", "", {}, "sha512-lU055AUccECZMIfGA0z/C1StYmboAYIPJLDFBzOO81yXBi35Pxdq+I4fWX6iUZ8qcoHneiLGk9jAUM1rA93iEg=="],
"@scalar/types": ["@scalar/types@0.16.1", "", { "dependencies": { "@scalar/helpers": "0.9.0", "nanoid": "^5.1.6", "type-fest": "^5.3.1", "zod": "^4.3.5" } }, "sha512-zzApf0dtEqztdY//3gmRJTgySGMpKnAVqcZltAzt95yuQPXbkSqxXDTitBLd1abeSeik+W6GFTIjffL8K+1wqQ=="],
"@scalar/use-codemirror": ["@scalar/use-codemirror@0.14.12", "", { "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.30" } }, "sha512-m+0tmVOHZVAybbcNEizR3m9RYOLtU59AStRkk29J5qaW4J+tsjrz4BDMceeTpoVawL/lxp2vDvQR+w/gKC3miQ=="],
"@scalar/use-hooks": ["@scalar/use-hooks@0.4.7", "", { "dependencies": { "@scalar/use-toasts": "0.10.2", "@scalar/validation": "0.6.0", "@vueuse/core": "13.9.0", "cva": "1.0.0-beta.4", "tailwind-merge": "3.5.0", "vue": "^3.5.30" } }, "sha512-8zajxhnKMJuO1HF36y8TeVuSIol2pueYdRvITZTEY8TuRbnwxrvJZk25II7YpxGMORAEDr0bwNF88Dr+QUfw1w=="],
"@scalar/use-toasts": ["@scalar/use-toasts@0.10.2", "", { "dependencies": { "vue": "^3.5.30", "vue-sonner": "^1.3.2" } }, "sha512-1iHQFbDXv0YQRp13aa63S5EcTJ5K8T0ocnLxk+nziloPrLjKt6jdRt6vOHsLSv5sm9kFKcVKNQTQgialmKCOGA=="],
"@scalar/validation": ["@scalar/validation@0.6.0", "", {}, "sha512-tpmmG+/xRE2Kn9RpflU3AIyZv08v10+E1ZrJCx7z6+/91zHVxy0M73kC1LT4/8PbYNt85ywyC8+n+D99JdMcGA=="],
"@scalar/workspace-store": ["@scalar/workspace-store@0.55.2", "", { "dependencies": { "@scalar/asyncapi-upgrader": "0.1.2", "@scalar/helpers": "0.9.0", "@scalar/json-magic": "0.12.17", "@scalar/openapi-upgrader": "0.2.9", "@scalar/schemas": "0.7.1", "@scalar/snippetz": "0.9.20", "@scalar/typebox": "0.1.3", "@scalar/types": "0.16.1", "@scalar/validation": "0.6.0", "js-base64": "^3.7.8", "type-fest": "^5.3.1", "vue": "^3.5.30", "yaml": "^2.8.3" } }, "sha512-/8BfJkave9vmweLdzH7w2TCaUFNcLu1vmE87id0QIi08enOwOExmPsz8YpUtaKLHM2bi/R2Tj/s9Uwp9i8Lttw=="],
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.1" } }, "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.1", "@tailwindcss/oxide-darwin-arm64": "4.3.1", "@tailwindcss/oxide-darwin-x64": "4.3.1", "@tailwindcss/oxide-freebsd-x64": "4.3.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", "@tailwindcss/oxide-linux-x64-musl": "4.3.1", "@tailwindcss/oxide-wasm32-wasi": "4.3.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA=="],
@@ -526,6 +647,8 @@
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.1", "", {}, "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA=="],
"@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.31", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-wZMEoSf852jQqaf3Ika1J7PiBae6341LNy/2CxmIyn0XKDQXMuK41wVX+xp6G0yx8jyR95Ef+Tdr13DK7mbJtQ=="],
"@tauri-apps/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="],
"@tauri-apps/cli": ["@tauri-apps/cli@2.11.2", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.2", "@tauri-apps/cli-darwin-x64": "2.11.2", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", "@tauri-apps/cli-linux-arm64-musl": "2.11.2", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-musl": "2.11.2", "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", "@tauri-apps/cli-win32-x64-msvc": "2.11.2" }, "bin": { "tauri": "tauri.js" } }, "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw=="],
@@ -586,18 +709,40 @@
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/har-format": ["@types/har-format@1.2.16", "", {}, "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.2", "", {}, "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA=="],
"@unhead/vue": ["@unhead/vue@2.1.15", "", { "dependencies": { "hookable": "^6.0.1", "unhead": "2.1.15" }, "peerDependencies": { "vue": ">=3.5.18" } }, "sha512-SSByXfEjhzPn8gXdEdgpYqpLMPSkLUH2HVE0GxZfOtNsJ0GgOHQs0g9T67ZZ1z0kTELLKdtOtYrzrbv9+ffF7g=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="],
"@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=="],
@@ -614,12 +759,40 @@
"@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=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.39", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.39", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw=="],
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.39", "", { "dependencies": { "@vue/compiler-core": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg=="],
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.39", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/compiler-core": "3.5.39", "@vue/compiler-dom": "3.5.39", "@vue/compiler-ssr": "3.5.39", "@vue/shared": "3.5.39", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.15", "source-map-js": "^1.2.1" } }, "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg=="],
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.39", "", { "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw=="],
"@vue/reactivity": ["@vue/reactivity@3.5.39", "", { "dependencies": { "@vue/shared": "3.5.39" } }, "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog=="],
"@vue/runtime-core": ["@vue/runtime-core@3.5.39", "", { "dependencies": { "@vue/reactivity": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw=="],
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.39", "", { "dependencies": { "@vue/reactivity": "3.5.39", "@vue/runtime-core": "3.5.39", "@vue/shared": "3.5.39", "csstype": "^3.2.3" } }, "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww=="],
"@vue/server-renderer": ["@vue/server-renderer@3.5.39", "", { "dependencies": { "@vue/compiler-ssr": "3.5.39", "@vue/shared": "3.5.39" }, "peerDependencies": { "vue": "3.5.39" } }, "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw=="],
"@vue/shared": ["@vue/shared@3.5.39", "", {}, "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="],
"@vueuse/core": ["@vueuse/core@13.9.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "13.9.0", "@vueuse/shared": "13.9.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA=="],
"@vueuse/integrations": ["@vueuse/integrations@13.9.0", "", { "dependencies": { "@vueuse/core": "13.9.0", "@vueuse/shared": "13.9.0" }, "peerDependencies": { "async-validator": "^4", "axios": "^1", "change-case": "^5", "drauu": "^0.4", "focus-trap": "^7", "fuse.js": "^7", "idb-keyval": "^6", "jwt-decode": "^4", "nprogress": "^0.2", "qrcode": "^1.5", "sortablejs": "^1", "universal-cookie": "^7 || ^8", "vue": "^3.5.0" }, "optionalPeers": ["async-validator", "axios", "change-case", "drauu", "focus-trap", "fuse.js", "idb-keyval", "jwt-decode", "nprogress", "qrcode", "sortablejs", "universal-cookie"] }, "sha512-SDobKBbPIOe0cVL7QxMzGkuUGHvWTdihi9zOrrWaWUgFKe15cwEcwfWmgrcNzjT6kHnNmWuTajPHoIzUjYNYYQ=="],
"@vueuse/metadata": ["@vueuse/metadata@13.9.0", "", {}, "sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg=="],
"@vueuse/shared": ["@vueuse/shared@13.9.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"ai": ["ai@6.0.33", "", { "dependencies": { "@ai-sdk/gateway": "3.0.13", "@ai-sdk/provider": "3.0.2", "@ai-sdk/provider-utils": "4.0.5", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bVokbmy2E2QF6Efl+5hOJx5MRWoacZ/CZY/y1E+VcewknvGlgaiCzMu8Xgddz6ArFJjiMFNUPHKxAhIePE4rmg=="],
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@@ -636,6 +809,8 @@
"axios": ["axios@1.16.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA=="],
@@ -654,10 +829,18 @@
"caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"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=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"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=="],
@@ -670,12 +853,18 @@
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "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-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="],
"convert-hrtime": ["convert-hrtime@5.0.0", "", {}, "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"country-flag-icons": ["country-flag-icons@1.6.17", "", {}, "sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw=="],
"crelt": ["crelt@1.0.7", "", {}, "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
@@ -684,6 +873,8 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"cva": ["cva@1.0.0-beta.4", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "typescript": ">= 4.5.5" }, "optionalPeers": ["typescript"] }, "sha512-F/JS9hScapq4DBVQXcK85l9U91M6ePeXoBMSp7vypzShoefUBxjQTo3g3935PUHgQd+IW77DjbPRIxugy4/GCQ=="],
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -692,6 +883,8 @@
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
@@ -706,6 +899,8 @@
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
@@ -754,10 +949,14 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
@@ -778,6 +977,8 @@
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"focus-trap": ["focus-trap@7.8.0", "", { "dependencies": { "tabbable": "^6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="],
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
@@ -788,6 +989,10 @@
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"function-timeout": ["function-timeout@1.0.2", "", {}, "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA=="],
"fuse.js": ["fuse.js@7.4.2", "", {}, "sha512-LVbzjD4WA6UP5B1UnP8wuaXJiLnqMdM/E4fiJXTJ5haJ5b/MBNsK29h2fm6swEoQaVQjvYFWKLE2RanyZIoRVQ=="],
"fzf": ["fzf@0.5.2", "", {}, "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
@@ -798,6 +1003,8 @@
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
@@ -816,6 +1023,8 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"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=="],
@@ -824,14 +1033,56 @@
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"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=="],
"hast-util-format": ["hast-util-format@1.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-embedded": "^3.0.0", "hast-util-minify-whitespace": "^1.0.0", "hast-util-phrasing": "^3.0.0", "hast-util-whitespace": "^3.0.0", "html-whitespace-sensitive-tag-names": "^3.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA=="],
"hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="],
"hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
"hast-util-has-property": ["hast-util-has-property@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA=="],
"hast-util-is-body-ok-link": ["hast-util-is-body-ok-link@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ=="],
"hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="],
"hast-util-minify-whitespace": ["hast-util-minify-whitespace@1.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-embedded": "^3.0.0", "hast-util-is-element": "^3.0.0", "hast-util-whitespace": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw=="],
"hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
"hast-util-phrasing": ["hast-util-phrasing@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-embedded": "^3.0.0", "hast-util-has-property": "^3.0.0", "hast-util-is-body-ok-link": "^3.0.0", "hast-util-is-element": "^3.0.0" } }, "sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ=="],
"hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
"hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="],
"hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
"hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="],
"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@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"html-whitespace-sensitive-tag-names": ["html-whitespace-sensitive-tag-names@3.0.1", "", {}, "sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA=="],
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
@@ -840,22 +1091,32 @@
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="],
"identifier-regex": ["identifier-regex@1.0.1", "", { "dependencies": { "reserved-identifiers": "^1.0.0" } }, "sha512-ZrYyM0sozNPZlvBvE7Oq9Bn44n0qKGrYu5sQ0JzMUnjIhpgWYE2JB6aBoFwEYdPjqj7jPyxXTMJiHDOxDfd8yw=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"is-absolute-url": ["is-absolute-url@4.0.1", "", {}, "sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-identifier": ["is-identifier@1.0.1", "", { "dependencies": { "identifier-regex": "^1.0.0", "super-regex": "^1.0.0" } }, "sha512-HQ5v4rEJ7REUV54bCd2l5FaD299SGDEn2UPoVXaTHAyGviLq2menVUD2udi3trQ32uvB6LdAh/0ck2EuizrtpA=="],
"is-obj": ["is-obj@3.0.0", "", {}, "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="],
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
@@ -866,6 +1127,8 @@
"joi": ["joi@18.2.1", "", { "dependencies": { "@hapi/address": "^5.1.1", "@hapi/formula": "^3.0.2", "@hapi/hoek": "^11.0.7", "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", "@standard-schema/spec": "^1.1.0" } }, "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ=="],
"js-base64": ["js-base64@3.8.0", "", {}, "sha512-65kvbemyZhj+ExQt1PEFyBEjL5vAHysu1lJdW1AwhhChkO8ZBPizYk/m9GVrpbS2Je1hF+UYZ+6KywqtZV8mHw=="],
"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=="],
@@ -874,12 +1137,16 @@
"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=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"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=="],
"kill-port-process": ["kill-port-process@4.0.2", "", { "dependencies": { "get-them-args": "1.3.2", "pid-port": "2.0.1" }, "bin": { "kill-port": "dist/bin/kill-port-process.js" } }, "sha512-fO8gc45EYJQUQWozPBmdTpsR0GDvldsmrhP2I4FPoNejwyBY4Liiwj9Is7P/5rj6k07ZQ5Ob0g0k2dqQcslW/w=="],
@@ -916,6 +1183,10 @@
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="],
"lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="],
"lucide-react": ["lucide-react@1.18.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA=="],
@@ -924,10 +1195,96 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"make-asynchronous": ["make-asynchronous@1.1.0", "", { "dependencies": { "p-event": "^6.0.0", "type-fest": "^4.6.0", "web-worker": "^1.5.0" } }, "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"microdiff": ["microdiff@1.5.0", "", {}, "sha512-Drq+/THMvDdzRYrK0oxJmOKiC24ayUV8ahrt8l3oRK51PWt6gdtrIGrlIH3pT/lFh1z93FbAcidtsHcWbnRz8Q=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
@@ -946,6 +1303,8 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"neverpanic": ["neverpanic@0.0.8", "", {}, "sha512-vVdkelrLxaow/fdWDumzNBO+jwm6X8bxeLJc34THtpj70u0C5QBkcV6CRCu2X726km7XD45N0A3QtYCla4RvKw=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
@@ -970,10 +1329,14 @@
"oxlint": ["oxlint@1.71.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.71.0", "@oxlint/binding-android-arm64": "1.71.0", "@oxlint/binding-darwin-arm64": "1.71.0", "@oxlint/binding-darwin-x64": "1.71.0", "@oxlint/binding-freebsd-x64": "1.71.0", "@oxlint/binding-linux-arm-gnueabihf": "1.71.0", "@oxlint/binding-linux-arm-musleabihf": "1.71.0", "@oxlint/binding-linux-arm64-gnu": "1.71.0", "@oxlint/binding-linux-arm64-musl": "1.71.0", "@oxlint/binding-linux-ppc64-gnu": "1.71.0", "@oxlint/binding-linux-riscv64-gnu": "1.71.0", "@oxlint/binding-linux-riscv64-musl": "1.71.0", "@oxlint/binding-linux-s390x-gnu": "1.71.0", "@oxlint/binding-linux-x64-gnu": "1.71.0", "@oxlint/binding-linux-x64-musl": "1.71.0", "@oxlint/binding-openharmony-arm64": "1.71.0", "@oxlint/binding-win32-arm64-msvc": "1.71.0", "@oxlint/binding-win32-ia32-msvc": "1.71.0", "@oxlint/binding-win32-x64-msvc": "1.71.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-U1m1X+C0vDj7DC1e13IoZULzEcPczE7UOMTs8VlZGHUEIUaSTZKo5qkPsQEfzpgnQ29Pea/w3Xntk62UCecxZw=="],
"p-event": ["p-event@6.0.1", "", { "dependencies": { "p-timeout": "^6.1.2" } }, "sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="],
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
@@ -1010,6 +1373,8 @@
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
"property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
@@ -1018,6 +1383,8 @@
"quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
"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.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
@@ -1038,12 +1405,34 @@
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
"rehype-external-links": ["rehype-external-links@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-is-element": "^3.0.0", "is-absolute-url": "^4.0.0", "space-separated-tokens": "^2.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw=="],
"rehype-format": ["rehype-format@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-format": "^1.0.0" } }, "sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ=="],
"rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="],
"rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="],
"rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="],
"rehype-stringify": ["rehype-stringify@10.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-html": "^9.0.0", "unified": "^11.0.0" } }, "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
"reserved-identifiers": ["reserved-identifiers@1.2.0", "", {}, "sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
@@ -1060,6 +1449,8 @@
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
@@ -1074,12 +1465,22 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
"string-byte-length": ["string-byte-length@3.0.1", "", {}, "sha512-yJ8vP0HMwZ54CcA8S8mKoXbkezpZHANFtmafFo8lGxZThCQcAwRHjdFabuSLgOzxj9OFJcmssmiAvmcOK4O2Hw=="],
"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=="],
"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-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
@@ -1088,10 +1489,20 @@
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
"style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
"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=="],
"swrv": ["swrv@1.2.0", "", { "peerDependencies": { "vue": ">=3.2.26 < 4" } }, "sha512-lH/g4UcNyj+7lzK4eRGT4C68Q4EhQ6JtM9otPRIASfhhzfLWtbZPHcMuhuba7S9YVYuxkMUGImwMyGpfbkH07A=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
"tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="],
@@ -1100,6 +1511,8 @@
"taze": ["taze@19.14.1", "", { "dependencies": { "@antfu/ni": "^30.1.0", "@henrygd/queue": "^1.2.0", "cac": "^7.0.0", "ofetch": "^1.5.1", "package-manager-detector": "^1.6.0", "pathe": "^2.0.3", "pnpm-workspace-yaml": "^1.6.1", "restore-cursor": "^5.1.0", "tinyexec": "^1.2.2", "tinyglobby": "^0.2.16", "unconfig": "^7.5.0", "yaml": "^2.9.0" }, "bin": { "taze": "bin/taze.mjs" } }, "sha512-+wf/IqGReU68vBE/iJ7JCuV5QeD6zQBp9MI6YphN7bT2vf/YIHd0oVA4AJiX3uANI1hQY58MrVmDwLv0x/q3BA=="],
"time-span": ["time-span@5.1.0", "", { "dependencies": { "convert-hrtime": "^5.0.0" } }, "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
@@ -1120,6 +1533,12 @@
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"truncate-json": ["truncate-json@3.0.1", "", { "dependencies": { "guess-json-indent": "^3.0.1", "string-byte-length": "^3.0.1", "string-byte-slice": "^3.0.1" } }, "sha512-QVsbr1WhGLq2F0oDyYbqtOXcf3gcnL8C9H5EX8bBwAr8ZWvWGJzukpPrDrWgJMrNtgDbo74BIjI4kJu3q2xQWw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"turbo": ["turbo@2.9.18", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.18", "@turbo/darwin-arm64": "2.9.18", "@turbo/linux-64": "2.9.18", "@turbo/linux-arm64": "2.9.18", "@turbo/windows-64": "2.9.18", "@turbo/windows-arm64": "2.9.18" }, "bin": { "turbo": "bin/turbo" } }, "sha512-bwabv6PupzeavybzEoArBAkwq5fnzwf8OFnRtpHwnviFWuwJPFxtyH+aVp36TmIqK3aYYgtTJ3J0m2ysxxSzQg=="],
@@ -1128,6 +1547,8 @@
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
@@ -1140,8 +1561,26 @@
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unhead": ["unhead@2.1.15", "", { "dependencies": { "hookable": "^6.0.1" } }, "sha512-MCt5T90mCWyr3Z6pUCdM9lVRXoMoVBlL7z7U4CYVIiaDiuzad/UCfLuMqz5MeNmpZUgoBCQnrucJimU7EZR+XA=="],
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
@@ -1152,12 +1591,28 @@
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "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-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="],
"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=="],
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
"vue": ["vue@3.5.39", "", { "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/compiler-sfc": "3.5.39", "@vue/runtime-dom": "3.5.39", "@vue/server-renderer": "3.5.39", "@vue/shared": "3.5.39" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA=="],
"vue-component-type-helpers": ["vue-component-type-helpers@3.3.6", "", {}, "sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ=="],
"vue-demi": ["vue-demi@0.14.10", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
"vue-sonner": ["vue-sonner@1.3.2", "", {}, "sha512-UbZ48E9VIya3ToiRHAZUbodKute/z/M1iT8/3fU8zEbwBRE11AKuHikssv18LMk2gTTr6eMQT4qf6JoLHWuj/A=="],
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"wait-on": ["wait-on@9.0.10", "", { "dependencies": { "axios": "^1.16.0", "joi": "^18.2.1", "lodash": "^4.18.1", "minimist": "^1.2.8", "rxjs": "^7.8.2" }, "bin": { "wait-on": "bin/wait-on" } }, "sha512-rCoJEhvMr0X6alHmwc9abbrA5ZrLZFKpFQVKPNFwl2h7DapXOGdmimIHDtLOWhT4PjhZhxFEtZoQgEXbkDWdZw=="],
@@ -1166,6 +1621,10 @@
"wavesurfer.js": ["wavesurfer.js@7.12.8", "", {}, "sha512-G3nxzcC4X+ZWrLtcIV17kCWHVq3ysJCS4dS0YkGKILrQ2esAb8cScw965zKNKYxUvpiZsPK93KLWgWTYdIBQiw=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"web-worker": ["web-worker@1.5.0", "", {}, "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw=="],
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
@@ -1206,10 +1665,18 @@
"zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@floating-ui/core/@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@floating-ui/dom/@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@floating-ui/vue/@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="],
"@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="],
@@ -1222,6 +1689,18 @@
"@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@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=="],
"@scalar/use-hooks/tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
@@ -1234,6 +1713,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.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="],
"@tauri-apps/plugin-process/@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
"@tauri-apps/plugin-updater/@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
@@ -1244,8 +1725,26 @@
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"@vue/compiler-core/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
"@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@vue/compiler-sfc/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
"@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=="],
"hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"make-asynchronous/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"playwright/playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="],
@@ -1254,6 +1753,12 @@
"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=="],
"radix-vue/@vueuse/core": ["@vueuse/core@10.11.1", "", { "dependencies": { "@types/web-bluetooth": "^0.0.20", "@vueuse/metadata": "10.11.1", "@vueuse/shared": "10.11.1", "vue-demi": ">=0.14.8" } }, "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww=="],
"radix-vue/@vueuse/shared": ["@vueuse/shared@10.11.1", "", { "dependencies": { "vue-demi": ">=0.14.8" } }, "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA=="],
"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.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="],
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@@ -1268,6 +1773,14 @@
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@vue/compiler-core/@babel/parser/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@vue/compiler-sfc/@babel/parser/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"hast-util-from-html/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"hast-util-raw/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"qrcode/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
@@ -1276,6 +1789,18 @@
"qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
"radix-vue/@vueuse/core/@types/web-bluetooth": ["@types/web-bluetooth@0.0.20", "", {}, "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow=="],
"radix-vue/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@10.11.1", "", {}, "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw=="],
"@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@vue/compiler-sfc/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@vue/compiler-sfc/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"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=="],
+6 -2
View File
@@ -82,8 +82,12 @@ If `huggingface.co` is slow or blocked, point the client at a mirror:
HF_ENDPOINT=https://hf-mirror.com
```
Set it as an environment variable (or in **Settings → environment**) before
downloading. Caveats:
Set it in **Settings → Models → Hugging Face mirror** (quick-pick presets
included), or as an environment variable before launching. On first run, the
setup wizard offers the same mirror quick-pick right on the system-check
screen when the endpoint is unreachable — the network check is a warning, not
a blocker, so an offline or firewalled machine can still finish setup once
models are available (mirror, or manual download below). Caveats:
- A mirror serves the **classic** download path, **not Xet** — you lose
chunk-dedup and Xet's parallel fetch, but you gain reachability. On the
+32
View File
@@ -102,6 +102,38 @@ provider via environment variables (e.g. `GROQ_API_KEY`, or the legacy
`TRANSLATE_BASE_URL` / `TRANSLATE_API_KEY` / `TRANSLATE_MODEL`, which map to the
**Custom** provider).
### Pinning the active provider with `LLM_DEFAULT_PROVIDER`
By default the LLM used for Cinematic/Autofit is the one you mark "use for
translation" in **Settings → LLM Providers**. To force a specific provider
regardless of that stored selection — handy for headless/CI/Docker runs or a
shared machine — set the `LLM_DEFAULT_PROVIDER` environment variable to a
provider id before launching the backend:
```
LLM_DEFAULT_PROVIDER=groq # or openai, openrouter, cerebras, ollama, custom, …
```
Resolution order for the active provider is: `LLM_DEFAULT_PROVIDER` (env) →
your saved selection → the first provider that has a key → none. The id must be
one OmniVoice knows (the ids shown in **Settings → LLM Providers**); an unknown
value is ignored and resolution falls through to your saved selection. While
this env var is set it wins over the in-app picker, so if the UI selection
appears to have "no effect," check whether `LLM_DEFAULT_PROVIDER` is exported.
## LLM Skills (per-feature routing)
**Settings → System → LLM Skills** lists every LLM-powered feature — Cinematic &
Autofit translation, speech-rate slot fitting, glossary auto-extract, direction
parsing, and dictation cleanup — and lets you toggle each one or route it to a
specific provider instead of the global active one. That way sensitive work
(e.g. dictation cleanup) can stay on a local Ollama/LM Studio model while
heavier jobs use a remote provider. A disabled skill degrades exactly like
having no LLM configured: Cinematic/Autofit falls back to Fast, dictation
cleanup passes the raw transcript through, direction parsing uses the keyword
heuristic. Everything defaults to enabled + "use active provider", so existing
setups behave unchanged.
## API keys (online MT engines)
The non-LLM online engines need a key, set as an environment variable before
+43
View File
@@ -0,0 +1,43 @@
# OmniVoice Studio — OpenAI-Compatible Remote ASR
A path to Qwen3-ASR, a self-hosted FunASR/SenseVoice server, or OpenAI's own
Whisper API — today, without waiting on `transformers` to ship a direct
Qwen3-ASR integration (tracked separately). Unlike every other ASR engine,
this one runs no model locally: it's a pure network client that calls any
server exposing an OpenAI-compatible `POST /v1/audio/transcriptions`
endpoint.
## Setup
No install step — configure it directly:
1. Open **Settings → Models** and find **OpenAI-compatible ASR (remote
server)**.
2. Set **Server URL** to your server's base URL (e.g.
`http://localhost:8000/v1` for a local Qwen3-ASR/FunASR server, or
`https://api.openai.com/v1` for OpenAI's own API).
3. Set **Model** to whatever your server expects (`whisper-1` for OpenAI's
API; check your self-hosted server's docs otherwise).
4. **API key** is optional — many self-hosted servers accept requests
without one. Set it if your server requires auth, or if you're using
OpenAI's own API.
5. Activate the engine in **Settings → Engines** — click **Use** on
**OpenAI-compatible ASR** in the ASR Engines table (the same picker TTS
engines have). Power users can pin it instead by setting
`OMNIVOICE_ASR_BACKEND=openai-compat-asr` before launching — the env var
always wins over the Settings pick.
## Response format
The backend prefers `response_format=verbose_json` for real per-segment
timestamps (OpenAI's API and most compatible servers support it) and falls
back to plain text automatically if your server rejects that format. Neither
path returns word-level timestamps — that's not part of this API.
## Privacy note
Unlike every other ASR engine in OmniVoice, audio sent through this backend
leaves your machine — to whatever server you configured. If that's a
self-hosted server on your own network, nothing leaves your control; if
it's a third-party API (OpenAI's, or someone else's), review their data
handling before sending anything sensitive.
+2
View File
@@ -74,6 +74,8 @@ asr_engines:
readme: FunASR
- id: sherpa-onnx-asr
readme: "**sherpa-onnx** (live dictation)"
- id: openai-compat-asr
readme: "**OpenAI-compatible** ⚠️ remote"
# Doc files that must exist (the install path users are sent to).
docs:
+2
View File
@@ -56,6 +56,8 @@ Priority: `duration` > `speed`.
| `preprocess_prompt` | bool | True | Whether to apply preprocessing to the voice-clone prompt audio (remove long silences in reference audio, add punctuation in the end of reference text). |
| `postprocess_output` | bool | True | Apply post-processing to generated audio (remove long silences). |
> **Tip — reference-clip quality transfers.** Zero-shot cloning mirrors the acoustics of the reference clip, not just the voice: a clip recorded in an echoey room clones echoey. Record dry and close-mic for clean output. No effect preset adds reverb unless you choose one that declares it (Cinematic, Warm).
## Long-Form Generation
To support stable long-form speech generation with low VRAM consumption, the text is automatically split into smaller segments when the estimated duration of the generated speech exceeds `audio_chunk_duration`, with each segment producing approximately `audio_chunk_duration` seconds of audio. This approach allows the model to accept arbitrarily long text and generate arbitrarily long speech with near-constant VRAM consumption.
+109 -33
View File
@@ -5,13 +5,32 @@ working OmniVoice Studio install on a Debian / Ubuntu / Fedora / Arch host.
## Prerequisites
### Using the AppImage
- **Linux x86_64** with a desktop session (X11 or Wayland) capable of running
a Tauri / WebKitGTK app.
- **~10 GB free disk** for the app, its Python environment, and model weights.
- Optional: an **NVIDIA driver** for CUDA GPU acceleration — the app runs
CPU-only without one. For AMD GPUs see [AMD GPU (ROCm)](#amd-gpu-rocm).
- Optional: **yt-dlp** for downloading YouTube/video clips directly in the
Voice Gallery and Dub tabs — `sudo apt install yt-dlp` (Debian/Ubuntu),
`sudo dnf install yt-dlp` (Fedora), or `sudo pacman -S yt-dlp` (Arch).
Without it those downloads fail; everything else works fine.
That's it — Python, FFmpeg, and the model weights are bundled or bootstrapped
by the app itself on first launch. No toolchain needed.
### Building from source
Everything above, plus the toolchain:
- **git**`sudo apt install git` (Debian/Ubuntu), `sudo dnf install git` (Fedora), or `sudo pacman -S git` (Arch).
- **curl** — usually preinstalled; used by the Bun and rustup install one-liners below.
- **Python 3.11+** — typically `sudo apt install python3.11` on Debian/Ubuntu,
`sudo dnf install python3.11` on Fedora, or already installed on Arch.
- **Bun**`curl -fsSL https://bun.sh/install | bash`.
- **FFmpeg**`sudo apt install ffmpeg` (Debian/Ubuntu), `sudo dnf install ffmpeg-free` (Fedora), or `sudo pacman -S ffmpeg` (Arch).
- **Rust / Cargo** (required for building from source only)`curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` or via your package manager (e.g., `sudo apt install rustc cargo`).
- **Rust / Cargo**`curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` or via your package manager (e.g., `sudo apt install rustc cargo`).
If you use rustup, reopen the shell or source `"$HOME/.cargo/env"` before running `bun run desktop-prod`.
- **GTK/WebKit deps** for the Tauri shell:
@@ -58,12 +77,15 @@ No FUSE? Use `--appimage-extract-and-run`:
./OmniVoice.Studio_*.AppImage --appimage-extract-and-run
```
## Install (.deb)
## .deb package
```bash
sudo apt install ./OmniVoice.Studio_*.amd64.deb
omnivoice-studio
```
Not currently published: `.deb` bundling is disabled in the release pipeline
because of a `tauri-cli` bug (`Failed to create control scripts`) — see the
comment in `.github/workflows/release.yml` for the tracking note. The
AppImage above is the supported Linux install path until a `tauri-cli`
version resolves it. `apt install`-able `.deb`s shipped before v0.3 (see
[.deb ffprobe conflict](#deb-ffprobe-conflict) below) if you're upgrading
from one of those.
The desktop app uses these canonical paths (kept in sync with
`scripts/desktop-prod.sh` by the docs-drift CI gate):
@@ -74,24 +96,49 @@ APP_ID="com.debpalash.omnivoice-studio"
APP_NAME="OmniVoice Studio"
```
## AppImage white-screen on Fedora 44 / Ubuntu 24.04
## AppImage white screen / EGL errors (Fedora 44, Ubuntu 24.04+, 26.04)
<a id="appimage-white-screen-on-fedora-44--ubuntu-2404"></a>
Newer distros ship WebKitGTK 2.44 / 2.46, which has a compositing-mode
regression that lands the Tauri window as a fully-white frame with no UI.
Two separate WebKitGTK rendering issues land the Tauri window as a
fully-white frame with no UI. Which one you have depends on your WebKitGTK
version (`pkg-config --modversion webkit2gtk-4.1` prints it).
**Workaround:** set `WEBKIT_DISABLE_COMPOSITING_MODE=1` before launching:
**Modern WebKitGTK (2.48+ — Ubuntu 24.04 and newer, incl. 26.04): try this
first.** WebKit's DMA-BUF renderer fails against some GPU drivers; the
terminal typically shows:
```
Could not create default EGL display: EGL_BAD_PARAMETER
```
Disable the DMA-BUF renderer before launching:
```bash
WEBKIT_DISABLE_DMABUF_RENDERER=1 ./OmniVoice.Studio_*.AppImage
```
**WebKitGTK 2.44 / 2.46 (Fedora 44, Ubuntu 24.04 at release):** a
compositing-mode regression blanks the surface on first paint. Disable
compositing mode instead:
```bash
WEBKIT_DISABLE_COMPOSITING_MODE=1 ./OmniVoice.Studio_*.AppImage
```
OmniVoice's AppRun launcher autodetects the broken WebKitGTK range and sets
this for you (shipped in v0.3+). The manual env-var path remains the documented
fallback when running from a checked-out source tree.
OmniVoice's AppRun launcher autodetects the broken 2.44/2.46 range and sets
this second variable for you (shipped in v0.3+). The manual env-var path
remains the documented fallback when running from a checked-out source tree.
Tracking issue: [#62](https://github.com/debpalash/OmniVoice-Studio/issues/62).
**Last resort** — if neither variable alone helps, force software rendering
(slower, but always paints):
```bash
WEBKIT_DISABLE_DMABUF_RENDERER=1 LIBGL_ALWAYS_SOFTWARE=1 ./OmniVoice.Studio_*.AppImage
```
Tracking issues: [#62](https://github.com/debpalash/OmniVoice-Studio/issues/62),
[#961](https://github.com/debpalash/OmniVoice-Studio/issues/961).
## .deb ffprobe conflict
@@ -134,36 +181,67 @@ that picks these defaults automatically; for v0.3 set them by hand.
<a id="amd-gpu-rocm"></a>
OmniVoice **auto-detects AMD GPUs**`get_best_device()` returns the GPU when a
ROCm build of PyTorch is installed (ROCm-built PyTorch reports through
`torch.cuda.is_available()`), and OmniVoice auto-sets `HSA_OVERRIDE_GFX_VERSION`
for consumer cards whose GFX ID isn't in the official ROCm support matrix. No
code changes or flags are needed.
ROCm support is **Linux-only and opt-in**. The **default install ships the
CUDA build** of PyTorch (the `pytorch-cuda` index in `pyproject.toml`), so on
an AMD-only machine `torch.cuda.is_available()` is `False` and OmniVoice runs
on CPU until you opt into the ROCm variant. (On Windows there is no ROCm path
at all — PyTorch publishes no Windows ROCm wheels; see
[windows.md](windows.md#gpu-support).)
The catch: the **default install ships the CUDA build** of PyTorch (the
`pytorch-cuda` index in `pyproject.toml`), so on an AMD-only machine
`torch.cuda.is_available()` is `False` and OmniVoice falls back to CPU. To use
your AMD GPU, replace torch with the ROCm wheel **after** the first-run install
populates the venv:
Three ways to opt in, in order of preference:
**1. First-run setup screen (recommended).** On Linux the setup screen's
**Compute** card offers **"AMD GPU (ROCm, Linux)"** next to the default
**Auto**. When OmniVoice detects an AMD GPU *and* the ROCm userspace
(`/opt/rocm` present, or `rocminfo` on PATH), the ROCm option is pre-selected;
with an AMD GPU but no ROCm runtime it stays offered-but-unselected — install
ROCm first (or continue on CPU). Choosing ROCm makes the bootstrap reinstall
`torch`/`torchaudio` from the ROCm wheel index
(`https://download.pytorch.org/whl/rocm6.4` by default) right after the
dependency sync — matched to the app's pinned `torch==2.8.0` (the rocm6.2
index only ever published up to torch 2.5.1, so it silently failed the
reinstall and left the CPU-only CUDA build in place).
**2. Environment variable (existing installs / headless).** Set
`OMNIVOICE_TORCH_VARIANT=rocm` before launching — the next bootstrap performs
the same ROCm reinstall. `OMNIVOICE_TORCH_INDEX=<url>` overrides the wheel
index when you need a different ROCm version — e.g. AMD publishes newer
driver-matched builds (7.2.x) at `repo.radeon.com` as a `--find-links` page
rather than a PyPI-style index:
```bash
uv pip install --reinstall torch==2.8.0 torchaudio==2.8.0 \
--find-links https://repo.radeon.com/rocm/manylinux/rocm-rel-7.2.4/
```
run that manually if you want a specific ROCm point release; the
`OMNIVOICE_TORCH_INDEX` env var only accepts a PEP 503 index URL, not a
find-links page. If the reinstall fails (network, unsupported card), OmniVoice
keeps the default torch build and warns instead of breaking the install.
**3. Manual wheel swap (fallback).** Replace torch with the ROCm wheel
**after** the first-run install populates the venv:
```bash
# From the project directory (source install), into OmniVoice's uv venv.
# Current stable is ROCm 6.2 — match your installed ROCm/driver version
# (https://pytorch.org/get-started/locally/ lists available wheels).
# Matches the app's torch==2.8.0 pin — a different ROCm point release
# (e.g. rocm6.2, rocm7.x) may not carry that exact torch build.
uv pip install --reinstall torch torchaudio \
--index-url https://download.pytorch.org/whl/rocm6.2
--index-url https://download.pytorch.org/whl/rocm6.4
```
Then relaunch — the Settings → System panel should now report the GPU device
instead of `cpu`. Verify the wheel sees your card:
Once a ROCm build of PyTorch is in the venv, detection is automatic —
`get_best_device()` returns the GPU (ROCm-built PyTorch reports through
`torch.cuda.is_available()`), and OmniVoice auto-sets
`HSA_OVERRIDE_GFX_VERSION` for consumer cards whose GFX ID isn't in the
official ROCm support matrix. Relaunch and the Settings → System panel should
report the GPU device instead of `cpu`. Verify the wheel sees your card:
```bash
uv run python -c "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_name(0))"
```
Notes:
- ROCm is **Linux-only** and **opt-in** — the default cross-platform behavior
(CUDA on NVIDIA, MPS on Apple, CPU elsewhere) is unchanged.
- ROCm is exercised far less than the default CUDA/MPS/CPU paths — it works,
but expect rough edges on consumer cards and report what you hit.
- Unsupported GFX (e.g. some consumer RDNA cards): if it still won't run, set
`HSA_OVERRIDE_GFX_VERSION` yourself (e.g. `export HSA_OVERRIDE_GFX_VERSION=11.0.0`)
to the nearest supported architecture before launching.
@@ -171,8 +249,6 @@ Notes:
native ROCm wheel.
Tracking issue: [#124](https://github.com/debpalash/OmniVoice-Studio/issues/124).
An installer-integrated, env-var-driven ROCm wheel selection is a planned
follow-up; until then this manual step is the supported path.
## Hugging Face token (optional but recommended)
+40 -14
View File
@@ -1,21 +1,42 @@
# OmniVoice Studio — Install on macOS
This page is self-contained: follow it top to bottom and you'll end up with a
working OmniVoice Studio install on macOS (Apple Silicon or Intel).
working OmniVoice Studio install on macOS (Apple Silicon).
> **Intel Macs:** the pre-built `.app`/DMG currently ships **Apple Silicon
> only** — on Intel, install **from source** (works fully; ASR falls back to
> CTranslate2). A pre-built Intel bundle is tracked in
> [#279](https://github.com/debpalash/OmniVoice-Studio/issues/279).
> [!IMPORTANT]
> **Intel Macs are not supported.** The app UI installs and launches, but the
> local Python backend **cannot run**: PyTorch stopped shipping Intel-Mac
> (macOS x86_64) wheels after 2.2.x, and OmniVoice's dependencies require a
> newer torch — so the first-run dependency install can never succeed, from
> the DMG *or* from source
> ([#889](https://github.com/debpalash/OmniVoice-Studio/issues/889)). The app
> detects this at first launch and tells you directly instead of failing with
> a raw installer error. Your options on an Intel Mac: point the UI at a
> remote backend running on another machine (**Settings → Sharing → Remote
> backend**), or run OmniVoice on an Apple Silicon Mac, Windows, or Linux.
## Prerequisites
- **macOS 12 (Monterey) or newer** — Apple Silicon or Intel.
### Using the DMG
- **macOS 12 (Monterey) or newer** — Apple Silicon (Intel: UI only, see the
note above).
- **~10 GB free disk** for the app, its Python environment, and model weights.
That's it — GPU acceleration (Apple MPS) is automatic on Apple Silicon, and
Python, FFmpeg, and the model weights are bundled or bootstrapped by the app
itself on first launch. No toolchain needed.
### Building from source
Everything above, plus the toolchain:
- **Xcode Command Line Tools**`xcode-select --install` (includes **git**
and the C toolchain; `curl` ships with macOS).
- **Python 3.11+**`brew install python@3.11` (or use `pyenv` / the system Python if you already have ≥3.11).
- **Bun**`curl -fsSL https://bun.sh/install | bash`.
- **Xcode Command Line Tools**`xcode-select --install`.
- **FFmpeg** (used by the dubbing + capture pipelines) — `brew install ffmpeg`.
- **Rust / Cargo** (required for building from source only)`curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` or `brew install rust`.
- **Rust / Cargo**`curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` or `brew install rust`.
If you use rustup, reopen the terminal or source `"$HOME/.cargo/env"` before running `bun run desktop-prod`.
Optional but recommended:
@@ -47,13 +68,15 @@ Pick the DMG that matches your Mac (check **Apple menu → About This Mac → Ch
| Mac | DMG to download |
|-----|-----------------|
| Apple Silicon (M1/M2/M3/M4…) | `OmniVoice.Studio_<version>_aarch64.dmg` |
| Intel | `OmniVoice.Studio_<version>_x64.dmg` |
| Intel | `OmniVoice.Studio_<version>_x64.dmg`**UI only**: the local backend cannot run on Intel ([#889](https://github.com/debpalash/OmniVoice-Studio/issues/889)) |
The architectures are **not** interchangeable: an Intel Mac cannot run the
`aarch64` build (Rosetta 2 only translates the other direction — it lets Apple
Silicon run Intel apps, never the reverse). If a release predates the Intel
build target and has no `x64` DMG, use the
[install-from-source path](#install-from-source) above instead.
Silicon run Intel apps, never the reverse). And note the Intel caveat above:
the `x64` DMG installs and launches, but is only useful together with a
remote backend — the local Python backend cannot install on Intel because
PyTorch no longer ships Intel-Mac wheels. Installing from source does not
help; the dependency resolution fails the same way.
If the first launch is blocked by macOS Gatekeeper ("OmniVoice Studio cannot be
opened because the developer cannot be verified"), see the next section — it
@@ -123,8 +146,11 @@ without the quarantine step.
- **Apple Silicon (M-series):** OmniVoice automatically picks the `mlx-whisper`
and `mlx-audio` backends where available — these use the Apple Neural Engine
and Metal Performance Shaders for ~2× the throughput of the CPU path.
- **Intel macs:** falls back to `faster-whisper` (CTranslate2) on CPU. Still
fast; just no ANE acceleration.
- **Intel Macs:** the local backend is **unsupported** — PyTorch no longer
ships Intel-Mac wheels, so the Python environment can never install
([#889](https://github.com/debpalash/OmniVoice-Studio/issues/889)). The UI
works only when pointed at a remote backend (**Settings → Sharing → Remote
backend**).
The picker in **Settings → Engines** shows which backend is active.
+151 -24
View File
@@ -115,13 +115,22 @@ quarantines every download.
**Fix:** see [macos.md#gatekeeper-quarantine](macos.md#gatekeeper-quarantine).
## 4. AppImage white screen on Fedora 44 / Ubuntu 24.04
## 4. AppImage white screen / EGL errors (Fedora 44, Ubuntu 24.04+, 26.04)
**Symptom:** the AppImage window opens fully white. No UI ever appears.
**Symptom:** the AppImage window opens fully white. No UI ever appears. On
newer distros (Ubuntu 24.04 and later, incl. 26.04) the terminal often shows
`Could not create default EGL display: EGL_BAD_PARAMETER`.
**Cause:** WebKitGTK 2.44 / 2.46 compositing-mode regression.
**Cause:** WebKitGTK rendering regressions — the DMA-BUF renderer on modern
WebKitGTK (2.48+), or the 2.44 / 2.46 compositing mode.
**Fix:** see [linux.md#appimage-white-screen-on-fedora-44--ubuntu-2404](linux.md#appimage-white-screen-on-fedora-44--ubuntu-2404).
**Fix:** try `WEBKIT_DISABLE_DMABUF_RENDERER=1` first (modern WebKitGTK / the
EGL error), then `WEBKIT_DISABLE_COMPOSITING_MODE=1` — full walkthrough incl.
the software-rendering last resort:
[linux.md#appimage-white-screen-on-fedora-44--ubuntu-2404](linux.md#appimage-white-screen-on-fedora-44--ubuntu-2404).
**Linked issues:** [#62](https://github.com/debpalash/OmniVoice-Studio/issues/62),
[#961](https://github.com/debpalash/OmniVoice-Studio/issues/961)
## 5. Windows Triton / torch.compile OOM
@@ -179,9 +188,11 @@ falling back to faster-whisper`.
**Cause:** `mlx-whisper` and `mlx-audio` only build for arm64 (Apple Silicon).
**Fix:** none needed `faster-whisper` (CTranslate2) is the supported Intel
path and is still fast. If you want the latest CT2 wheels, run `uv sync`
from a fresh source checkout.
**Fix:** none needed on Apple Silicon setups that log this transiently. Note
that Intel Macs can no longer run the local backend at all — PyTorch dropped
Intel-Mac wheels, so this entry only applies to historical installs (see
[macos.md](macos.md) and
[#889](https://github.com/debpalash/OmniVoice-Studio/issues/889)).
## 10. Windows: `Could not locate cudnn_ops_infer64_8.dll` during transcription
@@ -191,14 +202,30 @@ WhisperX or faster-whisper selected.
**Cause:** WhisperX and faster-whisper run on **CTranslate2**, which needs
**cuDNN 8**, but PyTorch 2.8 ships cuDNN 9. OmniVoice side-loads a cuDNN-8 copy
from `.venv\Lib\site-packages\cudnn8_compat\`; if that folder is missing
(some upgrade paths don't install it), CTranslate2 can't find the DLL.
from `.venv\Lib\site-packages\cudnn8_compat\` — but the step that installs that
folder only ever lived in the dev-loop setup script, which isn't bundled into
the packaged app. **Packaged installs never had these libraries at all**, so
reinstalling never fixed it ([#827](https://github.com/debpalash/OmniVoice-Studio/issues/827)).
**Fix:** switch the ASR backend to **PyTorch Whisper** in **Settings → Models**.
It runs on PyTorch's own stack (cuDNN 9, bundled with torch) and needs no
cuDNN-8 DLL — it loads its Whisper pipeline on demand (no extra env var). To
keep using faster-whisper/WhisperX instead, reinstall to restore the bundled
`cudnn8_compat` libraries.
**Fix:** update to the latest build and relaunch — the app's bootstrap now
detects a CUDA machine and installs the cuDNN-8 libraries into the backend venv
automatically at launch ([#869](https://github.com/debpalash/OmniVoice-Studio/pull/869)).
(The check is skipped — and its negative result cached — on CPU/AMD/Apple
machines, so non-NVIDIA launches stay instant.)
If the automatic install can't run (offline / restricted network), install
manually into the backend venv, then restart:
```
uv pip install --no-deps --python .venv\Scripts\python.exe --target .venv\Lib\site-packages\cudnn8_compat nvidia-cudnn-cu12==8.9.7.29
```
(On Linux the target is `.venv/lib/pythonX.Y/site-packages/cudnn8_compat`.)
Or sidestep cuDNN 8 entirely: switch the ASR backend to **PyTorch Whisper** in
**Settings → Models**. It runs on PyTorch's own stack (cuDNN 9, bundled with
torch) and needs no cuDNN-8 DLL — it loads its Whisper pipeline on demand (no
extra env var).
## 11. IndexTTS / CosyVoice / ChatterboxTTS clash
@@ -215,6 +242,15 @@ for the dedicated CosyVoice path.
**Linked issue:** [#55](https://github.com/debpalash/OmniVoice-Studio/issues/55)
**Same class, ASR side:** the `nemo-parakeet` ASR engine has the identical
problem and currently has **no safe install path** at all — `nemo_toolkit[asr]`
hard-pins `transformers>=4.57,<4.58`, which is unsatisfiable alongside
OmniVoice's own `transformers>=5.3` requirement. Installing it into the
shared venv breaks the backend outright. Do not `pip install nemo_toolkit`
into OmniVoice's environment; if you want to try it, use a separate Python
environment. Isolated-venv support for this engine (matching CosyVoice/
dots-tts) is tracked in [#974](https://github.com/debpalash/OmniVoice-Studio/issues/974).
## 12. CUDA PyTorch wheel download fails on first run
**Symptom:** first-run setup stops at **Installing dependencies** with a failure
@@ -287,8 +323,10 @@ order:
files are a common false-positive quarantine), then re-enable.
- **Connection** — use a stable, direct connection; pause any VPN; avoid
corporate/school networks.
- **Region mirror** — if `huggingface.co` is slow/blocked where you are, set a
mirror **before** launching and relaunch:
- **Region mirror** — if `huggingface.co` is slow/blocked where you are, pick a
mirror in-app (**Settings → Models → Hugging Face mirror**, or the quick-pick
the first-run system check offers when the endpoint is unreachable), or set
it as an env var before launching and relaunch:
- macOS/Linux: `export HF_ENDPOINT=https://hf-mirror.com`
- Windows (PowerShell): `[Environment]::SetEnvironmentVariable("HF_ENDPOINT","https://hf-mirror.com","User")`
@@ -344,14 +382,103 @@ did was `generate:start (audio)`, a dub, or a dictation.
4. **Test with a 10-second clip** first — if that returns quickly, it confirms a
compute/VRAM limit rather than a true hang.
Newer builds **bound** every GPU job — whole-file transcription **and** TTS
generation: instead of hanging forever and starving the backend, a wedged job now
fails after a timeout with this exact guidance, and the worker pool is reset so
capacity is restored automatically (no app restart needed). Tune the bounds with
`OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S` (transcription) and
`OMNIVOICE_GENERATE_TIMEOUT_S` (generation) — both in seconds, default 300.
**Raise** them for very long single files/generations, **lower** them to fail
faster on a small machine.
Newer builds **bound** every GPU job — whole-file transcription, **chunked dub
transcription**, **and** TTS generation: instead of hanging forever and starving
the backend, a wedged job now fails after a timeout with this exact guidance,
and the worker pool is reset so capacity is restored automatically (no app
restart needed). Tune the bounds with `OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S`
(whole-file transcription) and `OMNIVOICE_GENERATE_TIMEOUT_S` (generation) —
both in seconds, default 300 — and `OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S`
(per-chunk dub transcription, default 120). **Raise** them for very long single
files/generations, **lower** them to fail faster on a small machine.
**If transcribe timeouts keep repeating back-to-back**, pool resets aren't
recovering the underlying hang — the wedged thread keeps its VRAM until the app
exits. The error message will then recommend switching the ASR engine to
**Faster-Whisper (crash-isolated subprocess)** (`faster-whisper-isolated`) in
**Settings → Engines**: it runs transcription in a separate process that can be
force-killed to reclaim a hung transcribe *and* its VRAM, at a small per-call
overhead. It reuses your existing faster-whisper install (nothing extra to
download). OmniVoice never switches engines automatically — this stays your
call.
> **Seeing "The backend crashed (exit code …)" instead?** That's the other
> failure mode: the backend **process died** (native CUDA abort, out-of-memory
> kill, DLL crash) rather than hanging. Newer desktop builds detect the death,
> restart the backend automatically (giving up after 3 crashes in 10 minutes),
> and show a crash notice with a **View crash details** button (exit code +
> the last error output). Use **Report this bug** from that notice — the crash
> evidence is attached to the prefilled GitHub issue automatically, with home
> paths scrubbed. The raw markers live next to the backend logs in
> `backend_crash_markers.json`.
## 15. Stuck at "preparing" forever after a crash / BSOD (Windows)
**Symptom:** after an unclean shutdown (Windows BSOD, forced power-off), every
launch sits on the "preparing" splash indefinitely — even though the backend is
actually healthy (its log shows models loaded, and
`http://127.0.0.1:3900/health` answers `{"status":"ok"}` in a browser). The
WebView log contains:
```
IPC custom protocol failed, Tauri will now use the postMessage interface instead
TypeError: Failed to fetch
```
**Cause:** the crash corrupted the WebView2 profile cache at
`%LOCALAPPDATA%\com.debpalash.omnivoice-studio\EBWebView`. Both the IPC custom
protocol *and* its postMessage fallback break, so the splash never hears the
"ready" signal from the app shell (issue #879).
**Fix:** current builds handle this automatically — if the splash gets no IPC
signal within ~10 s it checks the backend over plain HTTP and proceeds on its
own; if the backend isn't up either, after ~45 s a recovery panel appears with
**Repair and restart** (Windows), which clears the WebView cache and relaunches.
Your voices, projects, and settings are not touched — only browser display data
is cleared.
On older builds (≤ 0.3.8), or if the automatic repair fails, do it manually:
quit OmniVoice Studio, delete the folder below, then start the app again.
<!-- validate: skip -->
```powershell
Remove-Item -Recurse -Force "$env:LOCALAPPDATA\com.debpalash.omnivoice-studio\EBWebView"
```
## 16. macOS: microphone permission never prompts, OmniVoice never appears in System Settings
**Symptom:** clicking record shows "Microphone access denied. macOS: open
System Settings → Privacy & Security → Microphone and enable OmniVoice" —
but OmniVoice never appears in that list, so there's nothing to enable.
`NSMicrophoneUsageDescription` is present in the app's `Info.plist`, and
resetting the permission (`tccutil reset Microphone
com.debpalash.omnivoice-studio`) followed by a relaunch changes nothing — no
system prompt ever appears.
**Cause:** the app bundle was missing the Hardened Runtime *entitlement* for
microphone access. An earlier revision of this section blamed an upstream
Tauri/WebKit limitation — that was wrong (a community contributor,
[@MahdiHedhli](https://github.com/MahdiHedhli), read the sources more
carefully and found the real gap). wry's `WKUIDelegate` already grants the
WebKit-layer media-capture request; but Tauri's macOS bundler enables
Hardened Runtime by default, and Hardened Runtime blocks microphone hardware
access unless `com.apple.security.device.audio-input` is present in the
signed binary's entitlements — regardless of `Info.plist`'s
`NSMicrophoneUsageDescription` (that only supplies the prompt *text*).
Without the entitlement, macOS's TCC layer never registers a request, which
is exactly why the app never appears in the System Settings list.
**Fix:** ships in the release after v0.3.12 (the bundle now carries
`src-tauri/entitlements.plist` — [#1016](https://github.com/debpalash/OmniVoice-Studio/pull/1016),
contributed by the same person who diagnosed it). Update and live recording
works, with a normal macOS permission prompt on first use.
**Workaround on older builds (≤ v0.3.12):** record your voice sample in any
other app (Voice Memos, QuickTime, etc.) and upload the resulting file in
OmniVoice instead of using live recording — upload-based cloning is
unaffected and works normally.
**Linked issue:** [#1013](https://github.com/debpalash/OmniVoice-Studio/issues/1013)
## Dub: "translation engine needs the optional … package"
+96 -5
View File
@@ -5,7 +5,24 @@ working OmniVoice Studio install on Windows 10 / 11 (x64).
## Prerequisites
### Using the MSI installer
- **Windows 10 (21H2 or newer) or Windows 11**, x64.
- **~10 GB free disk** for the app, its Python environment, and model weights.
- Optional: an **NVIDIA GPU + driver** for CUDA acceleration — see
[GPU support on Windows](#gpu-support). AMD GPUs run CPU-only on Windows.
That's it — Python, FFmpeg, and the model weights are bundled or bootstrapped
by the app itself on first launch. No toolchain needed.
### Building from source
Everything above, plus the toolchain:
- **Git for Windows**`winget install --id Git.Git -e`. Needed for
`git clone`, and it includes **Git Bash**, which `bun run desktop-prod`
uses to run its build-and-launch script. Without it, `desktop-prod` stops
with an error telling you to install it.
- **Python 3.11+**`winget install Python.Python.3.11` (or download from
[python.org](https://www.python.org/downloads/windows/)).
- **Microsoft C++ Build Tools** — required by some PyPI source distributions
@@ -14,13 +31,24 @@ working OmniVoice Studio install on Windows 10 / 11 (x64).
with the **"Desktop development with C++"** workload checked.
- **Bun**`powershell -c "irm bun.sh/install.ps1 | iex"`.
- **FFmpeg**`winget install Gyan.FFmpeg`.
- **Git for Windows** (from-source installs only) — `winget install --id Git.Git -e`.
You need it for `git clone` anyway, and it includes **Git Bash**, which
`bun run desktop-prod` uses to run its build-and-launch script. Without it,
`desktop-prod` stops with an error telling you to install it.
- **Rust / Cargo** (required for building from source only) — `winget install Rust.Rustup` or download `rustup-init.exe` from [rustup.rs](https://rustup.rs/).
- **Rust / Cargo**`winget install Rust.Rustup` or download `rustup-init.exe` from [rustup.rs](https://rustup.rs/).
After installing Rustup, close and reopen PowerShell before running `bun run desktop-prod`.
## GPU support on Windows
<a id="gpu-support"></a>
**GPU acceleration on Windows is NVIDIA/CUDA-only.** The Windows install
ships the CUDA build of PyTorch; with an NVIDIA GPU and a regular NVIDIA
driver it's picked up automatically (no CUDA Toolkit install needed).
**AMD GPUs — including Ryzen / Ryzen AI integrated Radeon graphics — run
CPU-only on Windows.** ROCm is not supported on Windows: PyTorch publishes no
Windows ROCm wheels, and OmniVoice's ROCm option is Linux-only. (The Ryzen AI
NPU is likewise not used.) Everything still works on CPU, just slower. If you
have an AMD GPU and want GPU acceleration, run OmniVoice on Linux instead —
see [linux.md — AMD GPU (ROCm)](linux.md#amd-gpu-rocm).
## Install (from source)
Run from a regular (non-admin) PowerShell:
@@ -49,6 +77,69 @@ Download the latest MSI from the
run it, follow the wizard. The shortcut lands in the Start menu as
**OmniVoice Studio**.
### Installing to a different drive
<a id="install-other-drive"></a>
The wizard's **directory picker** lets you install the app to any **local**
drive (D:, E:, …). Two caveats:
- **Mapped network drives (Z: → a share) are not supported** — this is a
Windows Installer limitation, not an OmniVoice bug: MSI custom actions run
as a service account that doesn't see per-user drive mappings, so the
install fails or rolls back. Install to a local drive instead.
- The install location only moves the ~200 MB app itself. The big data
(models, voices, projects — tens of GB) lives in the **data directory**,
which you move independently: **Settings → Storage → Models directory**
in-app, or `OMNIVOICE_DATA_DIR` / [Portable mode](#portable-install) for
the whole data tree.
If an install to a local non-C: drive fails anyway, capture a log with
`msiexec /i OmniVoice*.msi /L*V install.log` and
[open an issue](https://github.com/debpalash/OmniVoice-Studio/issues) with it
— that log shows exactly which step rolled back.
## Portable install (Windows)
<a id="portable-install"></a>
OmniVoice Studio has a **Portable** mode: instead of scattering data across
`%APPDATA%` and `%LOCALAPPDATA%`, the whole install — Python env, model
weights, voices, projects, settings — lives in a single
`OmniVoiceStudio-Data` folder created **next to the executable**. Moving or
copying the app folder (exe + that data folder together) relocates the entire
install, USB-stick style.
The first-run setup screen offers Portable whenever the folder next to
`OmniVoice Studio.exe` is writable. A default MSI install goes to
`C:\Program Files`, which is *not* user-writable — that's why Portable shows
as greyed out after a default install
([#766](https://github.com/debpalash/OmniVoice-Studio/issues/766)). To enable
it, install to a user-writable folder instead:
- Re-run the MSI and choose a custom destination folder in the setup wizard
(e.g. `D:\Apps\OmniVoice`), or
- From a terminal:
`msiexec /i OmniVoice.Studio_<version>_x64_en-US.msi INSTALLDIR="D:\Apps\OmniVoice"`
On the next launch, pick **Portable** on the first-run setup screen. What
lives next to the exe afterwards:
<!-- validate: skip -->
```
D:\Apps\OmniVoice\
├── OmniVoice Studio.exe ← the app
└── OmniVoiceStudio-Data\ ← the whole install, self-contained
├── config.json ← install-mode + app settings
├── env\ ← Python venv + backend code
└── data\ ← voices, projects, settings DB
└── models\ ← model weights (HF cache)
```
Prefer the default Program Files install? **Installed** mode is the same app —
data just lives in `%APPDATA%\OmniVoice` and the model cache in
`%LOCALAPPDATA%\OmniVoice\hf_cache`.
## HF_TOKEN persistence
The **recommended path** is the in-app **Settings → API Keys** panel: it
+164
View File
@@ -0,0 +1,164 @@
# Playbook — Setting up sponsorship for an open-source project
> A portable, copy-to-another-repo guide for adding a tasteful sponsorship
> system to a free/local-first OSS project. This is the exact setup shipped
> in OmniVoice Studio (PRs #923 + #924); lift the files, swap the names, and
> you have the same system in an afternoon.
## Philosophy (decide this first — it shapes everything)
1. **Sponsorship is a thank-you, not a paywall.** The software stays fully
free and the same license. Tiers buy *visibility and gratitude*
(logo placement), never gated features. Say this out loud in `SPONSORS.md`
— it's what keeps the community's trust and separates you from a freemium
bait-and-switch.
2. **Tell the honest funding story.** People sponsor a *reason*, not a tip
jar. OmniVoice's is "one developer, in the open, and the AI-agent bills
are real." Whatever yours is (server costs, your time, signing certs),
state it plainly and specifically. Vague "support us" underperforms a
concrete "here's what the money pays for."
3. **Local-first / no-infra.** No sponsor-management SaaS, no token held by
the app, no third-party embed. The contact flow is a prefilled GitHub
issue the user submits from their own browser — the same zero-credential
pattern good OSS bug-reporters use. It survives forks (change one URL).
4. **Ask at value moments, rarely.** (This is the *prompting* half — see the
donation-moments system, a separate piece: after a successful export,
≥N lifetime successes, long cooldown, permanent opt-out. Never nag.)
The two failure modes to avoid: **core-js** (console-spam nagging → community
backlash) and **blocking modals**. The two that work: **value-moment timing**
+ **enforced rarity** with an instant, respected exit.
## The pieces (what to create)
A complete system is six files. Placements form a natural ladder — each tier
adds one more surface:
```
SPONSORS.md ← the home: why, tiers, how-to, roster, asset rules
README.md (## Sponsors subsection) ← logo slots + "your logo here" + link to SPONSORS.md
.github/FUNDING.yml ← GitHub's native "Sponsor" button (Ko-fi / custom links)
.github/ISSUE_TEMPLATE/sponsor.yml ← the "Sponsorship inquiry" issue FORM (structured fields)
frontend/.../config/sponsors.js ← in-app single source of truth (empty array + contact URLs)
frontend/.../SupportPage + footer ← in-app logo grid, "Become a sponsor" CTA, footer link
```
### 1. `SPONSORS.md` — the home
Sections, in order: **Why sponsor** (the honest funding story + "where your
money goes"), **Tiers** (a table — placements as benefits, cumulative),
**How to become a sponsor**, **Logo/asset guidelines**, **Current sponsors**
(a "be the first" placeholder with empty tier tables ready to fill), and a
**Not a paywall** note.
Tier ladder that maps to real surfaces:
| Tier | Placement added |
|------|-----------------|
| Backer | name/handle in `SPONSORS.md` |
| Bronze | + small logo in `SPONSORS.md` and the README Sponsors section |
| Silver | + logo in the README and the in-app Sponsors page |
| Gold | + prominent logo slot on the project website/landing |
**Leave prices as owner-input placeholders.** Use an HTML-comment marker so
they're obvious in source and never accidentally invented by an automated
edit: `_set by owner_ <!-- OWNER: set amounts -->`. Same for a public contact
email — don't publish a personal address without the owner's explicit call;
default the contact to the GitHub issue form.
### 2. README `## Sponsors` subsection
A short pitch, a logo-slot placeholder (`**Your logo here** — [become a
sponsor](SPONSORS.md)`), and a link to `SPONSORS.md`. Wrap the logo area in
`<!-- SPONSORS:START -->` / `<!-- SPONSORS:END -->` markers so a future script
can auto-render logos from the config. Add a `Sponsors` entry to the top nav.
### 3. `.github/FUNDING.yml`
Turns on GitHub's native "Sponsor" button. Only list platforms you're
actually on — don't add `github: [you]` unless GitHub Sponsors is set up.
Ko-fi + a `custom:` list (PayPal, the SPONSORS.md link) is a fine start:
```yaml
ko_fi: yourhandle
custom:
- "https://paypal.me/you"
- "https://github.com/you/repo/blob/main/SPONSORS.md"
```
### 4. `.github/ISSUE_TEMPLATE/sponsor.yml` — the inquiry form
A structured issue **form** (name/org, website, logo URL, tier interest,
contact, acknowledgements), `labels: ["sponsor"]`. **Gotcha we hit:** if
`config.yml` has `blank_issues_enabled: false`, a bare
`issues/new?title=…&body=…` prefill redirects to the template chooser and
*drops the body*. So point "Become a sponsor" at the **template route**
instead: `issues/new?template=sponsor.yml`. That carries the form's fields
reliably.
### 5. In-app config — single source of truth
One module the whole app reads (`config/sponsors.js` in our case):
```js
export const SPONSORS = []; // { name, logoUrl, url, tier } — empty until you have sponsors
export const SPONSOR_TIERS = ['platinum', 'gold', 'silver', 'bronze']; // display order
export const SPONSOR_CONTACT = {
githubIssue: `${REPO}/issues/new?template=sponsor.yml`, // the template route (see gotcha)
kofi: KOFI_URL,
docsUrl: `${REPO}/blob/main/SPONSORS.md`,
};
```
Adding a sponsor = one PR touching this array **and** `SPONSORS.md` (keep them
in lockstep; a test can assert they match).
### 6. In-app surface — Support page section + footer link
- A **Sponsors section** on the Support/About page: a logo grid grouped by
tier that renders from `SPONSORS`, with a **tasteful empty state** ("Be the
first to sponsor — your logo here" + an outlined slot) while the array is
empty, a **"Become a sponsor"** button opening `SPONSOR_CONTACT.githubIssue`
via the app's external-open helper (Tauri-safe), and a one-line explainer of
what sponsors get, linking to `SPONSORS.md`.
- A **compact footer link/icon** that opens that section. Keep it small and
uniform with the other footer icons.
- Logos: lazy-loaded, max-height capped, `aria-label`ed, `rel="noreferrer"`.
## How to replicate on another project (checklist)
1. Copy `SPONSORS.md`, `.github/FUNDING.yml`, `.github/ISSUE_TEMPLATE/sponsor.yml`.
Find-and-replace the repo slug, handle, and funding URLs. Write your own
honest funding story + "where your money goes".
2. Add the README `## Sponsors` subsection with the `SPONSORS:START/END`
markers and a nav entry.
3. If the project has an app UI: add the `sponsors.js` config (empty array),
a Sponsors section on your support/about screen, and a footer link. Wire
the CTA to the issue-template route. If it's a library/CLI with no UI,
skip this — the docs + FUNDING.yml carry it.
4. Leave prices and any public contact as `<!-- OWNER: … -->` placeholders for
the maintainer to fill. Don't invent amounts or publish a personal email.
5. (Optional, recommended) Add the **value-moment donation prompt** — a
throttled, opt-out-able "support us" nudge shown only after a real success,
never more than rarely. That's a separate component; see the donation-
moments implementation.
6. Add a test that `sponsors.js` and `SPONSORS.md` list the same sponsors, so
they can't drift.
## What NOT to do
- ❌ A sponsor-management SaaS or a third-party embed (breaks local-first,
adds a dependency, holds credentials).
- ❌ Bare `issues/new?body=…` prefill when blank issues are disabled (body is
dropped — use `?template=`).
- ❌ Inventing tier prices or publishing a personal contact email in an
automated edit — leave `OWNER:` markers.
- ❌ Gating features behind tiers, or nagging. The software stays free; the
ask stays a rare, respected thank-you moment.
---
*Provenance: this is the system shipped in OmniVoice Studio — `SPONSORS.md`,
the README Sponsors section, `.github/FUNDING.yml`, `.github/ISSUE_TEMPLATE/
sponsor.yml`, `frontend/src/config/sponsors.js`, the Support-page Sponsors
section, and the footer link. Copy them and adapt.*
Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 358 KiB

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB

@@ -6,7 +6,7 @@ Today the longform renderer (Audiobook + Stories) applies a **single-pass** `lou
Upgrade to **two-pass** `loudnorm`: a first **measure** pass (`print_format=json`, output to `-f null -`) parses the clip's `input_i / input_tp / input_lra / input_thresh / target_offset`, then a second **apply** pass feeds those measured values back as `measured_*` + `offset` + `linear=true`. This lands the output accurately on the preset target. The change is a **runner enhancement** layered over the existing pure builders — the pure `build_loudnorm_filter()` and `LOUDNESS_PRESETS` stay; we add a measure-filter builder, a measured-apply-filter builder, a JSON parser, a measure-cmd argv builder, and an async two-pass orchestrator that runs in `_render_longform_sse` (`backend/api/routers/audiobook.py:345`) between the chapter renders and the final mux. Loudness stays **opt-in** (`loudness: None` default on both `AudiobookRequest` `:151` and `LongformRenderRequest` `:510`), so default cross-platform behavior is unchanged.
> **Naming note (grounded):** "mastering" already exists in this codebase as `services.audio_dsp.apply_mastering()` (`backend/services/audio_dsp.py:101`) — a per-clip pedalboard EQ/Compressor/Reverb chain used by `/generate`, `/dub`, batch, and stream paths. That is a **different** operation and **is not called** in the longform path (`_render_longform_sse` muxes chapter WAVs straight from `synthesize_chapter`, no `apply_mastering`). The two-pass loudnorm here is the *only* loudness operation in the longform renderer. To avoid conflating the two, the new SSE event is named `"mastering"` deliberately as the user-facing loudness step for longform; this is harmless because the longform stream never emits anything else by that name, but reviewers should know the term is overloaded across the repo.
> **Naming note (grounded):** "mastering" already exists in this codebase as `services.audio_dsp.apply_mastering()` (`backend/services/audio_dsp.py:101`) — a per-clip pedalboard highpass/Compressor chain used by `/generate`, `/dub`, batch, and stream paths. That is a **different** operation and **is not called** in the longform path (`_render_longform_sse` muxes chapter WAVs straight from `synthesize_chapter`, no `apply_mastering`). The two-pass loudnorm here is the *only* loudness operation in the longform renderer. To avoid conflating the two, the new SSE event is named `"mastering"` deliberately as the user-facing loudness step for longform; this is harmless because the longform stream never emits anything else by that name, but reviewers should know the term is overloaded across the repo.
## Problem
+29 -2
View File
@@ -1,8 +1,8 @@
# Update channels (Stable / Preview)
OmniVoice Studio auto-updates itself in the background. You choose **which
builds** it offers you with the update channel in **Settings → About → Update
channel**.
builds** it offers you with the update channel in **Settings → Updates →
Update channel**.
| Channel | What you get | Who it's for |
|---------|--------------|--------------|
@@ -25,6 +25,33 @@ manifest:
Both manifests are signed with the same minisign key, so a tampered build is
rejected regardless of channel.
## Your data during updates
Your voices, projects, history, and settings live in a SQLite database
(`omnivoice.db`) outside the app bundle, so replacing the app never touches
them. On the **first launch of an updated build**, if the new version needs a
database schema upgrade, OmniVoice:
1. **Backs up the database first** — a consistent snapshot is written next to
it as `omnivoice.db.backup-<version>-<n>` before any migration runs. The
newest **3** backups are kept; older ones are pruned automatically.
(Databases over 500 MB skip the snapshot, with a log line saying so.)
2. **Stops instead of guessing** — if a migration fails midway, the app does
*not* start on a half-migrated database and does *not* silently restore
anything. It shows an error naming the backup path so you (or a support
thread) decide: retry, report the issue, or roll back by replacing
`omnivoice.db` with the backup.
**Settings → Updates** shows the timestamp of the latest backup, the release
notes of any available update, and a **What's new** reader for the shipped
changelog — all local, no extra network calls.
The Python environment (`.venv`) is also updated non-destructively: dependency
drift after an app update is reconciled **in place** with `uv sync`, and a
failed sync keeps the previous environment working. The venv is only ever
rebuilt when its interpreter is *confirmed* broken (structural check + a
direct probe) or when you explicitly use **Clean & Retry**.
## For maintainers — how previews are built
Preview builds come from **`main`**, two ways:
+63
View File
@@ -0,0 +1,63 @@
import { test, expect } from '@playwright/test';
import { gotoMode } from './_helpers';
/**
* Footer-clipping guard "buttons hidden under the footer on small windows"
* (owner report 2026-07-02; same class as #476/#504).
*
* The LogsFooter is a grid row of .app-container (see index.css), so page
* content must physically end at the footer's top edge no card, button, or
* action bar may render underneath it. Verified at the app's minimum window
* size (tauri.conf.json minWidth 900 × minHeight 600), where the old fixed
* overlay + padding reservation clipped the bottom card row.
*/
const MIN_WINDOW = { width: 900, height: 600 };
async function footerTop(page): Promise<number> {
const footer = page.locator('.app-container .logs-footer');
await expect(footer).toBeVisible();
const box = await footer.boundingBox();
expect(box).not.toBeNull();
return box!.y;
}
test.describe('LogsFooter never covers page content @ 900x600', () => {
test.use({ viewport: MIN_WINDOW });
test('gallery: bottom-most voice card stays above the collapsed footer', async ({ page }) => {
await gotoMode(page, 'gallery');
const cards = page.locator('.archetype-card');
await expect(cards.first()).toBeVisible({ timeout: 20_000 });
const top = await footerTop(page);
// Scroll the last card into view — with the footer in the grid flow the
// scroll container ends at the footer's top, so the card must fit fully
// above it once scrolled.
const last = cards.last();
await last.scrollIntoViewIfNeeded();
const box = await last.boundingBox();
expect(box).not.toBeNull();
expect(box!.y + box!.height).toBeLessThanOrEqual(top + 1); // 1px AA tolerance
});
test('expanded footer still cannot cover content — scroll container shrinks instead', async ({
page,
}) => {
await gotoMode(page, 'gallery');
const cards = page.locator('.archetype-card');
await expect(cards.first()).toBeVisible({ timeout: 20_000 });
// Expand the logs panel (chevron toggle in the collapsed bar).
const toggle = page.locator('.logs-footer [title], .logs-footer button').first();
await toggle.click();
await expect(page.locator('.logs-footer--open')).toBeVisible();
const top = await footerTop(page);
const last = cards.last();
await last.scrollIntoViewIfNeeded();
const box = await last.boundingBox();
expect(box).not.toBeNull();
expect(box!.y + box!.height).toBeLessThanOrEqual(top + 1);
});
});
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omnivoice-studio",
"version": "0.3.8",
"version": "0.3.14",
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
@@ -39,6 +39,7 @@
"@radix-ui/react-toggle": "^1.1.12",
"@radix-ui/react-toggle-group": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.10",
"@scalar/api-reference-react": "^0.9.52",
"@tailwindcss/vite": "^4.3.1",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-table": "^8.21.3",
+2 -1
View File
@@ -2941,7 +2941,7 @@ dependencies = [
[[package]]
name = "omnivoice-studio"
version = "0.3.8"
version = "0.3.14"
dependencies = [
"arboard",
"dirs-next",
@@ -2969,6 +2969,7 @@ dependencies = [
"walkdir",
"webkit2gtk",
"webview2-com",
"windows 0.61.3",
"windows-core 0.61.2",
"zip 2.4.2",
]
+8 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "omnivoice-studio"
version = "0.3.8"
version = "0.3.14"
description = "OmniVoice Studio AI voice cloning & dubbing desktop app"
authors = ["Debpalash"]
license = "AGPL-3.0-only"
@@ -71,6 +71,13 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
# Versions match what wry already locks — no new native code is pulled in.
webview2-com = "0.38"
windows-core = "0.61"
# Raw HWND access (GetWindowLongPtrW/SetWindowLongPtrW/ShowWindow) to mark the
# dictation pill WS_EX_NOACTIVATE so showing it doesn't steal Win32 foreground
# activation (#982 — Windows counterpart of #287's macOS focus-steal fix).
# Version pinned to match what `tauri` itself already resolves to (0.61.x) so
# `WebviewWindow::hwnd()`'s return type and our syscalls share the exact same
# `HWND` type — no second copy of the crate enters the dependency graph.
windows = { version = "0.61", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+19 -1
View File
@@ -22,9 +22,27 @@ HERE="$(dirname -- "$(readlink -f -- "$0")")"
# Sourced by AppRun.test.sh — keep this function pure so unit tests can stub
# `pkg-config`, source the file, call _detect_webkit_workaround, and inspect
# the resulting environment without exec'ing the binary.
#
# Version source (#961 follow-up): the WebKitGTK that actually RUNS is the
# BUNDLED copy (LD_LIBRARY_PATH below puts $HERE/usr/lib first) — NOT the
# host's. Asking the host's pkg-config therefore reads the wrong number
# whenever host and bundle diverge (e.g. a user who builds from source has
# dev packages installed, so pkg-config answers with their system's healthy
# 2.48 while the bundle runs an older lib — skipping a workaround the running
# library needs). inject-apprun.sh stamps the bundled version into
# .bundled-webkitgtk-version at build time, where it is knowable by
# construction; the host pkg-config path survives only as a fallback for
# bundles predating the stamp. OMNIVOICE_APPRUN_WK_MARKER exists for the
# unit tests to point at a fixture marker.
_detect_webkit_workaround() {
local wk_version="0.0"
if command -v pkg-config >/dev/null 2>&1; then
local marker="${OMNIVOICE_APPRUN_WK_MARKER:-$HERE/.bundled-webkitgtk-version}"
if [ -r "$marker" ]; then
# Empty/unreadable marker content → "0.0" (unknown) → fail-safe workaround,
# same philosophy as the missing-pkg-config branch below.
wk_version="$(cat "$marker" 2>/dev/null | tr -d '[:space:]')"
[ -n "$wk_version" ] || wk_version="0.0"
elif command -v pkg-config >/dev/null 2>&1; then
wk_version="$(pkg-config --modversion webkit2gtk-4.1 2>/dev/null \
|| pkg-config --modversion webkit2gtk-4.0 2>/dev/null \
|| echo "0.0")"
@@ -72,6 +72,56 @@ run_case "2.46 (broken)" "2.46.1" "1"
run_case "2.48 (healthy)" "2.48.0" "unset"
run_case "pkg-config absent" "0.0" "1" "no"
# ── Bundled-version marker cases (#961 follow-up) ───────────────────────────
# inject-apprun.sh stamps the bundle's actual WebKitGTK version into
# .bundled-webkitgtk-version at build time; AppRun must prefer that marker
# over the host's pkg-config (which reports the SYSTEM version — wrong
# whenever it diverges from the bundled copy, e.g. on a machine with newer
# dev packages installed).
run_marker_case() {
local label="$1" marker_content="$2" pkg_output="$3" expected="$4"
local marker_file
marker_file="$(mktemp)"
printf '%s\n' "$marker_content" > "$marker_file"
local actual
actual=$(
bash -c '
set +e
pkg_output="'"$pkg_output"'"
export OMNIVOICE_APPRUN_WK_MARKER="'"$marker_file"'"
pkg-config() { echo "$pkg_output"; }
export -f pkg-config
exec() { :; }
export -f exec
# shellcheck disable=SC1090
source "'"$THIS_DIR"'/AppRun" >/dev/null 2>&1 || true
echo "${WEBKIT_DISABLE_COMPOSITING_MODE:-unset}"
'
)
rm -f "$marker_file"
if [[ "$actual" == "$expected" ]]; then
echo "PASS [$label]"
PASS_COUNT=$((PASS_COUNT + 1))
else
echo "FAIL [$label]: expected '$expected' got '$actual'" >&2
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
}
# Marker says broken → workaround applies, even though host pkg-config says healthy.
run_marker_case "marker 2.46 beats host 2.48" "2.46.1" "2.48.0" "1"
# Marker says healthy → no workaround, even though host pkg-config says broken
# (the exact #961 inversion: from-source user with old system lib, new bundle).
run_marker_case "marker 2.48 beats host 2.44" "2.48.0" "2.44.3" "unset"
# Empty marker → treated as unknown → fail-safe workaround.
run_marker_case "empty marker fails safe" "" "2.48.0" "1"
echo
echo "─── AppRun test summary: $PASS_COUNT pass / $FAIL_COUNT fail ───"
if [[ $FAIL_COUNT -ne 0 ]]; then
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
Tauri's macOS bundle defaults `hardenedRuntime` to true. Hardened
Runtime blocks camera/microphone hardware access unless the matching
entitlement is present here — regardless of Info.plist's
NSMicrophoneUsageDescription and regardless of wry's own WKUIDelegate
already granting the request at the WebKit/JS layer
(WryWebViewUIDelegate::request_media_capture_permission unconditionally
calls WKPermissionDecision::Grant). Without this entitlement, TCC
never even registers a request for the app — nothing shows up in
System Settings → Privacy & Security → Microphone to enable, because
the OS never saw a legitimately-entitled process ask.
-->
<key>com.apple.security.device.audio-input</key>
<true/>
<!--
Matches Info.plist's forward-looking NSCameraUsageDescription — no
current feature uses the camera, but ship the entitlement now so a
future getUserMedia({video: true}) call doesn't hit this same bug.
-->
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>
+75 -1
View File
@@ -34,11 +34,55 @@ pub fn port_in_use(port: u16) -> bool {
pub fn backend_healthy(port: u16) -> bool {
let url = format!("http://127.0.0.1:{}/system/info", port);
match ureq_get_with_timeout(&url, Duration::from_millis(500)) {
Ok(body) => body.contains("\"model_checkpoint\"") || body.contains("\"data_dir\""),
Ok(body) => is_omnivoice_body(&body),
Err(_) => false,
}
}
fn is_omnivoice_body(body: &str) -> bool {
body.contains("\"model_checkpoint\"") || body.contains("\"data_dir\"")
}
/// The `app_version` reported by the OmniVoice backend at :port.
/// `None` when nothing OmniVoice answers there (port free, or a foreign
/// process). `Some("")` when it IS our backend but predates the
/// `app_version` field — callers treat that as stale.
pub fn running_backend_version(port: u16) -> Option<String> {
let url = format!("http://127.0.0.1:{}/system/info", port);
let body = ureq_get_with_timeout(&url, Duration::from_millis(500)).ok()?;
if !is_omnivoice_body(&body) {
return None;
}
Some(parse_app_version(&body).unwrap_or_default())
}
/// Extract `"app_version": "X"` from a /system/info body. String-sniff on one
/// field (consistent with `backend_healthy`) — no JSON dependency needed.
fn parse_app_version(body: &str) -> Option<String> {
let key = "\"app_version\"";
let rest = &body[body.find(key)? + key.len()..];
let rest = rest[rest.find(':')? + 1..].trim_start();
let rest = rest.strip_prefix('"')?;
Some(rest[..rest.find('"')?].to_string())
}
/// Whether a running backend's version matches THIS app build, comparing
/// **base** versions (any `-N` pre-release suffix stripped from both sides) so
/// a preview build `0.3.10-4` still attaches to its `0.3.10` backend.
///
/// Why this exists (the "bound port blocked the newer version" report): an
/// orphaned backend from a *previous* version keeps answering health checks
/// after an update, so "healthy" alone made the new UI silently attach to old
/// backend code — every fix in the update appeared to change nothing. A
/// version-mismatched (or unversioned) OmniVoice responder is stale by
/// definition; callers kill it and spawn the bundled backend instead.
pub fn same_app_version(running: &str) -> bool {
fn base(v: &str) -> &str {
v.split('-').next().unwrap_or(v).trim()
}
!running.is_empty() && base(running) == base(env!("CARGO_PKG_VERSION"))
}
fn ureq_get_with_timeout(url: &str, timeout: Duration) -> Result<String, String> {
let url = url.strip_prefix("http://").ok_or("only http:// supported")?;
let (host_port, path) = match url.find('/') {
@@ -365,4 +409,34 @@ mod tests {
assert!(diag.contains("Interpreter present on disk: false"));
assert!(diag.contains("Clean & Retry"), "must give an actionable hint");
}
// ── stale-backend detection (the "bound port blocked the newer version"
// report: a healthy orphan from a previous version must NOT be
// attached to) ─────────────────────────────────────────────────────
#[test]
fn parse_app_version_reads_system_info_shape() {
let body = r#"{"app_version":"0.3.9","data_dir":"/x","model_checkpoint":"k2"}"#;
assert_eq!(parse_app_version(body).as_deref(), Some("0.3.9"));
// whitespace after the colon is fine
assert_eq!(
parse_app_version(r#"{ "app_version" : "1.2.3" }"#).as_deref(),
Some("1.2.3")
);
// pre-app_version backends and foreign bodies yield None
assert_eq!(parse_app_version(r#"{"data_dir":"/x"}"#), None);
assert_eq!(parse_app_version("<html>not json</html>"), None);
}
#[test]
fn same_app_version_matches_current_build_and_rejects_stale() {
let ours = env!("CARGO_PKG_VERSION");
assert!(same_app_version(ours), "own version must attach");
// preview stamp of the same base still attaches
assert!(same_app_version(&format!("{}-7", ours)));
// a different (older) release is stale
assert!(!same_app_version("0.0.1"));
// unversioned (pre-app_version backend) is stale by definition
assert!(!same_app_version(""));
}
}
+670 -62
View File
@@ -12,6 +12,7 @@ use serde::Serialize;
use tauri::{Emitter, Manager};
use crate::config::get_effective_region;
use crate::crash::BackendExit;
use crate::tools::resolve_uv;
use crate::{AppFlags, BackendState, backend_port};
@@ -145,13 +146,34 @@ pub fn retry_bootstrap(app: tauri::AppHandle, state: tauri::State<'_, BootstrapS
set_stage(&stage_handle, BootstrapStage::Ready);
return;
}
if crate::backend::backend_healthy(backend_port()) {
log::info!("Port {} already serving OmniVoice backend — attaching", backend_port());
set_stage(&stage_handle, BootstrapStage::Ready);
return;
match crate::backend::running_backend_version(backend_port()) {
Some(v) if crate::backend::same_app_version(&v) => {
log::info!(
"Port {} already serving OmniVoice backend v{} — attaching",
backend_port(), v
);
set_stage(&stage_handle, BootstrapStage::Ready);
return;
}
Some(v) => {
// A healthy-but-stale backend from a previous version (the
// classic post-update orphan). Attaching would silently run
// OLD backend code under the new UI — replace it instead.
log::warn!(
"Port {} serves a stale OmniVoice backend (v{} != app v{}) — replacing it",
backend_port(),
if v.is_empty() { "<unknown>" } else { v.as_str() },
env!("CARGO_PKG_VERSION"),
);
set_backend_kill_intended(true); // deliberate kill, not a crash (#941)
crate::backend::kill_orphan_on_port(backend_port());
std::thread::sleep(Duration::from_millis(500));
}
None => {}
}
if crate::backend::port_in_use(backend_port()) {
log::warn!("Port {} in use — taking ownership", backend_port());
set_backend_kill_intended(true); // deliberate kill, not a crash (#941)
crate::backend::kill_orphan_on_port(backend_port());
std::thread::sleep(Duration::from_millis(500));
}
@@ -173,9 +195,7 @@ pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<B
let mut venv_heal_attempted = false;
'bootstrap: loop {
let child = crate::backend::spawn_backend(app, Some(stage_handle));
if let Ok(mut guard) = app.state::<BackendState>().process.lock() {
*guard = child;
}
track_backend_child(app, child);
let start = std::time::Instant::now();
while start.elapsed() < Duration::from_secs(300) {
if crate::backend::backend_healthy(backend_port()) {
@@ -194,20 +214,41 @@ pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<B
}
return;
}
let process_dead = if let Ok(mut guard) = app.state::<BackendState>().process.lock() {
match guard.as_mut() {
Some(child) => match child.try_wait() {
Ok(Some(status)) => Some(status.to_string()),
Ok(None) => None,
Err(_) => Some("unknown".to_string()),
},
None => Some("never started".to_string()),
}
} else {
None
};
if let Some(exit_info) = process_dead {
let process_dead: Option<(String, Option<BackendExit>)> =
if let Ok(mut guard) = app.state::<BackendState>().process.lock() {
match guard.as_mut() {
Some(child) => match child.try_wait() {
Ok(Some(status)) => {
let exit = BackendExit::from_status(status);
Some((exit.description.clone(), Some(exit)))
}
Ok(None) => None,
// try_wait errored — the death is real but its
// shape is unknown; no exit code for the marker.
Err(_) => Some(("unknown".to_string(), None)),
},
// Spawn itself failed — no process ever ran, so this
// is a spawn failure (spawn_failure_diagnostic owns
// it), NOT a crash: no marker.
None => Some(("never started".to_string(), None)),
}
} else {
None
};
if let Some((exit_info, real_exit)) = process_dead {
let err_tail = crate::backend::read_error_log_tail(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.
if let Some(ref exit) = real_exit {
if !app_is_quitting(app) && !backend_kill_intended() {
crate::crash::record_crash(crate::crash::marker_now(
exit,
backend_uptime_s(app),
crate::backend::read_error_log_tail(CRASH_STDERR_TAIL_LINES),
));
}
}
// #314: a backend that dies because the venv itself is broken
// can only be healed by rebuilding the venv — do that once
// instead of failing into an unwinnable retry loop.
@@ -216,24 +257,40 @@ pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<B
{
venv_heal_attempted = true;
let venv_dir = crate::setup::env_root(app).join("project").join(".venv");
log::warn!(
"Backend exited with a broken-venv signature ({}) — removing {} and rebuilding (#314)",
exit_info,
venv_dir.display()
);
emit_log(
app,
"checking",
"Backend failed because the Python environment is broken — rebuilding it automatically",
);
if quarantine_broken_venv(&venv_dir) {
set_stage(stage_handle, BootstrapStage::Checking);
continue 'bootstrap;
// Data-safe guard (feat/safe-updates): the signature above
// is text matching — confirm the venv is actually broken
// (structural check + direct interpreter probe) before
// destroying it. A healthy venv is never deleted.
let structural = venv_structural_problem(&venv_dir);
let probe = venv_interpreter_probe(&venv_python_path(&venv_dir));
if venv_rebuild_justified(structural.as_deref(), probe) {
log::warn!(
"Backend exited with a broken-venv signature ({}; structural={:?}, probe={:?}) — removing {} and rebuilding (#314)",
exit_info,
structural,
probe,
venv_dir.display()
);
emit_log(
app,
"checking",
"Backend failed because the Python environment is broken — rebuilding it automatically",
);
if quarantine_broken_venv(&venv_dir) {
set_stage(stage_handle, BootstrapStage::Checking);
continue 'bootstrap;
}
log::error!(
"Could not remove broken venv at {} — surfacing the failure",
venv_dir.display()
);
} else {
log::warn!(
"Backend exit matched a broken-venv signature ({}) but the venv at {} probes healthy — keeping it (data-safe guard) and surfacing the real error",
exit_info,
venv_dir.display()
);
}
log::error!(
"Could not remove broken venv at {} — surfacing the failure",
venv_dir.display()
);
}
let msg = if err_tail.is_empty() {
format!("Backend process exited ({}) — no error output captured", exit_info)
@@ -273,12 +330,34 @@ pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<B
/// first to reach Ready claims this and the rest fall through.
static SUPERVISOR_ACTIVE: AtomicBool = AtomicBool::new(false);
/// Give up (surface Failed) if the backend dies this many times within
/// `RESTART_WINDOW` — a deterministic startup crash must not become a
/// fork-bomb. The #314 broken-venv self-heal stays the venv-failure path; the
/// #941: set while a retry/clean-retry flow deliberately kills the backend to
/// replace it, so the death watchers (startup poll + supervisor) never write a
/// crash marker for — or respawn against — an *intentional* kill. Cleared the
/// moment a fresh child is spawned and tracked (`track_backend_child`).
static BACKEND_KILL_INTENDED: AtomicBool = AtomicBool::new(false);
pub fn set_backend_kill_intended(value: bool) {
BACKEND_KILL_INTENDED.store(value, Ordering::SeqCst);
}
fn backend_kill_intended() -> bool {
BACKEND_KILL_INTENDED.load(Ordering::SeqCst)
}
/// How much of backend_err.log rides inside a crash marker (#941). ~40 lines
/// is enough for a Python traceback or a native abort banner without bloating
/// the marker file or the bug-report URL (the frontend truncates further).
const CRASH_STDERR_TAIL_LINES: usize = 40;
/// Crash-loop escalation guard (#941, supersedes the #567 5-in-60s budget):
/// give up (surface Failed with the crash details) once the backend has died
/// `MAX_RESTARTS` times inside `RESTART_WINDOW`. The longer 10-minute window
/// catches *slow* crash loops (e.g. an engine that OOMs a couple of minutes
/// into every generation) that the old 60-second window let spin silently
/// forever. The #314 broken-venv self-heal stays the venv-failure path; the
/// supervisor only handles post-Ready deaths.
const MAX_RESTARTS: usize = 5;
const RESTART_WINDOW: Duration = Duration::from_secs(60);
const MAX_RESTARTS: usize = 3;
const RESTART_WINDOW: Duration = Duration::from_secs(600);
fn app_is_quitting(app: &tauri::AppHandle) -> bool {
app.try_state::<AppFlags>()
@@ -286,17 +365,39 @@ fn app_is_quitting(app: &tauri::AppHandle) -> bool {
.unwrap_or(false)
}
/// Returns `Some(exit description)` if the tracked backend child has exited,
/// Store the freshly spawned backend child (and its spawn time, for the crash
/// marker's `uptime_s`), and re-arm the death watchers: any deliberate-kill
/// window ends the moment a new child is tracked.
fn track_backend_child(app: &tauri::AppHandle, child: Option<std::process::Child>) {
let state = app.state::<BackendState>();
if let Ok(mut guard) = state.process.lock() {
*guard = child;
}
if let Ok(mut spawned) = state.spawned_at.lock() {
*spawned = Some(Instant::now());
}
set_backend_kill_intended(false);
}
/// Seconds since the tracked backend child was spawned (0 when unknown).
fn backend_uptime_s(app: &tauri::AppHandle) -> u64 {
app.try_state::<BackendState>()
.and_then(|s| s.spawned_at.lock().ok().and_then(|g| *g))
.map(|t| t.elapsed().as_secs())
.unwrap_or(0)
}
/// Returns `Some(BackendExit)` if the tracked backend child has exited,
/// `None` if it is still running (or none is tracked — which we never treat as
/// a death to respawn, to avoid fighting a deliberate teardown).
fn backend_child_exit(app: &tauri::AppHandle) -> Option<String> {
fn backend_child_exit(app: &tauri::AppHandle) -> Option<BackendExit> {
let state = app.try_state::<BackendState>()?;
let mut guard = state.process.lock().ok()?;
match guard.as_mut() {
Some(child) => match child.try_wait() {
Ok(Some(status)) => Some(status.to_string()),
Ok(Some(status)) => Some(BackendExit::from_status(status)),
Ok(None) => None,
Err(e) => Some(format!("try_wait error: {e}")),
Err(e) => Some(BackendExit::unknown(&format!("try_wait error: {e}"))),
},
None => None,
}
@@ -323,21 +424,39 @@ fn supervise_backend(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<BootstrapS
if app_is_quitting(app) {
return;
}
let exit_info = match backend_child_exit(app) {
Some(info) => info,
let exit = match backend_child_exit(app) {
Some(exit) => exit,
None => continue, // still running
};
// The exit may have raced with a shutdown that killed the child.
if app_is_quitting(app) {
return;
}
// A retry/clean-retry flow killed the child on purpose and owns the
// respawn — no crash marker, and step aside so the retry's own
// spawn_backend_and_wait claims the supervisor slot at Ready (#941).
if backend_kill_intended() {
log::info!("Backend exit was a deliberate replace — supervisor yielding to the retry flow");
return;
}
let exit_info = exit.description.clone();
// #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.
let uptime_s = backend_uptime_s(app);
crate::crash::record_crash(crate::crash::marker_now(
&exit,
uptime_s,
crate::backend::read_error_log_tail(CRASH_STDERR_TAIL_LINES),
));
if restart_budget_exhausted(&mut restart_times, Instant::now()) {
let tail = crate::backend::read_error_log_tail(30);
let msg = format!(
"The backend kept crashing ({} times in {}s) and couldn't be kept running. \
Use Clean & Retry, or check Settings Logs Backend.{}",
"The backend kept crashing ({} times in {} min; last death: {}) and couldn't \
be kept running. Use Clean & Retry, or check Settings Logs Backend.{}",
MAX_RESTARTS,
RESTART_WINDOW.as_secs(),
RESTART_WINDOW.as_secs() / 60,
exit.label(),
if tail.is_empty() { String::new() } else { format!("\n\nLast output:\n{tail}") },
);
log::error!("Backend supervisor giving up: {msg}");
@@ -358,9 +477,7 @@ fn supervise_backend(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<BootstrapS
std::thread::sleep(Duration::from_millis(300));
}
let child = crate::backend::spawn_backend(app, Some(stage_handle));
if let Ok(mut guard) = app.state::<BackendState>().process.lock() {
*guard = child;
}
track_backend_child(app, child);
// Wait (bounded) for the respawn to become healthy. If it dies again
// immediately, bail early so the next loop counts it toward the cap.
let start = Instant::now();
@@ -395,6 +512,7 @@ pub fn clean_and_retry_bootstrap(app: tauri::AppHandle, state: tauri::State<'_,
// project dir, otherwise bootstrap will "attach" to the stale process.
if crate::backend::port_in_use(backend_port()) {
log::warn!("Clean retry: killing stale backend on port {}", backend_port());
set_backend_kill_intended(true); // deliberate kill, not a crash (#941)
crate::backend::kill_orphan_on_port(backend_port());
std::thread::sleep(Duration::from_millis(500));
}
@@ -451,6 +569,16 @@ fn refresh_project_manifests(resource_dir: &Path, project_dir: &Path) -> bool {
log::warn!("Could not refresh pyproject.toml from bundle: {}", e);
}
}
// Keep the shipped CHANGELOG.md current too — the backend's
// GET /api/settings/changelog (Settings → Updates "What's new" viewer)
// reads it from the project root, so an upgraded app must not show the
// notes from whenever the install was first created. Best-effort.
let res_changelog = res_root.join("CHANGELOG.md");
if res_changelog.is_file() {
if let Err(e) = fs::copy(&res_changelog, project_dir.join("CHANGELOG.md")) {
log::warn!("Could not refresh CHANGELOG.md from bundle: {}", e);
}
}
if !res_uvlock.is_file() {
return false;
}
@@ -503,6 +631,26 @@ Fix: install Python 3.11+ from https://www.python.org/downloads/ (tick \"Add to
then relaunch OmniVoice will use your system Python. Advanced: set \
UV_PYTHON_INSTALL_MIRROR to a reachable mirror (see docs/install/troubleshooting.md).";
/// #889: PyTorch stopped shipping macOS x86_64 wheels after 2.2.x, and the
/// locked dependency set needs a far newer torch (transformers 5.x requires
/// ≥2.6) — so `uv sync` can never resolve on an Intel Mac and the local
/// backend is unsupported there. Surfaced *before* any venv create/sync so
/// Intel-Mac users see this immediately instead of a raw resolver error after
/// minutes of downloads. Deliberately NOT checked when a healthy venv already
/// exists, so any pre-torch-bump install that still works keeps working.
const INTEL_MAC_UNSUPPORTED_MSG: &str =
"Intel Macs can't run the local AI backend — PyTorch no longer ships Intel-Mac (macOS x86_64) \
builds, so the Python environment can't be installed on this machine. The app UI works, but local \
voice generation is unavailable here. Options: point the app at a remote backend running on \
another machine (Settings Sharing Remote backend), or use an Apple Silicon Mac / Windows / \
Linux. See docs/install/macos.md (#889).";
/// True on macOS x86_64 builds (#889). `cfg!` (not `#[cfg]`) keeps the guard
/// compiled — and the message testable — on every platform.
fn intel_mac_backend_unsupported() -> bool {
cfg!(all(target_os = "macos", target_arch = "x86_64"))
}
/// Strip the bundled-runtime Python env vars before spawning any `uv`/venv/pip
/// or venv-python subprocess (#144). On the Linux AppImage, the bundled runtime
/// exports PYTHONHOME / PYTHONPATH (and sometimes LD_LIBRARY_PATH) pointing at
@@ -556,9 +704,14 @@ fn sync_failure_is_torch_download(tail: &str) -> bool {
|| (low.contains("torch") && (low.contains("failed to download") || low.contains("failed to fetch")))
}
/// Default PyTorch ROCm wheel index for the opt-in AMD path (#124). ROCm 6.2 is
/// the current stable wheel set; overridable via OMNIVOICE_TORCH_INDEX.
const ROCM_TORCH_INDEX: &str = "https://download.pytorch.org/whl/rocm6.2";
/// Default PyTorch ROCm wheel index for the opt-in AMD path (#124).
/// ROCm 6.4, not 6.2: the app's pinned `torch==2.8.0` (pyproject.toml) has no
/// build on the rocm6.2 index (it tops out at 2.5.1), so that index silently
/// failed the reinstall and left the default CUDA build in place — which runs
/// on CPU on an AMD GPU (#972). rocm6.4 carries a matching 2.8.0 build.
/// Overridable via OMNIVOICE_TORCH_INDEX (e.g. a `--find-links` URL for
/// distro-matched ROCm builds torch's own index doesn't carry).
const ROCM_TORCH_INDEX: &str = "https://download.pytorch.org/whl/rocm6.4";
/// `uv pip install` args that replace the default CUDA torch build with the AMD
/// ROCm wheel (#124). Opt-in (gated on OMNIVOICE_TORCH_VARIANT=rocm by the
@@ -680,6 +833,252 @@ pub fn backend_exit_indicates_broken_venv(exit_info: &str, err_tail: &str) -> bo
|| exit_info.trim_end().ends_with(": 106")
}
/// Data-safe guard for the destructive half of the #314 self-heal
/// (feat/safe-updates): an exit-*signature* match alone is text matching on a
/// stderr tail — before it is allowed to delete a multi-GB venv, the venv must
/// be *confirmed* broken by direct evidence:
///
/// - a structural problem found by [`venv_structural_problem`] (missing
/// pyvenv.cfg / missing or dangling python) is definitive → rebuild;
/// - otherwise the venv's own interpreter is probed
/// ([`venv_interpreter_probe`]): if it provably starts and imports its
/// stdlib (`Some(true)`), the venv is NOT the problem — deleting it would
/// destroy a working ~6 GB install to "fix" an unrelated crash, so the
/// rebuild is refused and the real error is surfaced instead;
/// - a failed probe (`Some(false)`) or one that couldn't even spawn (`None`)
/// confirms the interpreter is unrunnable → rebuild.
pub fn venv_rebuild_justified(
structural_problem: Option<&str>,
interpreter_probe: Option<bool>,
) -> bool {
if structural_problem.is_some() {
return true;
}
!matches!(interpreter_probe, Some(true))
}
/// Run the venv's python directly to check the interpreter can bootstrap its
/// stdlib. `Some(true)` = healthy, `Some(false)` = starts but fails (e.g. the
/// venv launcher's exit 106, or the 'encodings' bootstrap abort), `None` = the
/// binary couldn't be spawned at all. Env is scrubbed (#144) so an AppImage's
/// bundled-Python vars can't fake a failure on a healthy venv.
fn venv_interpreter_probe(venv_py: &Path) -> Option<bool> {
let mut cmd = Command::new(venv_py);
scrub_python_env(&mut cmd);
cmd.args(["-c", "import encodings"])
.stdout(Stdio::null())
.stderr(Stdio::null());
match cmd.status() {
Ok(status) => Some(status.success()),
Err(_) => None,
}
}
// ── Linux/Windows: cuDNN 8 compat side-load ────────────────────────────────
//
// This used to live ONLY in scripts/setup.py, run via `bun run setup:api`
// (dev loop only). Neither `scripts/` nor `setup.py` is bundled as a Tauri
// resource (see tauri.conf.json's `bundle.resources`), and the real
// packaged-install bootstrap path below never called that script — so every
// actual installed user with an NVIDIA GPU got a venv with no cuDNN 8 compat
// libs (#827). Ported here so the real app-data venv gets them, matching what
// backend/main.py's cuDNN preload (#255) expects to find.
//
// (An earlier draft of #869 also ported setup.py's VC++ Redistributable
// check. Dropped as dead code per review: the Tauri exe itself dynamically
// links the MSVC CRT, so `LoadLibraryA("vcruntime140.dll")` from a *running*
// app is a tautology — and torch's real failure mode is msvcp140.dll inside
// the venv python process, not this one.)
/// Cross-platform pin, matches the wheel scripts/setup.py has always used —
/// keep both in sync if this ever needs to move.
const CUDNN8_COMPAT_PIN: &str = "nvidia-cudnn-cu12==8.9.7.29";
/// The `cudnn8_compat/` install target inside a venv's site-packages,
/// mirroring `_find_compat_dir()` in scripts/setup.py exactly (and what
/// backend/main.py's ctypes preload looks for). Linux's path is versioned by
/// the venv's own Python (`lib/pythonX.Y/site-packages`), so this queries the
/// live interpreter rather than assuming the version `uv venv` was asked for
/// — the system-Python fallback path can hand back a different one.
fn cudnn8_compat_dir(venv_dir: &Path, venv_py: &Path) -> Option<PathBuf> {
if cfg!(windows) {
return Some(venv_dir.join("Lib").join("site-packages").join("cudnn8_compat"));
}
let out = Command::new(venv_py)
.args(["-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let pyver = String::from_utf8_lossy(&out.stdout).trim().to_string();
Some(
venv_dir
.join("lib")
.join(format!("python{}", pyver))
.join("site-packages")
.join("cudnn8_compat"),
)
}
/// The subdirectory (within `cudnn8_compat/`) actually holding the shared
/// libraries, and the filename pattern that counts as "installed" — same
/// glob scripts/setup.py's `_count_cudnn8_libs()` uses.
fn cudnn8_lib_dir_and_pattern(compat_dir: &Path) -> (PathBuf, &'static str, &'static str) {
if cfg!(windows) {
(compat_dir.join("nvidia").join("cudnn").join("bin"), "cudnn", "64_8.dll")
} else {
(compat_dir.join("nvidia").join("cudnn").join("lib"), "libcudnn", ".so.8")
}
}
fn count_cudnn8_libs(lib_dir: &Path, prefix: &str, suffix: &str) -> usize {
fs::read_dir(lib_dir)
.map(|entries| {
entries
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name();
let name = name.to_string_lossy();
name.starts_with(prefix) && name.ends_with(suffix)
})
.count()
})
.unwrap_or(0)
}
/// Verdict from probing the venv's torch (see `CUDNN8_CUDA_PROBE_PY`).
#[derive(Debug, PartialEq, Eq)]
enum CudnnProbe {
/// CUDA torch build with a live CUDA device: side-load cuDNN 8.
Install,
/// Definitive no — CPU-only box, no NVIDIA device, or a ROCm torch build
/// (HIP reports `torch.cuda.is_available() == True`, but the ~700 MB CUDA
/// `nvidia-cudnn-cu12` wheel is pure waste on an AMD box, #124). Cache it
/// so the synchronous `import torch` never taxes this venv's launches
/// again.
CacheNegative,
/// The probe didn't run cleanly (torch missing / broken venv / unexpected
/// output) — skip this launch but do NOT cache, so a transient failure
/// can't permanently disable the side-load on a real CUDA machine.
SkipNoCache,
}
/// Prints exactly one verdict: `hip` (ROCm build — checked BEFORE
/// `cuda.is_available()`, which HIP spoofs), `cuda` (CUDA build with a live
/// device), or `none`.
const CUDNN8_CUDA_PROBE_PY: &str = "import torch; print('hip' if getattr(torch.version, 'hip', None) else 'cuda' if torch.cuda.is_available() else 'none')";
fn classify_cuda_probe(stdout: &str) -> CudnnProbe {
match stdout.trim() {
"cuda" => CudnnProbe::Install,
"hip" | "none" => CudnnProbe::CacheNegative,
_ => CudnnProbe::SkipNoCache,
}
}
/// Marker recording a cached negative CUDA probe for this venv. Lives inside
/// `.venv/` so a full venv rebuild ("Clean & Retry") clears it implicitly;
/// anything that re-syncs the venv in place must call
/// `invalidate_cudnn8_probe_cache` (the torch build may have changed).
fn cudnn8_probe_marker(venv_dir: &Path) -> PathBuf {
venv_dir.join(".cudnn8_probe_negative")
}
/// Call after ANY operation that can change the venv's torch build (drift /
/// repair / first-run `uv sync`, ROCm reinstall) so the next launch re-probes
/// exactly once per venv lifetime.
fn invalidate_cudnn8_probe_cache(venv_dir: &Path) {
let _ = fs::remove_file(cudnn8_probe_marker(venv_dir));
}
/// CTranslate2 (faster-whisper / WhisperX) needs cuDNN 8, but PyTorch 2.8+
/// pulls in cuDNN 9. Side-loads cuDNN 8 into `cudnn8_compat/` next to the
/// venv's other packages — backend/main.py preloads it via ctypes at import
/// time (#255). Skipped entirely on macOS (no CUDA), on any machine without
/// a CUDA device, and on ROCm torch builds (#124) — and a negative probe is
/// cached per venv so CPU/AMD installs never pay the synchronous
/// `import torch` more than once (#869 review).
fn ensure_cudnn8_compat<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
uv_path: &Path,
venv_py: &Path,
venv_dir: &Path,
project_dir: &Path,
) {
if cfg!(target_os = "macos") {
return;
}
// Cached negative from a previous launch (CPU/Intel/AMD — the majority of
// installs): return before spending any subprocess. Cleared whenever the
// venv is rebuilt or re-synced.
let marker = cudnn8_probe_marker(venv_dir);
if marker.is_file() {
return;
}
let Some(compat_dir) = cudnn8_compat_dir(venv_dir, venv_py) else {
log::warn!("cuDNN 8 compat: could not resolve venv site-packages layout — skipping");
return;
};
let (lib_dir, prefix, suffix) = cudnn8_lib_dir_and_pattern(&compat_dir);
if count_cudnn8_libs(&lib_dir, prefix, suffix) >= 5 {
return;
}
let mut cuda_check = Command::new(venv_py);
scrub_python_env(&mut cuda_check);
let verdict = cuda_check
.args(["-c", CUDNN8_CUDA_PROBE_PY])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
match classify_cuda_probe(&verdict) {
CudnnProbe::Install => {}
CudnnProbe::CacheNegative => {
log::info!(
"cuDNN 8 compat: torch probe says '{}' — caching the negative result for this venv",
verdict
);
let _ = fs::write(&marker, format!("{}\n", verdict));
return;
}
CudnnProbe::SkipNoCache => {
log::warn!("cuDNN 8 compat: torch probe failed — skipping this launch (not cached)");
return;
}
}
log::info!("Installing cuDNN 8 compatibility libraries for CTranslate2 (#255)");
emit_log(app, "installing_deps", "Installing cuDNN 8 compatibility libraries for CUDA transcription…");
let mut cmd = Command::new(uv_path);
scrub_python_env(&mut cmd);
apply_uv_http_env(&mut cmd);
cmd.arg("pip")
.arg("install")
.arg("--no-deps")
.arg("--target")
.arg(&compat_dir)
.arg("--python")
.arg(venv_py)
.arg(CUDNN8_COMPAT_PIN)
.current_dir(project_dir);
match run_streaming(app, "installing_deps", &mut cmd) {
Ok(ref s) if s.success() => {
log::info!("cuDNN 8 compat installed: {} libraries", count_cudnn8_libs(&lib_dir, prefix, suffix));
}
other => {
log::warn!("cuDNN 8 compat install failed ({:?}) — CUDA transcription may not work", other);
emit_log(
app, "installing_deps",
"cuDNN 8 compat install failed — CUDA-based transcription may not work. \
Retry from Settings, or see docs/install/troubleshooting.md.",
);
}
}
}
/// Prepare (and on first run, create) the Python venv that will host the
/// backend process. Returns (venv_python, backend_source_dir).
pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Option<&Arc<Mutex<BootstrapStage>>>) -> Option<(PathBuf, PathBuf)> {
@@ -829,6 +1228,15 @@ manually, then relaunch.",
// #307: the source dirs above track the bundle, so the
// dependency manifests must too — otherwise an upgrade runs
// new code against a venv that predates newly added deps.
//
// Data-safety note (feat/safe-updates): this drift path — and
// the repair path below — reconcile the venv IN PLACE via
// `uv sync` (add/remove packages inside `.venv`); neither ever
// deletes the venv, and a failed sync keeps the old venv (see
// the error arm). The only venv-destroying paths are the #314
// broken-venv heal (guarded by venv_rebuild_justified: a venv
// whose interpreter probes healthy is never deleted) and the
// explicit user-initiated "Clean & Retry".
if refresh_project_manifests(res, &project_dir) {
log::info!("uv.lock changed since the venv was synced — running uv sync (#307)");
if let Some(p) = progress {
@@ -851,6 +1259,9 @@ manually, then relaunch.",
match run_streaming(app, "installing_deps", &mut drift_cmd) {
Ok(ref s) if s.success() => {
log::info!("Dependency drift sync complete (#307)");
// The torch build may have changed — let
// ensure_cudnn8_compat() re-probe once.
invalidate_cudnn8_probe_cache(&venv_dir);
}
other => {
// Don't brick a previously-working install
@@ -870,6 +1281,10 @@ the existing venv; newly added dependencies may be missing (#307)",
}
}
}
match resolve_uv(app, &app_data, None) {
Ok(uv_path) => ensure_cudnn8_compat(app, &uv_path, &venv_py, &venv_dir, &project_dir),
Err(e) => log::warn!("cuDNN 8 compat: could not resolve uv: {}", e),
}
return Some((venv_py, backend_dir));
}
if matches!(uvicorn_check, Ok(ref s) if s.success()) {
@@ -891,6 +1306,12 @@ the existing venv; newly added dependencies may be missing (#307)",
venv_dir.display()
);
}
// #889: a repair sync on an Intel Mac would just re-fail on the torch
// resolution — surface the real reason instead of the raw uv error.
if intel_mac_backend_unsupported() {
fail(progress, INTEL_MAC_UNSUPPORTED_MSG);
return None;
}
if let Some(p) = progress {
set_stage(p, BootstrapStage::InstallingDeps);
}
@@ -915,6 +1336,10 @@ the existing venv; newly added dependencies may be missing (#307)",
repair_cmd.current_dir(&project_dir);
let repair_status = run_streaming(app, "installing_deps", &mut repair_cmd);
if matches!(repair_status, Ok(ref s) if s.success()) {
// The repair sync may have changed the torch build — clear any
// cached negative CUDA probe so ensure_cudnn8_compat() below
// re-checks once.
invalidate_cudnn8_probe_cache(&venv_dir);
// #248: after the repair sync, ensure pkg_resources landed. The repair
// path is also triggered when pkg_resources is missing (see above), so
// we must verify here rather than trusting that uv sync alone fixed it
@@ -980,20 +1405,30 @@ the existing venv; newly added dependencies may be missing (#307)",
return None;
}
}
ensure_cudnn8_compat(app, &uv_path, &venv_py, &venv_dir, &project_dir);
return Some((venv_py, backend_dir));
}
fail(progress, &format!("Repair uv sync failed: {:?}", repair_status));
return None;
}
// #889: pre-check before creating a venv or attempting any `uv sync`. A
// first-run install on an Intel Mac can only ever end in an unresolvable
// torch dependency, so fail fast with the honest message — before any
// download starts.
if intel_mac_backend_unsupported() {
fail(progress, INTEL_MAC_UNSUPPORTED_MSG);
return None;
}
let resource_dir = app.path().resource_dir().ok()?;
let flat = resource_dir.clone();
let up2 = resource_dir.join("_up_").join("_up_");
let (resource_pyproject, resource_uvlock, resource_readme, resource_omnivoice, resource_backend) = if flat.join("pyproject.toml").is_file() {
(flat.join("pyproject.toml"), flat.join("uv.lock"), flat.join("README.md"), flat.join("omnivoice"), flat.join("backend"))
let (resource_pyproject, resource_uvlock, resource_readme, resource_changelog, resource_omnivoice, resource_backend) = if flat.join("pyproject.toml").is_file() {
(flat.join("pyproject.toml"), flat.join("uv.lock"), flat.join("README.md"), flat.join("CHANGELOG.md"), flat.join("omnivoice"), flat.join("backend"))
} else if up2.join("pyproject.toml").is_file() {
(up2.join("pyproject.toml"), up2.join("uv.lock"), up2.join("README.md"), up2.join("omnivoice"), up2.join("backend"))
(up2.join("pyproject.toml"), up2.join("uv.lock"), up2.join("README.md"), up2.join("CHANGELOG.md"), up2.join("omnivoice"), up2.join("backend"))
} else {
fail(progress, &format!(
"Missing bootstrap resources — checked flat={} and _up_={}",
@@ -1030,6 +1465,12 @@ the existing venv; newly added dependencies may be missing (#307)",
let _ = fs::write(project_dir.join("README.md"), "# OmniVoice\n");
log::warn!("No README.md in bundle — created stub");
}
// Shipped release notes for the Settings → Updates "What's new" viewer
// (GET /api/settings/changelog). Optional: the endpoint degrades to
// `available: false` when absent.
if resource_changelog.is_file() {
let _ = fs::copy(&resource_changelog, project_dir.join("CHANGELOG.md"));
}
let omnivoice_dir = project_dir.join("omnivoice");
if resource_omnivoice.is_dir() {
if let Err(e) = copy_dir_recursive(&resource_omnivoice, &omnivoice_dir) {
@@ -1231,6 +1672,11 @@ mirror in Settings → region/mirrors (see docs/install/troubleshooting.md).".to
}
}
// Fresh venv, fresh sync: a stale negative-probe marker (e.g. a venv
// recreated in place over a previous one) must not suppress the probe.
invalidate_cudnn8_probe_cache(&venv_dir);
ensure_cudnn8_compat(app, &uv_path, &venv_py, &venv_dir, &project_dir);
// Opt-in AMD ROCm (#124): the default install ships the CUDA torch build,
// so AMD-only machines fall back to CPU. If the user set
// OMNIVOICE_TORCH_VARIANT=rocm, reinstall torch/torchaudio from the ROCm
@@ -1243,7 +1689,12 @@ mirror in Settings → region/mirrors (see docs/install/troubleshooting.md).".to
apply_uv_http_env(&mut rocm_cmd);
rocm_cmd.args(rocm_torch_reinstall_args(&rocm_url)).current_dir(&project_dir);
let rocm_status = run_streaming(app, "installing_deps", &mut rocm_cmd);
if !matches!(rocm_status, Ok(ref s) if s.success()) {
if matches!(rocm_status, Ok(ref s) if s.success()) {
// The torch build just switched to ROCm: re-probe on the next
// launch (it reports 'hip' and re-caches the negative, so the
// CUDA cuDNN wheel is never fetched on an AMD box, #124).
invalidate_cudnn8_probe_cache(&venv_dir);
} else {
log::warn!("ROCm torch reinstall failed ({:?}); keeping default torch build", rocm_status);
emit_log(
app, "installing_deps",
@@ -1278,6 +1729,18 @@ mod tests {
assert!(removed.contains("LD_LIBRARY_PATH"), "LD_LIBRARY_PATH must be scrubbed");
}
#[test]
fn intel_mac_message_keeps_its_contract_phrases() {
// #889: BootstrapSplash.jsx routes this failure to the localized
// `bootstrap.hint_intel_mac` hint by matching the lead phrase, and the
// message must keep pointing users at the docs + the remote-backend
// escape hatch. Guard those load-bearing fragments against rewording.
assert!(INTEL_MAC_UNSUPPORTED_MSG.contains("Intel Macs can't run the local AI backend"));
assert!(INTEL_MAC_UNSUPPORTED_MSG.contains("docs/install/macos.md"));
assert!(INTEL_MAC_UNSUPPORTED_MSG.contains("Sharing → Remote backend"));
assert!(INTEL_MAC_UNSUPPORTED_MSG.contains("#889"));
}
#[test]
fn apply_uv_http_env_sets_timeouts_and_retries() {
let mut cmd = Command::new("uv");
@@ -1293,6 +1756,15 @@ mod tests {
assert_eq!(envs.get("UV_HTTP_RETRIES").map(String::as_str), Some("5"));
}
#[test]
fn crash_loop_policy_is_three_deaths_in_ten_minutes() {
// #941 escalation guard: ≥3 crashes inside 10 min must stop the
// respawn loop and land on the Failed screen with the crash details —
// the old 5-in-60s budget let slow crash loops spin silently forever.
assert_eq!(MAX_RESTARTS, 3);
assert_eq!(RESTART_WINDOW, Duration::from_secs(600));
}
#[test]
fn restart_budget_caps_respawns_and_prunes_old_ones() {
// Supervisor backoff policy (#567): fewer than MAX_RESTARTS deaths
@@ -1350,7 +1822,10 @@ mod tests {
assert!(args.iter().any(|a| a == "torch"));
assert!(args.iter().any(|a| a == "torchaudio"));
let i = args.iter().position(|a| a == "--index-url").expect("has --index-url");
assert!(args[i + 1].contains("rocm6.2"), "default index is the rocm6.2 wheel set");
// rocm6.4, not rocm6.2: rocm6.2's index tops out at torch 2.5.1 and
// can't satisfy the app's torch==2.8.0 pin (#972) — a regression to
// rocm6.2 here would silently resurrect the CPU-fallback bug.
assert!(args[i + 1].contains("rocm6.4"), "default index is the rocm6.4 wheel set (matches torch==2.8.0)");
}
#[test]
@@ -1513,6 +1988,42 @@ mod tests {
));
}
#[test]
fn venv_rebuild_requires_confirmed_breakage() {
// feat/safe-updates: an exit-signature match alone must not destroy a
// venv. A structural problem is definitive evidence → rebuild.
assert!(venv_rebuild_justified(Some("pyvenv.cfg is missing"), Some(true)));
assert!(venv_rebuild_justified(Some("python executable is missing"), None));
// No structural problem + interpreter provably healthy → NEVER delete
// (the data-safety property this guard exists for).
assert!(!venv_rebuild_justified(None, Some(true)));
// Interpreter starts but can't bootstrap (exit 106 / encodings abort)
// → confirmed broken → rebuild.
assert!(venv_rebuild_justified(None, Some(false)));
// Interpreter can't even be spawned → confirmed unrunnable → rebuild.
assert!(venv_rebuild_justified(None, None));
}
#[cfg(unix)]
#[test]
fn venv_interpreter_probe_maps_exit_status_and_spawn_failure() {
use std::os::unix::fs::PermissionsExt;
// A nonexistent binary can't spawn → None (still justifies a rebuild).
let missing = std::env::temp_dir().join("omnivoice-test-probe-missing-python");
assert_eq!(venv_interpreter_probe(&missing), None);
// Fake interpreters (exit 0 = healthy, exit 106 = the venv launcher's
// "No pyvenv.cfg" code) exercise the status mapping without needing a
// real python on the test runner.
let dir = temp_venv_dir("probe");
for (name, code, expected) in [("py-ok", 0, Some(true)), ("py-106", 106, Some(false))] {
let script = dir.join(name);
fs::write(&script, format!("#!/bin/sh\nexit {}\n", code)).unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(venv_interpreter_probe(&script), expected, "{}", name);
}
let _ = fs::remove_dir_all(&dir);
}
/// #248: verify that the setuptools repair install uses the correct specifier.
/// The specifier `"setuptools>=75,<80"` must be passed as a single argument so
/// pip/uv interprets the range constraint as one requirement, not two.
@@ -1549,4 +2060,101 @@ mod tests {
let v82: (u32, u32) = (82, 0);
assert!(!(v82.0 >= 75 && v82.0 < 80), "82.x (pre-fix version) must NOT satisfy <80");
}
// -- cuDNN 8 compat side-load (real prod bootstrap, not just dev) --------
#[cfg(windows)]
#[test]
fn cudnn8_compat_dir_matches_backend_main_py_layout() {
// backend/main.py hardcodes `.venv/Lib/site-packages/cudnn8_compat` on
// Windows (no pyver in the path) -- this must match exactly or the
// ctypes preload never finds what we just installed.
let venv_dir = PathBuf::from(r"C:\fake\project\.venv");
let venv_py = venv_python_path(&venv_dir);
let dir = cudnn8_compat_dir(&venv_dir, &venv_py).expect("windows path is pure, no subprocess needed");
assert_eq!(dir, venv_dir.join("Lib").join("site-packages").join("cudnn8_compat"));
}
#[test]
fn cudnn8_lib_dir_and_pattern_matches_platform_glob() {
// Mirrors scripts/setup.py's _cudnn8_lib_dir()/_count_cudnn8_libs() and
// backend/main.py's _cudnn8_glob exactly -- a divergence here means the
// Rust installer and the Python ctypes preload disagree on what counts
// as "installed".
let compat_dir = PathBuf::from("compat");
let (lib_dir, prefix, suffix) = cudnn8_lib_dir_and_pattern(&compat_dir);
if cfg!(windows) {
assert_eq!(lib_dir, compat_dir.join("nvidia").join("cudnn").join("bin"));
assert_eq!((prefix, suffix), ("cudnn", "64_8.dll"));
assert!("cudnn_ops64_8.dll".starts_with(prefix) && "cudnn_ops64_8.dll".ends_with(suffix));
} else {
assert_eq!(lib_dir, compat_dir.join("nvidia").join("cudnn").join("lib"));
assert_eq!((prefix, suffix), ("libcudnn", ".so.8"));
assert!("libcudnn_ops.so.8".starts_with(prefix) && "libcudnn_ops.so.8".ends_with(suffix));
}
}
#[test]
fn count_cudnn8_libs_counts_only_matching_files() {
let dir = temp_venv_dir("cudnn-count");
let (_, prefix, suffix) = cudnn8_lib_dir_and_pattern(Path::new(""));
// Two real matches...
fs::write(dir.join(format!("{prefix}_a{suffix}")), b"").unwrap();
fs::write(dir.join(format!("{prefix}_b{suffix}")), b"").unwrap();
// ...one file that only matches the prefix, one that only matches the
// suffix, and one totally unrelated file -- none of these should count.
fs::write(dir.join(format!("{prefix}_only_prefix.txt")), b"").unwrap();
fs::write(dir.join(format!("unrelated{suffix}")), b"").unwrap();
fs::write(dir.join("readme.md"), b"").unwrap();
assert_eq!(count_cudnn8_libs(&dir, prefix, suffix), 2);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn count_cudnn8_libs_zero_when_dir_missing() {
// First-run case: the compat dir doesn't exist yet -- must report 0,
// not error, so the caller's ">= 5" threshold cleanly triggers install.
let missing = std::env::temp_dir().join("omnivoice-test-cudnn8-does-not-exist");
let _ = fs::remove_dir_all(&missing);
assert_eq!(count_cudnn8_libs(&missing, "cudnn", "64_8.dll"), 0);
}
#[test]
fn classify_cuda_probe_gates_install_on_cuda_only() {
// 'cuda' (CUDA build + live device) is the ONLY verdict that triggers
// the ~700 MB nvidia-cudnn-cu12 download.
assert_eq!(classify_cuda_probe("cuda"), CudnnProbe::Install);
assert_eq!(classify_cuda_probe("cuda\n"), CudnnProbe::Install); // print() newline
// ROCm torch spoofs torch.cuda.is_available(); the probe reports
// 'hip' first so opt-in AMD installs (#124) never fetch the CUDA
// wheel -- and the negative is cacheable.
assert_eq!(classify_cuda_probe("hip\n"), CudnnProbe::CacheNegative);
// Plain no-CUDA box: cache so `import torch` never re-runs at launch.
assert_eq!(classify_cuda_probe("none"), CudnnProbe::CacheNegative);
// Broken venv / import error / garbage: skip this launch but never
// cache -- a transient failure must not wedge a real CUDA machine.
assert_eq!(classify_cuda_probe(""), CudnnProbe::SkipNoCache);
assert_eq!(
classify_cuda_probe("Traceback (most recent call last):"),
CudnnProbe::SkipNoCache
);
}
#[test]
fn cudnn8_probe_cache_marker_roundtrip() {
let venv_dir = temp_venv_dir("cudnn-probe-cache");
let marker = cudnn8_probe_marker(&venv_dir);
// Must live INSIDE the venv so a full rebuild clears it implicitly.
assert!(marker.starts_with(&venv_dir));
assert!(!marker.is_file());
fs::write(&marker, "none\n").unwrap();
assert!(marker.is_file());
// Re-sync invalidation: marker gone, next launch re-probes.
invalidate_cudnn8_probe_cache(&venv_dir);
assert!(!marker.is_file());
// Idempotent when the marker is already absent.
invalidate_cudnn8_probe_cache(&venv_dir);
assert!(!marker.is_file());
let _ = fs::remove_dir_all(&venv_dir);
}
}
+232 -13
View File
@@ -257,43 +257,121 @@ fn hf_hub_cache_dir() -> PathBuf {
use enigo::{Direction, Enigo, Key, Keyboard, Settings as EnigoSettings};
/// Error-kind builder the dictation widget switches on. Kinds are a plain
/// string prefix ("a11y:" | "clipboard:" | "paste:") so the JS side can do
/// `err.split(':')[0]` without a serde enum crossing the IPC boundary.
fn kind_err(kind: &str, detail: impl std::fmt::Display) -> String {
format!("{kind}:{detail}")
}
/// How long the transcript must sit on the clipboard before the user's
/// previous clipboard is restored: ~300ms covers slow paste consumers
/// (Electron apps, remote desktops) without being user-noticeable.
const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(300);
/// macOS Accessibility grant check — CGEvent key synthesis silently no-ops
/// without it. Direct FFI against ApplicationServices: one symbol, not worth
/// a crate.
#[cfg(target_os = "macos")]
fn accessibility_trusted() -> bool {
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXIsProcessTrusted() -> bool;
}
unsafe { AXIsProcessTrusted() }
}
/// True when the app may synthesize keyboard input. On macOS this is the
/// Accessibility grant (System Settings → Privacy & Security → Accessibility);
/// other OSes don't gate synthetic input behind a permission, so always true.
#[tauri::command]
pub fn check_accessibility() -> bool {
#[cfg(target_os = "macos")]
{
accessibility_trusted()
}
#[cfg(not(target_os = "macos"))]
{
true
}
}
/// Deep-link into the macOS Privacy → Accessibility pane so the widget can
/// walk the user straight to the toggle an "a11y:" error asked for. No-op on
/// other OSes (nothing to grant there).
#[tauri::command]
pub fn open_accessibility_settings() {
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("open")
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
.spawn();
}
}
#[tauri::command]
pub fn simulate_paste(text: Option<String>) -> Result<(), String> {
// macOS: fail loud BEFORE touching the clipboard if Accessibility isn't
// granted — otherwise the ⌘V below silently goes nowhere and the caller
// can't tell (the old fire-and-forget behavior).
#[cfg(target_os = "macos")]
if !accessibility_trusted() {
return Err(kind_err("a11y", "accessibility permission not granted"));
}
// Write the transcript to the clipboard natively first: the widget window
// is intentionally unfocused on macOS (so the simulated ⌘V reaches the
// target app), which makes the WebView clipboard APIs (navigator.clipboard
// / execCommand('copy')) fail silently there (#287). `text` is optional so
// call sites that already populated the clipboard keep working.
//
// Save what the user had there first (text only — restoring images/files
// isn't worth the platform-specific surface) so dictation doesn't clobber
// their clipboard.
let mut saved: Option<String> = None;
if let Some(t) = text {
let mut cb = arboard::Clipboard::new()
.map_err(|e| format!("clipboard init failed: {e}"))?;
.map_err(|e| kind_err("clipboard", format!("init failed: {e}")))?;
saved = cb.get_text().ok();
cb.set_text(t)
.map_err(|e| format!("clipboard write failed: {e}"))?;
.map_err(|e| kind_err("clipboard", format!("write failed: {e}")))?;
}
std::thread::sleep(Duration::from_millis(80));
let mut enigo = Enigo::new(&EnigoSettings::default())
.map_err(|e| format!("Failed to init keyboard sim: {e}"))?;
.map_err(|e| kind_err("paste", format!("failed to init keyboard sim: {e}")))?;
#[cfg(target_os = "macos")]
{
enigo.key(Key::Meta, Direction::Press)
.map_err(|e| format!("key press failed: {e}"))?;
.map_err(|e| kind_err("paste", format!("key press failed: {e}")))?;
enigo.key(Key::Unicode('v'), Direction::Click)
.map_err(|e| format!("key click failed: {e}"))?;
.map_err(|e| kind_err("paste", format!("key click failed: {e}")))?;
enigo.key(Key::Meta, Direction::Release)
.map_err(|e| format!("key release failed: {e}"))?;
.map_err(|e| kind_err("paste", format!("key release failed: {e}")))?;
}
#[cfg(not(target_os = "macos"))]
{
enigo.key(Key::Control, Direction::Press)
.map_err(|e| format!("key press failed: {e}"))?;
.map_err(|e| kind_err("paste", format!("key press failed: {e}")))?;
enigo.key(Key::Unicode('v'), Direction::Click)
.map_err(|e| format!("key click failed: {e}"))?;
.map_err(|e| kind_err("paste", format!("key click failed: {e}")))?;
enigo.key(Key::Control, Direction::Release)
.map_err(|e| format!("key release failed: {e}"))?;
.map_err(|e| kind_err("paste", format!("key release failed: {e}")))?;
}
// Best-effort restore of the user's clipboard once the target app has
// consumed the paste. Only on success — on a paste error the transcript
// stays on the clipboard so the user can ⌘V it manually as a fallback.
if let Some(prev) = saved {
std::thread::spawn(move || {
std::thread::sleep(CLIPBOARD_RESTORE_DELAY);
if let Ok(mut cb) = arboard::Clipboard::new() {
let _ = cb.set_text(prev);
}
});
}
Ok(())
@@ -316,24 +394,32 @@ pub fn simulate_paste(text: Option<String>) -> Result<(), String> {
///
/// Returns `Err` if the input layer is unavailable (e.g. accessibility not
/// granted) so the JS caller can fall back to the clipboard+paste path for
/// that segment without double-inserting.
/// that segment without double-inserting. Errors carry the same kind
/// prefixes as `simulate_paste` ("a11y:" | "paste:").
#[tauri::command]
pub fn simulate_type(text: Option<String>, backspaces: Option<u32>) -> Result<(), String> {
// Same a11y gate as simulate_paste — `.text()`/`.key()` go through the
// identical CGEvent path on macOS and would silently no-op without it.
#[cfg(target_os = "macos")]
if !accessibility_trusted() {
return Err(kind_err("a11y", "accessibility permission not granted"));
}
let mut enigo = Enigo::new(&EnigoSettings::default())
.map_err(|e| format!("Failed to init keyboard sim: {e}"))?;
.map_err(|e| kind_err("paste", format!("failed to init keyboard sim: {e}")))?;
let n = backspaces.unwrap_or(0);
for _ in 0..n {
enigo
.key(Key::Backspace, Direction::Click)
.map_err(|e| format!("backspace failed: {e}"))?;
.map_err(|e| kind_err("paste", format!("backspace failed: {e}")))?;
}
if let Some(t) = text {
if !t.is_empty() {
enigo
.text(&t)
.map_err(|e| format!("type failed: {e}"))?;
.map_err(|e| kind_err("paste", format!("type failed: {e}")))?;
}
}
@@ -441,3 +527,136 @@ pub fn save_text_file(path: String, contents: String) -> Result<(), String> {
}
std::fs::write(p, contents).map_err(|e| format!("write: {e}"))
}
// ── WebView cache repair (issue #879) ─────────────────────────────────────
//
// After an unclean shutdown (e.g. a Windows BSOD), WebView2's profile cache
// (%LOCALAPPDATA%\<identifier>\EBWebView) can corrupt. Tauri's IPC custom
// protocol then fails ("IPC custom protocol failed, Tauri will now use the
// postMessage interface instead") and the postMessage fallback can break too,
// so the splash never hears bootstrap events even with a healthy backend.
// The splash's recovery panel (Windows-only affordance, error-state only)
// calls `clear_webview_cache_and_relaunch` to fix it in one click.
//
// Deleting EBWebView from inside a running app fails — the WebView2 browser
// processes hold locks on the profile — so this is a two-step dance:
// 1. the command writes a marker file next to the cache and relaunches;
// 2. the fresh process calls `clear_webview_cache_if_marked()` at the very
// top of `run()`, before any webview exists, and deletes the cache
// there — retrying briefly while the old instance's WebView2 children
// finish exiting.
//
// Everything below compiles on every platform (runtime `cfg!` guards, not
// `#[cfg]`) so a macOS/Linux `cargo check` validates the whole path; the
// behavior itself is Windows-only and the frontend never renders the button
// elsewhere.
const CLEAR_WEBVIEW_MARKER: &str = ".clear-webview-cache";
const WEBVIEW_CACHE_DIR: &str = "EBWebView";
/// (marker file, cache dir) under the pre-app local data dir. Mirrors
/// `config::config_path_pre_app()` — `%LOCALAPPDATA%\<identifier>` on
/// Windows — because step 2 runs before an `AppHandle` exists.
fn webview_cache_paths() -> Option<(PathBuf, PathBuf)> {
let base = dirs_next::data_local_dir()?.join(crate::config::BUNDLE_IDENTIFIER);
Some((base.join(CLEAR_WEBVIEW_MARKER), base.join(WEBVIEW_CACHE_DIR)))
}
#[tauri::command]
pub fn clear_webview_cache_and_relaunch(app: tauri::AppHandle) -> Result<(), String> {
if !cfg!(target_os = "windows") {
return Err("WebView cache repair is only available on Windows (WebView2)".into());
}
let (marker, cache) = webview_cache_paths()
.ok_or_else(|| "could not resolve the local app data directory".to_string())?;
if let Some(parent) = marker.parent() {
let _ = fs::create_dir_all(parent);
}
fs::write(&marker, b"requested by the splash recovery panel (issue #879)\n")
.map_err(|e| format!("write {}: {e}", marker.display()))?;
log::warn!(
"WebView cache repair requested (#879) — relaunching to clear {}",
cache.display()
);
app.restart()
}
/// Startup half of the repair: if the previous run left the marker, delete
/// the WebView2 profile cache before any webview is created. Called at the
/// top of `run()`. One-shot by design — the marker is removed first so a
/// failing repair can never loop across launches.
pub fn clear_webview_cache_if_marked() {
if !cfg!(target_os = "windows") {
return;
}
let Some((marker, cache)) = webview_cache_paths() else {
return;
};
if !marker.exists() {
return;
}
let _ = fs::remove_file(&marker);
if !cache.exists() {
return;
}
// `app.restart()` spawns the new process before the old one has fully
// exited, so its WebView2 children may still hold locks — retry briefly.
const ATTEMPTS: u32 = 20;
for attempt in 1..=ATTEMPTS {
match fs::remove_dir_all(&cache) {
Ok(()) => {
log::warn!(
"cleared WebView2 profile cache at {} (attempt {attempt}) — issue #879 repair",
cache.display()
);
return;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
Err(e) if attempt < ATTEMPTS => {
log::debug!("WebView2 cache still locked ({e}) — retrying");
std::thread::sleep(Duration::from_millis(500));
}
Err(e) => {
// Never brick startup over a failed repair: WebView2 rebuilds
// whatever subset survived, and the user can retry.
log::error!(
"could not fully clear WebView2 cache at {}: {e} — continuing startup",
cache.display()
);
}
}
}
}
#[cfg(test)]
mod paste_error_tests {
use super::{kind_err, CLIPBOARD_RESTORE_DELAY};
#[test]
fn kind_err_prefixes_with_kind() {
assert_eq!(kind_err("a11y", "not granted"), "a11y:not granted");
assert_eq!(
kind_err("clipboard", "write failed: busy"),
"clipboard:write failed: busy"
);
assert_eq!(
kind_err("paste", "key press failed"),
"paste:key press failed"
);
}
#[test]
fn kind_survives_colons_in_detail() {
// The widget does `err.split(':')[0]` — details containing ':' (OS
// error strings usually do) must not corrupt the kind.
let e = kind_err("clipboard", "init failed: os error 5");
assert_eq!(e.split_once(':').map(|(k, _)| k), Some("clipboard"));
}
#[test]
fn restore_delay_is_about_300ms() {
// Contract with the widget layer: previous clipboard comes back
// ~300ms after the paste, long enough for slow paste consumers.
assert_eq!(CLIPBOARD_RESTORE_DELAY.as_millis(), 300);
}
}
+3 -1
View File
@@ -154,7 +154,9 @@ pub fn load_config_pre_app() -> AppConfig {
.unwrap_or_default()
}
const BUNDLE_IDENTIFIER: &str = "com.debpalash.omnivoice-studio";
/// Also used by `commands::webview_cache_paths` (#879) to locate the WebView2
/// profile cache before an `AppHandle` exists.
pub const BUNDLE_IDENTIFIER: &str = "com.debpalash.omnivoice-studio";
fn config_path_pre_app() -> Option<PathBuf> {
portable_config_file()
+318
View File
@@ -0,0 +1,318 @@
//! Backend crash forensics (#941).
//!
//! When the backend PROCESS dies (native CUDA abort, OOM kill, DLL crash),
//! the user used to see only "Can't reach the local OmniVoice backend" — and
//! the evidence (exit code, stderr tail) evaporated with the process. Every
//! such report was undiagnosable without asking for logs nobody sends.
//!
//! This module makes every backend death self-documenting: the death watchers
//! in `bootstrap.rs` (the startup health poll and the post-Ready supervisor)
//! call [`record_crash`] with the exit status and captured stderr tail, which
//! persists a small JSON **crash marker** next to the backend logs. The
//! frontend reads the newest marker via the `get_last_backend_crash` command
//! to replace the vague unreachable-toast with the honest story ("the backend
//! crashed (exit code X)…"), and the bug-report prefill attaches it so the
//! next #941-class GitHub issue arrives WITH the evidence.
//!
//! Only the last [`MAX_MARKERS`] crashes are kept. Acknowledgment is a
//! persisted timestamp (not deletion!) so viewing the crash details doesn't
//! destroy the evidence a subsequent bug report needs.
use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitStatus;
use serde::{Deserialize, Serialize};
/// How many crash markers to retain (newest first).
pub const MAX_MARKERS: usize = 3;
// ── Exit-status decomposition ──────────────────────────────────────────────
/// Structured view of how the backend child ended: the numeric exit code (or
/// Unix signal) for the marker, plus the human-readable `ExitStatus` display
/// for logs and bootstrap messages.
#[derive(Clone, Debug, PartialEq)]
pub struct BackendExit {
pub code: Option<i32>,
pub signal: Option<i32>,
pub description: String,
}
impl BackendExit {
pub fn from_status(status: ExitStatus) -> Self {
#[cfg(unix)]
let signal = {
use std::os::unix::process::ExitStatusExt;
status.signal()
};
#[cfg(not(unix))]
let signal = None;
BackendExit { code: status.code(), signal, description: status.to_string() }
}
/// For deaths we can't decompose (`try_wait` errored).
pub fn unknown(description: &str) -> Self {
BackendExit { code: None, signal: None, description: description.to_string() }
}
/// Short human label — "exit code 3221226505" / "signal 6" — for messages.
pub fn label(&self) -> String {
match (self.code, self.signal) {
(Some(c), _) => format!("exit code {}", c),
(None, Some(s)) => format!("signal {}", s),
(None, None) => self.description.clone(),
}
}
}
// ── Marker model ───────────────────────────────────────────────────────────
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CrashMarker {
/// Unix seconds when the death was detected.
pub ts: u64,
/// Process exit code, when the OS reported one.
pub exit_code: Option<i32>,
/// Unix signal that killed the process (None on Windows / normal exits).
pub signal: Option<i32>,
/// Human-readable `ExitStatus` display ("exit status: 134", …).
pub exit_desc: String,
/// App/backend version (lockstep per the versioning rule).
pub backend_version: String,
/// Seconds the backend had been running when it died.
pub uptime_s: u64,
/// Tail of backend_err.log captured at death time.
pub last_stderr: String,
}
/// The single on-disk store: newest-first markers plus the acknowledgment
/// watermark. One file keeps rotation + ack updates atomic-ish and avoids
/// filename collisions for same-second crashes.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct CrashStore {
/// `ts` of the newest marker the user has acknowledged (seen). Markers
/// with `ts <= acked_ts` are "old news" for UI purposes but are retained
/// for bug-report attachment.
#[serde(default)]
pub acked_ts: u64,
/// Newest first, capped at [`MAX_MARKERS`].
#[serde(default)]
pub markers: Vec<CrashMarker>,
}
/// Prepend `marker` and keep only the newest [`MAX_MARKERS`]. Pure so the
/// rotation policy is unit-tested without touching the filesystem.
pub fn push_marker(store: &mut CrashStore, marker: CrashMarker) {
store.markers.insert(0, marker);
store.markers.truncate(MAX_MARKERS);
}
/// Newest marker + whether the user has already acknowledged it.
pub fn newest_with_ack(store: &CrashStore) -> Option<(CrashMarker, bool)> {
store.markers.first().map(|m| (m.clone(), m.ts <= store.acked_ts))
}
// ── Persistence ────────────────────────────────────────────────────────────
/// The marker store lives next to the backend logs (same rationale: it's
/// forensic output of the backend process, discoverable alongside
/// backend.log / backend_err.log).
pub fn markers_path() -> PathBuf {
crate::backend::backend_log_path().with_file_name("backend_crash_markers.json")
}
pub fn load_store_from(path: &Path) -> CrashStore {
fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save_store_to(path: &Path, store: &CrashStore) {
match serde_json::to_string_pretty(store) {
Ok(json) => {
if let Err(e) = fs::write(path, json) {
log::warn!("Could not persist crash marker to {}: {}", path.display(), e);
}
}
Err(e) => log::warn!("Could not serialize crash marker: {}", e),
}
}
fn now_unix_s() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Build a marker for a death detected right now.
pub fn marker_now(exit: &BackendExit, uptime_s: u64, last_stderr: String) -> CrashMarker {
CrashMarker {
ts: now_unix_s(),
exit_code: exit.code,
signal: exit.signal,
exit_desc: exit.description.clone(),
backend_version: env!("CARGO_PKG_VERSION").to_string(),
uptime_s,
last_stderr,
}
}
/// Persist an unexpected backend death. Called by the death watchers in
/// `bootstrap.rs` AFTER they have ruled out intentional shutdowns (app quit,
/// deliberate retry/clean-retry kills).
pub fn record_crash(marker: CrashMarker) {
log::error!(
"Backend process died unexpectedly ({}, uptime {} s). Crash marker written. Stderr tail:\n{}",
marker.exit_desc,
marker.uptime_s,
if marker.last_stderr.is_empty() { "<none captured>" } else { &marker.last_stderr },
);
let path = markers_path();
let mut store = load_store_from(&path);
push_marker(&mut store, marker);
save_store_to(&path, &store);
}
// ── Tauri commands ─────────────────────────────────────────────────────────
/// Newest crash marker + its acknowledgment state, as returned to the
/// frontend (`get_last_backend_crash`).
#[derive(Clone, Debug, Serialize)]
pub struct CrashNotice {
#[serde(flatten)]
pub marker: CrashMarker,
pub acknowledged: bool,
}
/// Newest backend crash marker, or null when the backend has never crashed.
/// `acknowledged` tells the UI whether the user already viewed/dismissed it.
#[tauri::command]
pub fn get_last_backend_crash() -> Option<CrashNotice> {
let store = load_store_from(&markers_path());
newest_with_ack(&store).map(|(marker, acknowledged)| CrashNotice { marker, acknowledged })
}
/// Mark the newest crash as seen. Deliberately does NOT delete the marker —
/// the bug-report prefill still needs the evidence after the user viewed it.
#[tauri::command]
pub fn acknowledge_backend_crash() {
let path = markers_path();
let mut store = load_store_from(&path);
if let Some(newest_ts) = store.markers.first().map(|m| m.ts) {
if store.acked_ts < newest_ts {
store.acked_ts = newest_ts;
save_store_to(&path, &store);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn marker(ts: u64) -> CrashMarker {
CrashMarker {
ts,
exit_code: Some(1),
signal: None,
exit_desc: format!("exit status: 1 (#{ts})"),
backend_version: "0.0.0-test".into(),
uptime_s: 42,
last_stderr: "Traceback…".into(),
}
}
#[test]
fn rotation_keeps_only_the_last_three_newest_first() {
// #941: write 4 markers → only the newest MAX_MARKERS survive.
let mut store = CrashStore::default();
for ts in [1, 2, 3, 4] {
push_marker(&mut store, marker(ts));
}
assert_eq!(store.markers.len(), MAX_MARKERS);
let kept: Vec<u64> = store.markers.iter().map(|m| m.ts).collect();
assert_eq!(kept, vec![4, 3, 2], "newest first, oldest dropped");
}
#[test]
fn ack_semantics_survive_newer_crashes() {
let mut store = CrashStore::default();
push_marker(&mut store, marker(100));
// Fresh crash → unacknowledged.
let (m, acked) = newest_with_ack(&store).expect("has a marker");
assert_eq!(m.ts, 100);
assert!(!acked, "a fresh crash must be unacknowledged");
// Viewing acks the newest…
store.acked_ts = 100;
assert!(newest_with_ack(&store).unwrap().1, "viewed crash is acknowledged");
// …but a NEWER crash re-arms the notice, and the marker itself is
// retained (evidence survives the ack — bug reports still attach it).
push_marker(&mut store, marker(200));
let (m2, acked2) = newest_with_ack(&store).unwrap();
assert_eq!(m2.ts, 200);
assert!(!acked2, "a newer crash must surface again");
assert_eq!(store.markers.len(), 2, "ack never deletes markers");
}
#[test]
fn store_roundtrips_through_json_and_defaults_when_missing() {
let dir = std::env::temp_dir().join(format!("omnivoice-test-941-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("backend_crash_markers.json");
// Missing file → default store, never an error (first run).
assert_eq!(load_store_from(&path), CrashStore::default());
// Corrupt file → default store (a truncated write must not wedge the
// whole forensics path).
fs::write(&path, "{not json").unwrap();
assert_eq!(load_store_from(&path), CrashStore::default());
let mut store = CrashStore::default();
push_marker(
&mut store,
CrashMarker {
ts: 1,
exit_code: None,
signal: Some(6), // SIGABRT — the native-CUDA-abort shape
exit_desc: "signal: 6 (SIGABRT)".into(),
backend_version: "0.3.10".into(),
uptime_s: 7,
last_stderr: "CUDA error: an illegal memory access".into(),
},
);
store.acked_ts = 0;
save_store_to(&path, &store);
let loaded = load_store_from(&path);
assert_eq!(loaded, store, "Option fields (code=None, signal=Some) must roundtrip");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn backend_exit_labels_code_signal_and_unknown() {
let coded = BackendExit { code: Some(-1073740791), signal: None, description: "x".into() };
assert_eq!(coded.label(), "exit code -1073740791");
let signaled = BackendExit { code: None, signal: Some(9), description: "x".into() };
assert_eq!(signaled.label(), "signal 9");
let unknown = BackendExit::unknown("try_wait error: gone");
assert_eq!(unknown.label(), "try_wait error: gone");
}
#[cfg(unix)]
#[test]
fn backend_exit_decomposes_real_exit_statuses() {
use std::os::unix::process::ExitStatusExt;
// Normal exit with code 3.
let e = BackendExit::from_status(ExitStatus::from_raw(3 << 8));
assert_eq!(e.code, Some(3));
assert_eq!(e.signal, None);
// Killed by SIGABRT (6) — code is None, signal carries the story.
let k = BackendExit::from_status(ExitStatus::from_raw(6));
assert_eq!(k.code, None);
assert_eq!(k.signal, Some(6));
assert_eq!(k.label(), "signal 6");
}
}
+211 -19
View File
@@ -13,6 +13,7 @@ pub mod bootstrap;
pub mod tools;
pub mod backend;
pub mod commands;
pub mod crash;
pub mod updater_channel;
use std::process::Child;
@@ -41,6 +42,9 @@ pub fn backend_port() -> u16 {
pub struct BackendState {
pub process: Mutex<Option<Child>>,
/// When the tracked child was spawned — feeds the crash marker's
/// `uptime_s` (#941). Set alongside `process` in bootstrap.rs.
pub spawned_at: Mutex<Option<std::time::Instant>>,
}
pub struct AppFlags {
@@ -75,9 +79,18 @@ pub const TRAY_ICON_RECORDING: &[u8] = include_bytes!("../icons/tray-recording.p
// applies on top.
// - Linux (WebKitGTK): media-stream must be enabled per-WebView and the
// permission request answered programmatically.
// - macOS (WKWebView): nothing to do here — wry grants media-capture to the
// app origin and the user-visible consent is the system TCC prompt driven
// by NSMicrophoneUsageDescription in src-tauri/Info.plist.
// - macOS (WKWebView): nothing to do here in code — wry's own WKUIDelegate
// (WryWebViewUIDelegate::request_media_capture_permission) already grants
// every media-capture request unconditionally at the WebKit/JS layer. But
// that alone isn't sufficient (#1013): Tauri's macOS bundle defaults
// `hardenedRuntime` to true, and Hardened Runtime blocks camera/microphone
// hardware access unless the matching entitlement is present — without it,
// TCC never even registers a request, so the app never appears in System
// Settings → Privacy & Security → Microphone for the user to enable. See
// src-tauri/entitlements.plist (wired in via tauri.conf.json's
// bundle.macOS.entitlements) for the actual grant; NSMicrophoneUsageDescription
// in Info.plist only supplies the *prompt text* TCC shows, it doesn't
// substitute for the entitlement.
/// True for origins the app itself serves: the Tauri custom-protocol origin
/// in production and the Vite dev server / loopback in `tauri dev`.
@@ -201,10 +214,129 @@ mod media_permission_tests {
}
}
// ── Windows: dictation pill must never take foreground focus (#982) ────────
//
// Windows counterpart of #287 (macOS auto-paste — don't steal focus). The
// pill is `.always_on_top(true).skip_taskbar(true)` and is documented above
// (see `grant_webview_media_permissions`) as "deliberately unfocused so the
// auto-paste lands in the target app" — true on macOS, but on Windows,
// showing an always-on-top top-level window gives it Win32 foreground
// activation by default (ordinary Windows window-manager behavior; macOS
// doesn't force-activate a shown window the same way). Nothing marked the
// pill non-activating, so on Windows it stole foreground on every show —
// the synthesized Ctrl+V from `simulate_paste` landed back in the pill
// instead of the app the user was dictating into, and because the pill
// wrongly held focus for the whole session the target app never got it back
// until the pill's auto-dismiss timer eventually hid it.
//
// Two pieces, both required (verified by reading how `.show()` is used at
// the call sites below — several are followed by an explicit `set_focus()`
// that would fight the style bit on its own):
// 1. WS_EX_NOACTIVATE on the HWND, applied once right after creation, so
// the OS never grants this window foreground activation implicitly.
// 2. `ShowWindow(SW_SHOWNOACTIVATE)` in place of `WebviewWindow::show()` at
// the pill's dictation-trigger call sites, and the explicit
// `set_focus()` calls at those same sites are skipped on Windows (the
// same way they already are on macOS below).
//
// The flag math (`with_noactivate_style`) is a plain function so it's
// unit-testable on every platform — the actual Win32 syscalls that use it
// are Windows-only and can't run under `cargo test` on a non-Windows runner.
/// `WS_EX_NOACTIVATE` (winuser.h: `#define WS_EX_NOACTIVATE 0x08000000L`).
/// Hardcoded rather than imported from the `windows` crate so `with_noactivate_style`
/// below stays free of the Windows-only dependency and is testable everywhere.
/// Only consumed by Windows-only code (or the platform-agnostic test module
/// below) — `#[allow(dead_code)]` elsewhere, same as `is_app_origin` above.
#[cfg_attr(not(windows), allow(dead_code))]
const WS_EX_NOACTIVATE_BIT: isize = 0x0800_0000;
/// OR `WS_EX_NOACTIVATE` into an existing extended window style, preserving
/// every other bit already set (topmost, layered, etc. — the pill's
/// `always_on_top(true)` sets one of these). Pure so it's unit-testable
/// without a real HWND. See module comment above for why this exists.
#[cfg_attr(not(windows), allow(dead_code))]
fn with_noactivate_style(current_ex_style: isize) -> isize {
current_ex_style | WS_EX_NOACTIVATE_BIT
}
/// Mark the pill's HWND `WS_EX_NOACTIVATE`, once, right after creation — this
/// holds for every later `.show()` regardless of call site (belt-and-braces
/// alongside `show_pill_noactivate` below, which some call sites also need
/// because they pair `.show()` with an explicit `set_focus()`).
#[cfg(target_os = "windows")]
fn mark_pill_noactivate(win: &tauri::WebviewWindow) {
use windows::Win32::UI::WindowsAndMessaging::{
GetWindowLongPtrW, SetWindowLongPtrW, GWL_EXSTYLE,
};
let Ok(hwnd) = win.hwnd() else {
log::warn!("pill: could not resolve HWND to apply WS_EX_NOACTIVATE (#982)");
return;
};
unsafe {
let current = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
SetWindowLongPtrW(hwnd, GWL_EXSTYLE, with_noactivate_style(current));
}
}
/// Show the pill without granting it foreground activation. Used instead of
/// `WebviewWindow::show()` at the pill's dictation-trigger call sites on
/// Windows — `.show()` maps to plain `ShowWindow(SW_SHOW)`, which relies on
/// the NOACTIVATE style alone to suppress activation; `SW_SHOWNOACTIVATE` is
/// the explicit, documented way to show a window without activating it and
/// costs nothing extra now that the style bit is also set (#982).
#[cfg(target_os = "windows")]
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)");
return;
};
unsafe {
let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE);
}
}
#[cfg(test)]
mod pill_noactivate_tests {
use super::{with_noactivate_style, WS_EX_NOACTIVATE_BIT};
#[test]
fn adds_noactivate_bit_without_clobbering_existing_style() {
// Stand-in for whatever bits the pill's always_on_top/skip_taskbar
// window already carries (e.g. WS_EX_TOPMOST = 0x00000008) —
// NOACTIVATE must be added on top, never replace them.
let topmost = 0x0000_0008isize;
let updated = with_noactivate_style(topmost);
assert_eq!(
updated & WS_EX_NOACTIVATE_BIT,
WS_EX_NOACTIVATE_BIT,
"NOACTIVATE bit must be set"
);
assert_eq!(updated & topmost, topmost, "pre-existing style bits must survive");
}
#[test]
fn idempotent_if_already_noactivate() {
assert_eq!(with_noactivate_style(WS_EX_NOACTIVATE_BIT), WS_EX_NOACTIVATE_BIT);
}
#[test]
fn matches_documented_win32_value() {
// winuser.h: #define WS_EX_NOACTIVATE 0x08000000L
assert_eq!(WS_EX_NOACTIVATE_BIT, 0x0800_0000);
}
}
// ── Tauri entry ───────────────────────────────────────────────────────────
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// #879: if the previous run requested a WebView cache repair (splash
// recovery panel → clear_webview_cache_and_relaunch), perform it now —
// before any webview exists, so WebView2 holds no locks on the profile.
commands::clear_webview_cache_if_marked();
// ── Detect pill mode from CLI args OR persisted config ────────────────
// CLI flag takes precedence. If not passed, fall back to the
// `launch_as_widget` config field (set via tray "Switch to Pill Mode" or
@@ -256,6 +388,8 @@ pub fn run() {
commands::hf_cache_scan,
commands::simulate_paste,
commands::simulate_type,
commands::check_accessibility,
commands::open_accessibility_settings,
commands::set_tray_recording,
commands::quit_app,
commands::save_text_file,
@@ -263,6 +397,9 @@ pub fn run() {
commands::set_dictation_shortcut,
commands::get_launch_as_widget,
commands::set_launch_as_widget,
commands::clear_webview_cache_and_relaunch,
crash::get_last_backend_crash,
crash::acknowledge_backend_crash,
])
.setup(move |app| {
app.handle().plugin(tauri_plugin_dialog::init())?;
@@ -320,9 +457,15 @@ pub fn run() {
.skip_taskbar(true)
.center()
.build();
if let Err(e) = result {
if let Err(e) = &result {
log::error!("Failed to create widget window: {e:?}");
}
// Windows: mark the pill non-activating right away so it holds
// for every later `.show()` regardless of call site (#982).
#[cfg(target_os = "windows")]
if let Ok(win) = &result {
mark_pill_noactivate(win);
}
}
app.manage(AppFlags {
@@ -356,12 +499,17 @@ pub fn run() {
if win.move_window(Position::BottomCenter).is_err() {
let _ = win.center();
}
// Windows: show without granting foreground activation
// (#982) — `.show()` on other platforms is unaffected.
#[cfg(target_os = "windows")]
show_pill_noactivate(&win);
#[cfg(not(target_os = "windows"))]
let _ = win.show();
// Don't steal focus on macOS: the simulated ⌘V from
// simulate_paste() must land in the app the user is
// dictating into — focusing the widget would swallow
// it (#287).
#[cfg(not(target_os = "macos"))]
// Don't steal focus on macOS or Windows: the simulated
// ⌘V/Ctrl+V from simulate_paste() must land in the app
// the user is dictating into — focusing the widget would
// swallow it (#287 macOS, #982 Windows).
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let _ = win.set_focus();
}
let _ = app_handle.emit("tray-dictate", ());
@@ -512,7 +660,9 @@ pub fn run() {
// + focus the widget BEFORE emitting tray-dictate so
// the user sees the pill instead of silent recording.
// Positioning mirrors the global-shortcut handler:
// bottom-center (WhisperFlow style).
// bottom-center (WhisperFlow style). Windows skips the
// focus (and uses a non-activating show) for the same
// reason the global-shortcut handler does — see #982.
if let Some(win) = app.get_webview_window("widget") {
if win.is_visible().unwrap_or(false) {
let _ = app.emit("tray-dictate-stop", ());
@@ -520,8 +670,13 @@ pub fn run() {
if win.move_window(Position::BottomCenter).is_err() {
let _ = win.center();
}
let _ = win.show();
let _ = win.set_focus();
#[cfg(target_os = "windows")]
show_pill_noactivate(&win);
#[cfg(not(target_os = "windows"))]
{
let _ = win.show();
let _ = win.set_focus();
}
let _ = app.emit("tray-dictate", ());
}
} else {
@@ -596,6 +751,17 @@ pub fn run() {
if let Some(win) = app.get_webview_window("widget") {
let _ = win.hide();
}
// Enforce the always-open-maximized contract (#881) at
// runtime: macOS can ignore `maximized: true` from
// tauri.conf.json at window creation when combined with the
// Overlay title-bar style, so the config flag alone isn't
// reliable. maximize() zooms the window — it never enters a
// fullscreen Space. Guarded by tests/test_window_launch_state.py.
if let Some(main_win) = app.get_webview_window("main") {
if !main_win.is_maximized().unwrap_or(false) {
let _ = main_win.maximize();
}
}
}
// ── WebView media-capture permissions (mic for dictation) ────
@@ -620,6 +786,7 @@ pub fn run() {
app.manage(bootstrap_state);
app.manage(BackendState {
process: Mutex::new(None),
spawned_at: Mutex::new(None),
});
let app_handle = app.handle().clone();
@@ -638,13 +805,30 @@ pub fn run() {
set_stage(&stage_handle, BootstrapStage::AwaitingSetup);
return;
}
if backend::backend_healthy(backend_port()) {
log::info!(
"Port {} already serving OmniVoice backend — attaching",
backend_port()
);
set_stage(&stage_handle, BootstrapStage::Ready);
return;
match backend::running_backend_version(backend_port()) {
Some(v) if backend::same_app_version(&v) => {
log::info!(
"Port {} already serving OmniVoice backend v{} — attaching",
backend_port(), v
);
set_stage(&stage_handle, BootstrapStage::Ready);
return;
}
Some(v) => {
// Healthy-but-stale backend from a previous version —
// the post-update orphan that made new installs run
// old backend code. Replace it (see backend.rs
// same_app_version for the full story).
log::warn!(
"Port {} serves a stale OmniVoice backend (v{} != app v{}) — replacing it",
backend_port(),
if v.is_empty() { "<unknown>" } else { v.as_str() },
env!("CARGO_PKG_VERSION"),
);
backend::kill_orphan_on_port(backend_port());
std::thread::sleep(Duration::from_millis(500));
}
None => {}
}
if backend::port_in_use(backend_port()) {
log::warn!(
@@ -701,6 +885,14 @@ pub fn run() {
app.run(|app_handle, event| {
if let tauri::RunEvent::ExitRequested { .. } = event {
// Raise the quitting flag FIRST: exits that don't pass through the
// tray Quit item (macOS ⌘Q, OS session end) would otherwise let a
// death watcher observe our own SIGTERM below and record a false
// "backend crashed" marker (#941).
app_handle
.state::<AppFlags>()
.quitting
.store(true, Ordering::SeqCst);
if let Ok(mut lock) = app_handle.state::<BackendState>().process.lock() {
if let Some(ref mut child) = *lock {
let pid = child.id();

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