Compare commits

...
17 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
52 changed files with 2000 additions and 261 deletions
+1 -1
View File
@@ -149,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 }}
+13 -2
View File
@@ -213,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
+44
View File
@@ -8,6 +8,50 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
## [Unreleased]
## [0.3.14] — 2026-07-09
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
- **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)
### Docs
- **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.
+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 -->
+4 -3
View File
@@ -266,7 +266,7 @@ Professional-grade voice AI, minus the subscription and the cloud.
| | **Minimum** | **Recommended** |
|---|---|---|
| **OS** | Windows 10, macOS 12+ (Apple Silicon), 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 |
@@ -322,10 +322,10 @@ Professional-grade voice AI, minus the subscription and the cloud.
### 🎧 ASR Engines
**9 engines, all fully local** — 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 → ASR Engine** or via the `OMNIVOICE_ASR_BACKEND` env var.
**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> — 9 engines, what each is best at, and compute-type notes</summary>
<summary><b>📊 The full lineup</b> — 10 engines, what each is best at, and compute-type notes</summary>
<br/>
@@ -340,6 +340,7 @@ Professional-grade voice AI, minus the subscription and the cloud.
| **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.
+9
View File
@@ -1006,6 +1006,11 @@ async def dub_transcribe_stream(
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
@@ -1025,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)
+45
View File
@@ -750,6 +750,51 @@ def set_hf_mirror(body: _HFMirrorBody):
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
+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.12"
_FALLBACK_VERSION = "0.3.14"
def _fallback_version() -> str:
+61 -8
View File
@@ -504,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 /
@@ -578,6 +607,7 @@ async def lifespan(app: FastAPI):
# 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())
@@ -646,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
@@ -661,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
+164
View File
@@ -29,6 +29,7 @@ import os
import re
import threading
from abc import ABC, abstractmethod
from typing import Optional
logger = logging.getLogger("omnivoice.asr")
@@ -1634,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."""
@@ -1689,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).
})
@@ -1713,6 +1870,13 @@ _INSTALL_HINTS: dict[str, str] = {
"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 "
+12 -2
View File
@@ -957,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)
@@ -1089,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
+54
View File
@@ -223,6 +223,60 @@ 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 ───────────────────────────────────────────────────────────────
+7
View File
@@ -849,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))
@@ -859,6 +860,12 @@ class MLXAudioBackend(TTSBackend):
kwargs = {"text": text, "speed": speed}
if voice: kwargs["voice"] = voice
if ref_audio: kwargs["ref_audio"] = ref_audio
# 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
+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:
+35
View File
@@ -445,6 +445,41 @@ quit OmniVoice Studio, delete the folder below, then start the app again.
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"
**Symptom:** in the Dub tab, translating fails with e.g. *"The 'google'
+22
View File
@@ -77,6 +77,28 @@ 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>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omnivoice-studio",
"version": "0.3.12",
"version": "0.3.14",
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
+1 -1
View File
@@ -2941,7 +2941,7 @@ dependencies = [
[[package]]
name = "omnivoice-studio"
version = "0.3.12"
version = "0.3.14"
dependencies = [
"arboard",
"dirs-next",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "omnivoice-studio"
version = "0.3.12"
version = "0.3.14"
description = "OmniVoice Studio AI voice cloning & dubbing desktop app"
authors = ["Debpalash"]
license = "AGPL-3.0-only"
+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>
+12 -3
View File
@@ -79,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`.
+2 -1
View File
@@ -85,7 +85,8 @@
],
"macOS": {
"minimumSystemVersion": "12.0",
"signingIdentity": "-"
"signingIdentity": "-",
"entitlements": "entitlements.plist"
}
},
"plugins": {
@@ -70,6 +70,11 @@ function reasonMentionsLicense(reason) {
* arg is set only by mlx-audio's curated-model picker (#981).
* - activeId?: string the currently-active backend id for this
* family. Used to render the "active" badge.
* - showFamilyTabs?: boolean default true. When false, the matrix is
* pinned to `family` no TTS/ASR/LLM switcher, and the header names
* the family ("ASR Engines") instead of the generic matrix title.
* Settings Engines stacks one pinned matrix per family so the ASR
* and LLM pickers are visible instead of tucked behind a tab.
*/
const FAMILY_META = {
tts: { label: 'TTS', icon: Cpu },
@@ -158,8 +163,10 @@ export default function EngineCompatibilityMatrix({
family = 'tts',
onSelect = null,
activeId = null,
// Test-friendly overrides let the RTL suite mock the API layer
// without resorting to module-level vi.mock incantations.
showFamilyTabs = true,
// Injectable API layer lets the RTL suite mock it without module-level
// vi.mock incantations, and lets EnginesTab share one in-flight
// GET /engines across its stacked per-family matrices.
apiListEngines = listEngines,
apiGetEngineHealth = getEngineHealth,
apiSelfTestEngine = selfTestEngine,
@@ -347,12 +354,19 @@ export default function EngineCompatibilityMatrix({
// TTS-05: the license dialog registered for the engine awaiting acceptance
// (or null). Capitalized so JSX renders it as a component below.
const LicenseDialog = licenseDialogFor ? LICENSE_DIALOGS[licenseDialogFor] : null;
// Pinned mode: the header names the family (with its icon) since there is
// no switcher to say which family this table is.
const familyMeta = FAMILY_META[activeFamily] || FAMILY_META.tts;
const TitleIcon = showFamilyTabs ? Layers : familyMeta.icon;
return (
<section className="engine-matrix flex flex-col gap-[var(--space-3,8px)]">
<header className="engine-matrix__head flex items-center justify-between gap-[12px]">
<h3 className="engine-matrix__title inline-flex items-center gap-[6px] m-0 text-[13px] font-semibold text-[color:var(--chrome-fg,currentColor)]">
<Layers size={14} /> {t('engines.matrixTitle')}
<TitleIcon size={14} />{' '}
{showFamilyTabs
? t('engines.matrixTitle')
: t('engines.familyMatrixTitle', { family: familyMeta.label })}
</h3>
<Button
size="sm"
@@ -365,7 +379,7 @@ export default function EngineCompatibilityMatrix({
</Button>
</header>
{families.length > 1 && (
{showFamilyTabs && families.length > 1 && (
<Segmented
size="sm"
value={activeFamily}
-27
View File
@@ -73,9 +73,6 @@ export default function NavRail({ mode, setMode, side = 'left', onFlipSide }) {
[t],
);
const donateLabel = t('donate.pill', { defaultValue: 'Support OmniVoice' });
const donateActive = mode === 'donate';
// `nav-rail` is retained purely as the layout hook the (out-of-scope)
// `.app-container > .nav-rail` grid rules position by; all visual styling now
// lives in the utilities below. Border flips to the inner edge when on the right.
@@ -84,17 +81,6 @@ export default function NavRail({ mode, setMode, side = 'left', onFlipSide }) {
? '[border-left:1px_solid_var(--chrome-border)]'
: '[border-right:1px_solid_var(--chrome-border)]';
// Quiet "Support" pill (was `.rail-btn.donate-pill`): neutral at rest, warms to
// the accent on hover/active.
const donateState = donateActive
? 'text-[var(--chrome-accent)] bg-[var(--chrome-accent-bg)] [border:1px_solid_var(--chrome-accent-border)]'
: 'bg-transparent text-[var(--chrome-fg-dim)] [border:1px_solid_transparent] hover:bg-[color-mix(in_srgb,var(--chrome-accent)_10%,transparent)] hover:text-[var(--chrome-accent)]';
const heartBase =
'text-[16px] leading-none [transition:filter_0.16s,opacity_0.16s,transform_0.16s] group-hover:[transform:scale(1.1)] motion-reduce:[transition:none] motion-reduce:group-hover:[transform:none]';
const heartState = donateActive
? 'opacity-100 [filter:grayscale(0)]'
: 'opacity-75 [filter:grayscale(0.55)] group-hover:opacity-100 group-hover:[filter:grayscale(0)]';
return (
<aside
className={`nav-rail z-50 flex select-none flex-col items-center gap-[6px] bg-[var(--chrome-bg)] py-[8px] ${asideBorder}`}
@@ -111,19 +97,6 @@ export default function NavRail({ mode, setMode, side = 'left', onFlipSide }) {
))}
</div>
<div className="flex flex-col items-center gap-[4px]">
{/* Quiet "Support" pill warms to the accent on hover, opens the
donate page. Sits with the footer nav (Settings / flip). (#007) */}
<button
onClick={() => setMode('donate')}
title={donateLabel}
aria-label={donateLabel}
className={`${RAIL_BTN_BASE} ${donateState}`}
>
<span className={`${heartBase} ${heartState}`} aria-hidden="true">
🩷
</span>
<span className={railLabelCls(side)}>{donateLabel}</span>
</button>
{footerItems.map((it) => (
<RailBtn
key={it.id}
+17 -3
View File
@@ -351,7 +351,14 @@ function WaveformTimeline(
console.warn('WebKit audio decode not supported, using media element directly');
try {
const emptyPeaks = new Float32Array(1000).fill(0);
ws.load(undefined, [emptyPeaks], mediaEl.duration || 60);
// Don't rely solely on the 'ready' event firing again for this
// recovery load the play button stayed permanently disabled
// when it didn't (the waveform still rendered from the peaks, so
// there was no visible sign anything was wrong). Confirm
// readiness explicitly once this load settles either way.
Promise.resolve(ws.load(undefined, [emptyPeaks], mediaEl.duration || 60))
.then(() => setReady(true))
.catch(() => setReady(true));
} catch (_) {
setReady(true);
}
@@ -372,7 +379,12 @@ function WaveformTimeline(
})
.then((audioBuffer) => {
const channelData = audioBuffer.getChannelData(0);
ws.load(undefined, [channelData], audioBuffer.duration);
// Same explicit-readiness guard as the NotSupportedError branch
// above don't depend on the 'ready' event re-firing for this
// manually-decoded recovery load.
Promise.resolve(ws.load(undefined, [channelData], audioBuffer.duration))
.then(() => setReady(true))
.catch(() => setReady(true));
})
.catch((decodeErr) => {
// HTTP 404 on the companion audio means the source file is
@@ -391,7 +403,9 @@ function WaveformTimeline(
console.warn('Audio decode fallback failed, loading with empty peaks:', decodeErr);
try {
const emptyPeaks = new Float32Array(1000).fill(0);
ws.load(undefined, [emptyPeaks], mediaEl.duration || 60);
Promise.resolve(ws.load(undefined, [emptyPeaks], mediaEl.duration || 60))
.then(() => setReady(true))
.catch(() => setReady(true));
} catch (_) {
setLoadError(true);
}
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
// Regression guard: the dub editor's play button stayed permanently disabled
// (disabled={!ready}) whenever the initial WaveSurfer decode failed and the
// component fell back to a peaks-only ws.load(undefined, [peaks], duration)
// call — the waveform still rendered from those peaks (so nothing looked
// visibly broken), but `ready` was only ever set from the 'ready' event
// re-firing on that recovery load, which this component's own error-handling
// code never actually confirmed. Each fallback load must now explicitly
// confirm readiness once it settles, instead of assuming the event fires.
//
// Driving WaveSurfer + a real decode-failure/recovery sequence through jsdom
// is brittle (see WaveformTimeline.unlock.test.js), so this is a
// source-level contract guard, same house pattern: every `ws.load(undefined,
// ...)` recovery call inside the `ws.on('error', ...)` handler must be
// followed by an explicit setReady(true) confirmation.
const src = readFileSync(
path.resolve(process.cwd(), 'src/components/WaveformTimeline.jsx'),
'utf8',
);
describe('WaveformTimeline error-recovery ready confirmation', () => {
it("confirms readiness explicitly after every fallback ws.load() call, not just via the 'ready' event", () => {
const errorHandler = /ws\.on\('error', \(err\) => \{([\s\S]*?)\n \}\);/.exec(src)?.[1];
expect(errorHandler, "ws.on('error', ...) handler not found").toBeTruthy();
// Every recovery load in this handler passes peaks explicitly
// (`ws.load(undefined, [...], ...)`) — each occurrence must be
// immediately confirmed ready via a .then()/.catch() pair (or an
// unconditional setReady in a synchronous catch), not left to hope the
// 'ready' event re-fires on its own.
const loadCalls = [...errorHandler.matchAll(/ws\.load\(undefined, \[[^\]]*\][^)]*\)/g)];
expect(loadCalls.length).toBeGreaterThanOrEqual(3);
for (const match of loadCalls) {
const tail = errorHandler.slice(match.index, match.index + 220);
expect(tail, `no readiness confirmation after: ${match[0]}`).toMatch(/setReady\(true\)/);
}
});
});
@@ -91,8 +91,13 @@ export default function CommunityZone({
name: r.name,
}),
);
} catch {
flash(t('gallery.use_failed', { defaultValue: 'Could not add that voice.' }));
} catch (e) {
flash(
t('gallery.use_failed', {
message: e?.message || String(e),
defaultValue: 'Could not create that voice: {{message}}',
}),
);
}
}}
onDesign={(item) =>
@@ -110,7 +110,12 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
const r = await searchYoutube(q, 'import', 10);
setResults(r.results || []);
} catch (e) {
flash(t('gallery.search_failed', { defaultValue: 'Search failed.' }));
flash(
t('gallery.search_failed', {
message: e?.message || String(e),
defaultValue: 'Search failed: {{message}}',
}),
);
} finally {
setIsSearching(false);
}
@@ -149,7 +154,12 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
await uploadVoiceClip(fd);
reload();
} catch (err) {
flash(t('gallery.upload_failed', { defaultValue: 'Upload failed.' }));
flash(
t('gallery.upload_failed', {
message: err?.message || String(err),
defaultValue: 'Upload failed: {{message}}',
}),
);
} finally {
if (fileRef.current) fileRef.current.value = '';
}
@@ -165,7 +175,12 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
}),
);
} catch (e) {
flash(t('gallery.save_failed', { defaultValue: 'Could not save profile.' }));
flash(
t('gallery.save_failed', {
message: e?.message || String(e),
defaultValue: 'Could not save profile: {{message}}',
}),
);
}
};
@@ -179,8 +194,13 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
try {
await deleteGalleryVoice(v.id);
reload();
} catch {
/* noop */
} catch (e) {
flash(
t('gallery.delete_failed', {
message: e?.message || String(e),
defaultValue: 'Could not delete: {{message}}',
}),
);
}
};
@@ -191,7 +211,12 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
const file = new File([blob], `${v.name}.wav`, { type: 'audio/wav' });
setTrimming({ voice: v, file });
} catch (e) {
flash(t('gallery.trim_load_failed', { defaultValue: 'Could not load audio for trimming.' }));
flash(
t('gallery.trim_load_failed', {
message: e?.message || String(e),
defaultValue: 'Could not load audio for trimming: {{message}}',
}),
);
}
};
@@ -209,7 +234,12 @@ export default function ImportsZone({ t, playingId, loadingPreviewId, onPlayGall
reload();
setTrimming(null);
} catch (e) {
flash(t('gallery.upload_failed', { defaultValue: 'Upload failed.' }));
flash(
t('gallery.upload_failed', {
message: e?.message || String(e),
defaultValue: 'Upload failed: {{message}}',
}),
);
}
};
@@ -0,0 +1,152 @@
/**
* Settings Models tab OpenAI-compatible remote ASR panel (#877).
*
* 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. Configures the `openai-compat-asr` backend's
* base_url/model/api_key; activating it as the active ASR engine still needs
* `OMNIVOICE_ASR_BACKEND=openai-compat-asr` (no in-app ASR engine picker
* exists yet for any ASR backend this panel only configures this one).
*
* Endpoints (loopback-only):
* GET /api/settings/asr-openai-compat {base_url, model, has_key}
* PUT /api/settings/asr-openai-compat body {base_url?, model?, api_key?}
* ('' clears api_key; omitted/null leaves it unchanged never returned)
*/
import React, { useCallback, useEffect, useState } from 'react';
import { Mic } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { apiJson, apiFetch } from '../../api/client';
import { SettingsSection, SettingRow, SettingsInput } from './primitives';
import { Button } from '../../ui';
export default function AsrOpenAICompatPanel() {
const { t } = useTranslation();
const [baseUrl, setBaseUrl] = useState('');
const [model, setModel] = useState('');
const [apiKey, setApiKey] = useState('');
const [hasKey, setHasKey] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const refresh = useCallback(async () => {
setError(null);
try {
const d = await apiJson('/api/settings/asr-openai-compat');
setBaseUrl(d?.base_url || '');
setModel(d?.model || '');
setHasKey(Boolean(d?.has_key));
setApiKey(''); // the key is never returned the field always starts blank
} catch (e) {
setError(e?.message || t('models.asrOpenAICompatLoadError'));
}
}, [t]);
useEffect(() => {
refresh();
}, [refresh]);
const save = async () => {
setSaving(true);
setError(null);
try {
const res = await apiFetch('/api/settings/asr-openai-compat', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
base_url: baseUrl,
model,
// Only send api_key when the user actually typed something
// an untouched field must leave the stored key unchanged, not
// clear it (the field is always blank on load, so "unchanged"
// and "empty" would otherwise be indistinguishable).
...(apiKey ? { api_key: apiKey } : {}),
}),
});
const d = await res.json();
setBaseUrl(d.base_url || '');
setModel(d.model || '');
setHasKey(Boolean(d.has_key));
setApiKey('');
} catch (e) {
setError(e?.message || t('models.asrOpenAICompatSaveError'));
} finally {
setSaving(false);
}
};
return (
<SettingsSection
icon={Mic}
title={t('models.asrOpenAICompatTitle')}
description={t('models.asrOpenAICompatDescription')}
>
{error && (
<div className="perfpanel__error" role="alert">
{error}
</div>
)}
<SettingRow
stack
title={t('models.asrOpenAICompatBaseUrlTitle')}
hint={t('models.asrOpenAICompatBaseUrlHint')}
control={
<SettingsInput
mono
type="text"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="http://localhost:8000/v1"
data-testid="asr-openai-compat-base-url"
/>
}
/>
<SettingRow
stack
title={t('models.asrOpenAICompatModelTitle')}
control={
<SettingsInput
mono
type="text"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="whisper-1"
data-testid="asr-openai-compat-model"
/>
}
/>
<SettingRow
stack
title={t('models.asrOpenAICompatApiKeyTitle')}
hint={
hasKey ? t('models.asrOpenAICompatKeyConfigured') : t('models.asrOpenAICompatApiKeyHint')
}
control={
<>
<SettingsInput
mono
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={hasKey ? '••••••••' : t('models.asrOpenAICompatApiKeyOptional')}
data-testid="asr-openai-compat-api-key"
/>
<Button
variant="subtle"
size="sm"
onClick={save}
loading={saving}
disabled={saving}
data-testid="asr-openai-compat-save"
>
{t('common.save')}
</Button>
</>
}
/>
</SettingsSection>
);
}
@@ -1,12 +1,19 @@
import React, { useCallback } from 'react';
import React, { useCallback, useRef } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { addBreadcrumb } from '../../utils/breadcrumbs';
import { selectEngine } from '../../api/engines';
import { listEngines, selectEngine } from '../../api/engines';
import { notifyEngineSelected } from '../../utils/engineSelectToast';
import EngineCompatibilityMatrix from '../EngineCompatibilityMatrix';
import { SETTINGS_SECTION_SURFACE } from './primitives';
/** One pinned matrix per family, stacked in this order. ASR used to be
* reachable only through the matrix's family tabs, which read as a
* TTS-only table README even promised a Settings ASR picker that
* didn't exist (UX gap found during #877). Every family now gets a
* visible picker; `OMNIVOICE_*_BACKEND` env vars still win over any pick. */
const FAMILIES = ['tts', 'asr', 'llm'];
export default function EnginesTab() {
const { t } = useTranslation();
@@ -33,9 +40,32 @@ export default function EnginesTab() {
[t],
);
// The stacked matrices all consume the same GET /engines payload share
// one in-flight request so opening the tab probes every engine once, not
// once per family. A per-matrix Refresh after the shared promise settles
// still triggers a fresh fetch.
const inflightList = useRef(null);
const listEnginesShared = useCallback(() => {
if (!inflightList.current) {
inflightList.current = listEngines().finally(() => {
inflightList.current = null;
});
}
return inflightList.current;
}, []);
return (
<section className={SETTINGS_SECTION_SURFACE} data-slot="settings-section">
<EngineCompatibilityMatrix family="tts" onSelect={onSelect} />
</section>
<>
{FAMILIES.map((family) => (
<section key={family} className={SETTINGS_SECTION_SURFACE} data-slot="settings-section">
<EngineCompatibilityMatrix
family={family}
showFamilyTabs={false}
onSelect={onSelect}
apiListEngines={listEnginesShared}
/>
</section>
))}
</>
);
}
@@ -0,0 +1,88 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
// Keep toast side-channels out of the test (timers, portals).
vi.mock('react-hot-toast', () => ({
default: { error: vi.fn(), success: vi.fn() },
toast: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() }),
}));
vi.mock('../../api/engines', () => ({
listEngines: vi.fn(),
selectEngine: vi.fn(),
getEngineHealth: vi.fn(),
selfTestEngine: vi.fn(),
}));
import { listEngines, selectEngine } from '../../api/engines';
import EnginesTab from './EnginesTab';
function entry(id, name) {
return {
id,
display_name: name,
available: true,
reason: null,
install_hint: null,
last_error: null,
isolation_mode: 'in-process',
gpu_compat: ['cpu'],
};
}
const ENGINES = {
tts: { active: 'omnivoice', backends: [entry('omnivoice', 'OmniVoice (test)')] },
asr: {
active: 'whisperx',
backends: [
entry('whisperx', 'WhisperX (test)'),
entry('openai-compat-asr', 'OpenAI-compatible ASR (test)'),
],
},
llm: { active: 'off', backends: [entry('off', 'Off (test)')] },
};
describe('EnginesTab', () => {
beforeEach(() => {
vi.clearAllMocks();
listEngines.mockResolvedValue(ENGINES);
});
it('renders a pinned picker per family — TTS, ASR and LLM all visible at once', async () => {
render(<EnginesTab />);
await waitFor(() => screen.getByText('WhisperX (test)'));
// One named section per family (the ASR picker used to be tucked behind
// a family tab inside a single TTS-titled matrix no picker to find).
expect(screen.getByText('TTS Engines')).toBeInTheDocument();
expect(screen.getByText('ASR Engines')).toBeInTheDocument();
expect(screen.getByText('LLM Engines')).toBeInTheDocument();
// Pinned matrices render no family switcher.
expect(document.querySelector('.engine-matrix__tab-family')).toBeNull();
});
it('the stacked matrices share one GET /engines on mount', async () => {
render(<EnginesTab />);
await waitFor(() => screen.getByText('WhisperX (test)'));
expect(listEngines).toHaveBeenCalledTimes(1);
});
it('clicking Use on an ASR engine selects it with family="asr"', async () => {
selectEngine.mockResolvedValue({
family: 'asr',
active: 'openai-compat-asr',
env_override: false,
routing_status: 'cpu_only',
effective_device: 'cpu',
routing_reason: null,
});
render(<EnginesTab />);
await waitFor(() => screen.getByText('OpenAI-compatible ASR (test)'));
fireEvent.click(screen.getByRole('button', { name: /use openai-compatible asr \(test\)/i }));
await waitFor(() => {
expect(selectEngine).toHaveBeenCalledWith('asr', 'openai-compat-asr', undefined);
});
});
});
+34 -3
View File
@@ -11,7 +11,11 @@ import { generateSpeech, audioUrlWithCacheBust } from '../api/generate';
import { apiFetch } from '../api/client';
import { playBlobAudio } from '../utils/media';
import { PRESETS } from '../utils/constants';
import { instructToFormValue, mergeDescribedAttrs } from '../utils/voiceInstruct';
import {
instructToFormValue,
mergeDescribedAttrs,
buildDesignInstruct,
} from '../utils/voiceInstruct';
import { askConfirm } from '../utils/dialog';
import { toast } from 'react-hot-toast';
import { recordValueMoment } from '../utils/donationMoments';
@@ -55,7 +59,12 @@ export default function useProfiles({ loadHistory, loadProfiles }) {
const safeBlob = new Blob([arrBuf], { type: refAudio.type });
formData.append('ref_audio', safeBlob, refAudio.name || 'profile.wav');
formData.append('ref_text', refText);
formData.append('instruct', instruct);
// #1010: the backend only sanitizes instruct on save for kind='design'
// profiles — a clone profile (this call always creates kind='clone')
// would silently persist an unsupported free-text instruct and then
// 400 every single time it's used to generate. Filter here too.
const { instruct: safeInst } = buildDesignInstruct({}, instruct);
formData.append('instruct', safeInst);
formData.append('language', language);
try {
await createProfile(formData);
@@ -204,6 +213,25 @@ export default function useProfiles({ loadHistory, loadProfiles }) {
fin_prof = '';
}
// #1010: this instruct string comes straight from segment/preset data,
// never through the validator-safe builder — a preset's raw attrs or a
// free-text style field can carry phrases outside the active engine's
// supported instruct vocabulary, 400ing instead of previewing. Same
// client-side guard useTTS.js already applies to the clone path.
if (fin_inst) {
const { instruct: safeInst, unsupported, duplicates } = buildDesignInstruct({}, fin_inst);
if (unsupported.length) {
toast(t('tts_errors.ignored_unsupported', { items: unsupported.join(', ') }), {
icon: '⚠️',
});
}
if (duplicates.length) {
toast(t('tts_errors.ignored_duplicate', { items: duplicates.join(', ') }), {
icon: '⚠️',
});
}
fin_inst = safeInst;
}
if (fin_prof) formData.append('profile_id', fin_prof);
if (fin_inst) formData.append('instruct', fin_inst);
const fin_lang = seg.target_lang || dubLang;
@@ -245,7 +273,10 @@ export default function useProfiles({ loadHistory, loadProfiles }) {
: item.text
: '';
formData.append('ref_text', extractedText);
formData.append('instruct', item.instruct || '');
// #1010: same guard as handleSaveProfile — this always creates a
// kind='clone' profile, which the backend never sanitizes on save.
const { instruct: safeHistInst } = buildDesignInstruct({}, item.instruct || '');
formData.append('instruct', safeHistInst);
formData.append('language', item.language || 'Auto');
if (item.seed !== undefined && item.seed !== null) {
formData.append('seed', item.seed);
+20 -7
View File
@@ -1188,8 +1188,8 @@
"no_matches": "No voices match these filters.",
"load_more": "Load more",
"saved_as_profile": "Added \"{{name}}\" to your voices.",
"use_failed": "Could not create that voice — the engine may be loading.",
"preview_failed": "Preview unavailable — the voice engine may still be loading.",
"use_failed": "Could not create that voice: {{message}}",
"preview_failed": "Preview unavailable: {{message}}",
"import_explainer": "Paste a URL you have the rights to (or upload a file), trim the part you need, and save it as a voice. You are responsible for the licensing of anything you import.",
"import_placeholder": "Paste a video/audio URL, or type to search…",
"imported_clip": "Imported clip",
@@ -1199,11 +1199,12 @@
"no_imports": "Nothing imported yet. Paste a URL above to get started.",
"search_results": "{{count}} results",
"download_failed": "Download failed: {{msg}}",
"search_failed": "Search failed.",
"upload_failed": "Upload failed.",
"save_failed": "Could not save profile.",
"search_failed": "Search failed: {{message}}",
"upload_failed": "Upload failed: {{message}}",
"save_failed": "Could not save profile: {{message}}",
"confirm_delete": "Delete \"{{name}}\"?",
"trim_load_failed": "Could not load audio for trimming.",
"delete_failed": "Could not delete: {{message}}",
"trim_load_failed": "Could not load audio for trimming: {{message}}",
"delete": "Delete",
"community_empty": "No community voices loaded yet — connect to the internet and reopen, or be the first to submit one.",
"community_explainer": "Designed presets and recorded voices shared by the community, loaded from the omnivoice-gallery.",
@@ -1553,6 +1554,7 @@
"loading": "Loading engines…",
"refresh": "Refresh",
"matrixTitle": "Engine Compatibility Matrix",
"familyMatrixTitle": "{{family}} Engines",
"loadFailed": "Failed to load engines: {{message}}",
"couldNotLoad": "Could not load engines: {{message}}",
"retry": "Retry",
@@ -2014,7 +2016,18 @@
"mirror_preset_hint": "On a restricted network, route model downloads through a mirror. Leave empty for the official endpoint.",
"mirror_restart_note": "Model Store downloads use the new mirror immediately. Only model loads (transformers) pick it up after a restart.",
"mirror_load_error": "Failed to load mirror setting",
"mirror_save_error": "Failed to save"
"mirror_save_error": "Failed to save",
"asrOpenAICompatTitle": "OpenAI-compatible ASR (remote server)",
"asrOpenAICompatDescription": "Point transcription at Qwen3-ASR, a self-hosted FunASR/SenseVoice server, or OpenAI's own API.",
"asrOpenAICompatBaseUrlTitle": "Server URL",
"asrOpenAICompatBaseUrlHint": "The base URL of an OpenAI-compatible transcription server. To use this engine, also set OMNIVOICE_ASR_BACKEND=openai-compat-asr — there's no in-app engine picker for ASR yet.",
"asrOpenAICompatModelTitle": "Model",
"asrOpenAICompatApiKeyTitle": "API key",
"asrOpenAICompatApiKeyHint": "Optional — many self-hosted servers don't require one.",
"asrOpenAICompatApiKeyOptional": "optional",
"asrOpenAICompatKeyConfigured": "A key is saved. Leave blank to keep it, or type a new one to replace it.",
"asrOpenAICompatLoadError": "Failed to load ASR server setting",
"asrOpenAICompatSaveError": "Failed to save"
},
"enterprise_faq": {
"q_internal_tools": "Do I need a license for internal tools?",
+2
View File
@@ -22,6 +22,7 @@ import StoragePanel from '../components/settings/StoragePanel';
import StorageTab from '../components/settings/StorageTab';
import StorageUsagePanel from '../components/settings/StorageUsagePanel';
import HFMirrorPanel from '../components/settings/HFMirrorPanel';
import AsrOpenAICompatPanel from '../components/settings/AsrOpenAICompatPanel';
import SharingPanel from '../components/settings/SharingPanel';
import RemoteBackendPanel from '../components/settings/RemoteBackendPanel';
import MCPBindingsPanel from '../components/settings/MCPBindingsPanel';
@@ -367,6 +368,7 @@ export default function Settings() {
<>
<StoragePanel />
<HFMirrorPanel />
<AsrOpenAICompatPanel />
<ModelStoreTab info={info} modelBadge={modelBadge} />
</>
);
+4 -2
View File
@@ -138,7 +138,8 @@ export default function VoiceGallery() {
stopPlayback();
flash(
t('gallery.preview_failed', {
defaultValue: 'Preview unavailable — the voice engine may still be loading.',
message: e?.message || String(e),
defaultValue: 'Preview unavailable: {{message}}',
}),
);
} finally {
@@ -222,7 +223,8 @@ export default function VoiceGallery() {
} catch (e) {
flash(
t('gallery.use_failed', {
defaultValue: 'Could not create that voice — the engine may be loading.',
message: e?.message || String(e),
defaultValue: 'Could not create that voice: {{message}}',
}),
);
}
@@ -817,4 +817,74 @@ describe('EngineCompatibilityMatrix', () => {
await waitFor(() => screen.getByText('MLX-Audio (test)'));
expect(screen.getByTestId('curated-model-select-mlx-audio')).toBeDisabled();
});
// showFamilyTabs={false} pinned per-family mount (Settings Engines)
function multiFamilyResponse() {
return {
tts: {
active: 'omnivoice',
backends: [
{
id: 'omnivoice',
display_name: 'OmniVoice (test)',
available: true,
reason: null,
install_hint: null,
last_error: null,
isolation_mode: 'in-process',
gpu_compat: ['cpu'],
},
],
},
asr: {
active: 'whisperx',
backends: [
{
id: 'whisperx',
display_name: 'WhisperX (test)',
available: true,
reason: null,
install_hint: null,
last_error: null,
isolation_mode: 'in-process',
gpu_compat: ['cpu'],
},
],
},
llm: { active: 'off', backends: [] },
};
}
it('pins to the given family and hides the TTS/ASR/LLM switcher when showFamilyTabs is false', async () => {
const apiListEngines = vi.fn().mockResolvedValue(multiFamilyResponse());
render(
<EngineCompatibilityMatrix
family="asr"
showFamilyTabs={false}
apiListEngines={apiListEngines}
apiGetEngineHealth={vi.fn()}
/>,
);
await waitFor(() => screen.getByText('WhisperX (test)'));
// Pinned header names the family instead of the generic matrix title
expect(screen.getByText('ASR Engines')).toBeInTheDocument();
// the TTS family never leaks into the pinned table
expect(screen.queryByText('OmniVoice (test)')).not.toBeInTheDocument();
// and there is no family switcher to wander off to.
expect(document.querySelector('.engine-matrix__tab-family')).toBeNull();
});
it('keeps the family switcher by default (standalone mounts unchanged)', async () => {
const apiListEngines = vi.fn().mockResolvedValue(multiFamilyResponse());
render(
<EngineCompatibilityMatrix
family="tts"
apiListEngines={apiListEngines}
apiGetEngineHealth={vi.fn()}
/>,
);
await waitFor(() => screen.getByText('OmniVoice (test)'));
expect(screen.getByText('Engine Compatibility Matrix')).toBeInTheDocument();
expect(document.querySelectorAll('.engine-matrix__tab-family').length).toBe(3);
});
});
@@ -0,0 +1,26 @@
// Regression guard: VoiceGallery/CommunityZone/ImportsZone catch blocks used
// to discard the real error and show a hardcoded, often-wrong generic guess
// (e.g. "the engine may be loading" on ANY failure, including ones that had
// nothing to do with loading). Fixed to interpolate the real `e.message`
// (already a clean, user-facing string from api/client.js's ApiError),
// matching the `{{message}}` convention used everywhere else in this file.
// This test only pins the i18n keys, not the call sites, deliberately: it's
// a cheap net against reverting to a hardcoded string, not a full behavior test.
import { describe, it, expect } from 'vitest';
import en from '../i18n/locales/en.json';
describe('gallery error messages interpolate the real error', () => {
const keys = [
'use_failed',
'preview_failed',
'search_failed',
'upload_failed',
'save_failed',
'delete_failed',
'trim_load_failed',
];
it.each(keys)('gallery.%s contains {{message}}', (key) => {
expect(en.gallery[key]).toContain('{{message}}');
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "omnivoice"
version = "0.3.12"
version = "0.3.14"
description = "OmniVoice: Towards Omnilingual Zero-Shot Text-to-Speech with Diffusion Language Models"
readme = "README.md"
# Free and open-source under the GNU Affero General Public License v3 (see
+17
View File
@@ -42,6 +42,23 @@ for stage_base in "$STAGE_BASE_RELEASE" "$STAGE_BASE_DEBUG"; do
echo "inject-apprun: replacing AppRun in $appdir"
cp -f "$APPRUN_SRC" "$appdir/AppRun"
chmod 755 "$appdir/AppRun"
# Stamp the bundled WebKitGTK version (#961 follow-up). The AppImage
# bundles THIS build host's libwebkit2gtk, so the host's pkg-config
# answer here is the version the shipped bundle will actually run —
# knowable by construction at bundle time, unknowable reliably at
# runtime (a user's pkg-config reports their SYSTEM's version, which
# LD_LIBRARY_PATH overrides with the bundled copy). AppRun's workaround
# auto-detection reads this marker first and only falls back to host
# pkg-config when the marker is absent (bundles predating the stamp).
wk_bundled="$(pkg-config --modversion webkit2gtk-4.1 2>/dev/null \
|| pkg-config --modversion webkit2gtk-4.0 2>/dev/null \
|| echo "")"
if [ -n "$wk_bundled" ]; then
printf '%s\n' "$wk_bundled" > "$appdir/.bundled-webkitgtk-version"
echo "inject-apprun: stamped bundled WebKitGTK version: $wk_bundled"
else
echo "inject-apprun: WARNING — could not read the bundled WebKitGTK version (pkg-config missing?); AppRun will use its runtime fallback" >&2
fi
found=1
fi
done
@@ -247,6 +247,121 @@ def test_select_llm_never_routing_gated(fresh_app, monkeypatch):
assert r.status_code == 200, r.text
# ── ASR selection via /engines/select (Settings → Engines ASR picker) ──────
#
# The ASR family was always wired in _FAMILIES on paper, but no UI called it
# and nothing exercised it — the Settings picker now does. Lock the contract:
# a pick persists to prefs["asr_backend"], `OMNIVOICE_ASR_BACKEND` still wins
# over the pick, and unknown / not-ready ids are 400s.
def _register_fake_asr(asr_mod, engine_id, *, available=True):
"""Register a light in-process ASR stub (CPU-only so a forced-CPU host
routes it `cpu_only`, never `unavailable`). Returns (cls, restore_fn)."""
_avail = available
class _FakeASR(asr_mod.ASRBackend):
id = engine_id
display_name = f"Fake {engine_id}"
gpu_compat = ("cpu",)
@classmethod
def is_available(cls):
return (True, "ready") if _avail else (False, "deps missing (test)")
def transcribe(self, audio_path, *, word_timestamps=True):
raise NotImplementedError
saved = dict(asr_mod._REGISTRY)
asr_mod._REGISTRY[engine_id] = _FakeASR
def restore():
asr_mod._REGISTRY.clear()
asr_mod._REGISTRY.update(saved)
return _FakeASR, restore
def test_select_asr_persists_pref_and_echoes_active(fresh_app, monkeypatch):
from core import prefs as _prefs
from services import asr_backend as asr_mod
_force_cpu_host(monkeypatch)
monkeypatch.delenv("OMNIVOICE_ASR_BACKEND", raising=False)
_, restore = _register_fake_asr(asr_mod, "fake-asr")
try:
r = _client(fresh_app).post(
"/engines/select", json={"family": "asr", "backend_id": "fake-asr"})
assert r.status_code == 200, r.text
body = r.json()
assert body["family"] == "asr"
assert body["active"] == "fake-asr"
assert body["env_override"] is False
assert _prefs.get("asr_backend") == "fake-asr"
finally:
restore()
def test_select_asr_env_var_still_wins(fresh_app, monkeypatch):
"""CRITICAL backward-compat: an existing `OMNIVOICE_ASR_BACKEND` pin keeps
winning over a Settings pick the pick persists to prefs (for when the
pin is lifted) but the active id stays the env value, and the response
says so via env_override."""
from core import prefs as _prefs
from services import asr_backend as asr_mod
_force_cpu_host(monkeypatch)
monkeypatch.setenv("OMNIVOICE_ASR_BACKEND", "pytorch-whisper")
_, restore = _register_fake_asr(asr_mod, "fake-asr-pinned")
try:
r = _client(fresh_app).post(
"/engines/select", json={"family": "asr", "backend_id": "fake-asr-pinned"})
assert r.status_code == 200, r.text
body = r.json()
assert body["env_override"] is True
assert body["active"] == "pytorch-whisper" # env wins
assert _prefs.get("asr_backend") == "fake-asr-pinned"
finally:
restore()
def test_select_asr_unknown_backend_is_400(fresh_app):
r = _client(fresh_app).post(
"/engines/select", json={"family": "asr", "backend_id": "nope-not-real"})
assert r.status_code == 400
assert "Unknown asr backend" in r.json()["detail"]
def test_select_asr_unavailable_backend_is_400(fresh_app, monkeypatch):
from services import asr_backend as asr_mod
_force_cpu_host(monkeypatch)
_, restore = _register_fake_asr(asr_mod, "fake-asr-down", available=False)
try:
r = _client(fresh_app).post(
"/engines/select", json={"family": "asr", "backend_id": "fake-asr-down"})
assert r.status_code == 400
assert "not ready" in r.json()["detail"]
finally:
restore()
def test_get_engines_asr_family_shape(fresh_app):
"""GET /engines/asr — the ASR picker's data source: active id + one row
per registered backend with availability, reasons and install hints."""
r = _client(fresh_app).get("/engines/asr")
assert r.status_code == 200
body = r.json()
assert isinstance(body["active"], str) and body["active"]
by_id = {b["id"]: b for b in body["backends"]}
assert {"whisperx", "faster-whisper", "openai-compat-asr"}.issubset(by_id)
# Install hints power the picker's tooltips (parity with TTS).
assert by_id["openai-compat-asr"]["install_hint"]
for entry in by_id.values():
missing = _REQUIRED_KEYS - entry.keys()
assert not missing, f"asr entry {entry['id']!r} missing: {missing}"
# ── #981 — mlx-audio curated-model selection via /engines/select ───────────
#
# mlx-audio multiplexes 7+ curated models behind one backend id. Before this
+32
View File
@@ -37,6 +37,38 @@ if not os.environ.get("OMNIVOICE_ENV_FILE"):
import pytest
import warnings as _warnings
# ── torch default-dtype isolation (CI flaky trio) ───────────────────────────
# Three tests (test_effects_chain / test_generation_audio_guard /
# test_persona_bundle) fail intermittently on CI — never locally — with
# signatures that all trace to one cause: a leaked
# `torch.set_default_dtype(torch.float16)` from some earlier test. The
# smoking gun is test_generation_audio_guard's observed value
# 0.0999755859375, which is exactly float16(0.1): `torch.tensor([0.1, …])`
# built under a leaked fp16 default. The same leak collapses
# test_effects_chain's preset differences into identical quantized outputs,
# and hands test_persona_bundle's soundfile writer fp16 data libsndfile
# can't encode. The polluter only executes on CI-Linux (it never reproduces
# on macOS), so rather than chase it blind, this guard makes the whole leak
# class impossible — same philosophy as the LLM-state guard below — and
# names the offender in CI output when it fires, so it CAN be chased.
@pytest.fixture(autouse=True)
def _torch_default_dtype_guard(request):
yield
torch = sys.modules.get("torch")
if torch is None:
return
if torch.get_default_dtype() is not torch.float32:
_warnings.warn(
f"{request.node.nodeid} leaked torch default dtype "
f"{torch.get_default_dtype()} — resetting to float32. This is "
f"the polluter behind the CI flaky trio; fix it at the source.",
stacklevel=1,
)
torch.set_default_dtype(torch.float32)
# ── LLM-provider state isolation (issue #878) ──────────────────────────────
# LLM provider selection is process-global three ways: env vars (the
+2
View File
@@ -18,6 +18,7 @@ DELETE /profiles/{profile_id}/consent
DELETE /projects/{project_id}
DELETE /pronunciation/{entry_id}
GET /api/mcp/bindings
GET /api/settings/asr-openai-compat
GET /api/settings/changelog
GET /api/settings/db-backup
GET /api/settings/dictation-refinement
@@ -216,6 +217,7 @@ POST /v1/audio/transcriptions
POST /watermark/detect
POST /watermark/settings
PUT /api/mcp/bindings
PUT /api/settings/asr-openai-compat
PUT /api/settings/dictation-refinement
PUT /api/settings/hf-mirror
PUT /api/settings/llm-endpoint
+29
View File
@@ -0,0 +1,29 @@
"""Run the AppImage AppRun launcher's shell unit tests under pytest.
AppRun.test.sh existed but was wired into NO CI job the launcher's
workaround auto-detection (which decides whether shipped Linux builds get
WEBKIT_DISABLE_COMPOSITING_MODE) could regress silently. This wrapper rides
the standard "Tests (backend + frontend)" gate instead of needing its own
workflow step. Covers the #961 follow-up too: the build-time
.bundled-webkitgtk-version marker must beat the host's pkg-config answer.
"""
import os
import shutil
import subprocess
import pytest
_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_SCRIPT = os.path.join(_REPO, "frontend", "src-tauri", "appimage", "AppRun.test.sh")
@pytest.mark.skipif(shutil.which("bash") is None, reason="bash not available")
def test_apprun_shell_suite_passes():
proc = subprocess.run(
["bash", _SCRIPT], capture_output=True, text=True, timeout=120,
)
assert proc.returncode == 0, (
f"AppRun.test.sh failed (exit {proc.returncode}):\n"
f"{proc.stdout}\n{proc.stderr}"
)
assert "0 fail" in proc.stdout
+239
View File
@@ -0,0 +1,239 @@
"""Generic OpenAI-compatible ASR backend (#877) — a path to Qwen3-ASR,
FunASR/SenseVoice self-hosted servers, or OpenAI's own Whisper API, today,
without waiting on transformers to ship a direct Qwen3-ASR integration.
settings_store backed by in-memory dicts, OpenAI client faked at the SDK
boundary (no network) house convention, same as test_llm_providers_router.py:
direct handler calls, no TestClient, so the loopback auth guard isn't in play.
"""
from __future__ import annotations
import os
import sys
import types
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backend"))
os.environ.setdefault("OMNIVOICE_MODEL", "test")
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
_HAS_OPENAI = __import__("importlib").util.find_spec("openai") is not None
pytestmark = pytest.mark.skipif(not _HAS_OPENAI, reason="openai package not installed")
@pytest.fixture
def ss(monkeypatch):
"""services.settings_store, resolved fresh (no module-level import — see
asr_mod's docstring for why staleness across sys.modules reimports is a
real risk in this suite) and patched to in-memory dicts (no SQLite)."""
from services import settings_store as _ss
text: dict[str, str] = {}
secrets: dict[str, str] = {}
monkeypatch.setattr(_ss, "get_text", lambda k, default=None: text.get(k, default))
monkeypatch.setattr(_ss, "set_text", lambda k, v: text.__setitem__(k, v))
monkeypatch.setattr(_ss, "get_secret", lambda n: secrets.get(n))
monkeypatch.setattr(
_ss, "set_secret", lambda n, v: secrets.__setitem__(n, v) if v else secrets.pop(n, None)
)
monkeypatch.setattr(_ss, "list_secret_names", lambda: list(secrets))
return _ss
@pytest.fixture
def asr_mod(ss, monkeypatch):
"""services.asr_backend with settings_store in-memory (no SQLite).
Resolved via importlib.import_module INSIDE the fixture (not a top-level
`import` in this file) so it's the module object actually live in
sys.modules at test-run time other test files in this ~2400-test suite
pop+reimport shared service modules (services.model_manager,
services.tts_backend), and a module-level import captured once at file
COLLECTION time can go stale by the time an individual test in this file
finally runs, hours of test-order later. A collection-time reference
calling .set_text() and a fixture-time reference reading via .get_text()
can silently be two different module objects the write and the read
land in different in-memory dicts, and the test fails with no obvious
cause. Every test below takes `ss` as a fixture (not a module-level
`from services import settings_store`) for the same reason.
"""
for var in ("ASR_OPENAI_COMPAT_BASE_URL", "ASR_OPENAI_COMPAT_MODEL", "ASR_OPENAI_COMPAT_API_KEY"):
monkeypatch.delenv(var, raising=False)
import importlib
return importlib.import_module("services.asr_backend")
@pytest.fixture
def settings_mod(asr_mod):
"""api.routers.settings sharing the same monkeypatched settings_store."""
import importlib
return importlib.import_module("api.routers.settings")
def _fake_openai_transcribe(monkeypatch, *, verbose_ok=True, response=None, raise_exc=None):
"""Fake openai.OpenAI whose audio.transcriptions.create() either returns
a canned response or raises. verbose_ok=False simulates a minimal server
that rejects response_format="verbose_json" on the first call, forcing
the plain-json fallback."""
captured_kwargs = []
calls = []
class _FakeClient:
def __init__(self, **kwargs):
captured_kwargs.append(kwargs)
self.audio = types.SimpleNamespace(
transcriptions=types.SimpleNamespace(create=self._create)
)
def _create(self, **kw):
calls.append(kw)
if raise_exc is not None:
raise raise_exc
if kw.get("response_format") == "verbose_json" and not verbose_ok:
raise RuntimeError("response_format not supported")
return response
import openai
monkeypatch.setattr(openai, "OpenAI", _FakeClient)
return captured_kwargs, calls
# ── is_available() gating ───────────────────────────────────────────────────
def test_unavailable_without_base_url(asr_mod):
ok, msg = asr_mod.OpenAICompatASRBackend.is_available()
assert ok is False
assert "Settings" in msg
def test_available_once_base_url_configured(asr_mod, ss):
ss.set_text(asr_mod._ASR_OPENAI_COMPAT_BASE_URL_KEY, "http://localhost:8080/v1")
ok, _ = asr_mod.OpenAICompatASRBackend.is_available()
assert ok is True
# ── response adaptation ─────────────────────────────────────────────────────
def test_transcribe_adapts_verbose_json_segments(asr_mod, ss, monkeypatch, tmp_path):
ss.set_text(asr_mod._ASR_OPENAI_COMPAT_BASE_URL_KEY, "http://localhost:8080/v1")
class _Seg:
def model_dump(self):
return {"text": "hello world", "start": 0.0, "end": 1.5}
resp = types.SimpleNamespace(segments=[_Seg()], language="en")
_fake_openai_transcribe(monkeypatch, response=resp)
audio = tmp_path / "seg.wav"
audio.write_bytes(b"RIFF....WAVEfmt ") # content is never read by the fake client
out = asr_mod.OpenAICompatASRBackend().transcribe(str(audio))
assert out["language"] == "en"
assert out["segments"] == [{"text": "hello world", "start": 0.0, "end": 1.5, "words": []}]
assert out["chunks"] == [{"text": "hello world", "timestamp": (0.0, 1.5)}]
def test_transcribe_falls_back_to_plain_text_when_verbose_json_rejected(asr_mod, ss, monkeypatch, tmp_path):
ss.set_text(asr_mod._ASR_OPENAI_COMPAT_BASE_URL_KEY, "http://localhost:8080/v1")
resp = types.SimpleNamespace(text="plain text only", segments=None, language=None)
_captured, calls = _fake_openai_transcribe(monkeypatch, verbose_ok=False, response=resp)
audio = tmp_path / "seg.wav"
audio.write_bytes(b"RIFF....WAVEfmt ")
out = asr_mod.OpenAICompatASRBackend().transcribe(str(audio))
assert len(calls) == 2 # verbose_json attempt, then the plain fallback
assert calls[0]["response_format"] == "verbose_json"
assert calls[1]["response_format"] == "json"
assert out["segments"] == [{"text": "plain text only", "start": 0.0, "end": None, "words": []}]
assert out["language"] == "en" # default when the server doesn't report one
def test_transcribe_network_failure_does_not_leak_raw_exception(asr_mod, ss, monkeypatch, tmp_path):
"""Mirrors the #977 convention: a raw SDK/httpx exception must never reach
the caller unformatted only a clean, actionable RuntimeError."""
ss.set_text(asr_mod._ASR_OPENAI_COMPAT_BASE_URL_KEY, "http://localhost:8080/v1")
_fake_openai_transcribe(monkeypatch, raise_exc=ConnectionError("connection refused"))
audio = tmp_path / "seg.wav"
audio.write_bytes(b"RIFF....WAVEfmt ")
with pytest.raises(RuntimeError) as ei:
asr_mod.OpenAICompatASRBackend().transcribe(str(audio))
msg = str(ei.value)
assert "localhost:8080" in msg
assert "ConnectionError" in msg
def test_client_disables_sdk_retries(asr_mod, ss, monkeypatch, tmp_path):
"""max_retries=0 — mirrors llm_skills.resolve_skill_client: a slow/rate-
limited server retrying inside the SDK would blow past the caller's own
bounded timeout expectation for a single transcribe call."""
ss.set_text(asr_mod._ASR_OPENAI_COMPAT_BASE_URL_KEY, "http://localhost:8080/v1")
resp = types.SimpleNamespace(text="ok", segments=None, language="en")
captured_kwargs, _ = _fake_openai_transcribe(monkeypatch, response=resp)
audio = tmp_path / "seg.wav"
audio.write_bytes(b"RIFF....WAVEfmt ")
asr_mod.OpenAICompatASRBackend().transcribe(str(audio))
assert captured_kwargs[0]["max_retries"] == 0
# ── settings endpoints ───────────────────────────────────────────────────────
def test_get_default_empty(settings_mod):
st = settings_mod.get_asr_openai_compat()
assert st == {"base_url": "", "model": "whisper-1", "has_key": False}
def test_put_persists_and_never_echoes_the_key(settings_mod):
st = settings_mod.set_asr_openai_compat(
settings_mod._ASROpenAICompatBody(
base_url="http://localhost:8080/v1/", model="qwen3-asr", api_key="sk-test-123",
)
)
assert st["base_url"] == "http://localhost:8080/v1" # trailing slash trimmed
assert st["model"] == "qwen3-asr"
assert st["has_key"] is True
assert "sk-test-123" not in str(st) # the key never round-trips
st2 = settings_mod.get_asr_openai_compat()
assert st2 == st
def test_empty_api_key_clears_it(settings_mod):
settings_mod.set_asr_openai_compat(
settings_mod._ASROpenAICompatBody(api_key="sk-test-123")
)
assert settings_mod.get_asr_openai_compat()["has_key"] is True
settings_mod.set_asr_openai_compat(settings_mod._ASROpenAICompatBody(api_key=""))
assert settings_mod.get_asr_openai_compat()["has_key"] is False
def test_none_fields_leave_existing_values_unchanged(settings_mod):
settings_mod.set_asr_openai_compat(
settings_mod._ASROpenAICompatBody(base_url="http://localhost:8080/v1", model="qwen3-asr")
)
# A save that only touches api_key must not clobber base_url/model.
settings_mod.set_asr_openai_compat(settings_mod._ASROpenAICompatBody(api_key="sk-abc"))
st = settings_mod.get_asr_openai_compat()
assert st["base_url"] == "http://localhost:8080/v1"
assert st["model"] == "qwen3-asr"
assert st["has_key"] is True
def test_rejects_a_base_url_without_scheme(settings_mod):
from fastapi import HTTPException
with pytest.raises(HTTPException):
settings_mod.set_asr_openai_compat(
settings_mod._ASROpenAICompatBody(base_url="localhost:8080/v1")
)
def test_registered_in_backend_list(asr_mod):
assert "openai-compat-asr" in asr_mod._REGISTRY
assert asr_mod._REGISTRY["openai-compat-asr"] is asr_mod.OpenAICompatASRBackend
assert "openai-compat-asr" in asr_mod._INSTALL_HINTS
+111
View File
@@ -194,6 +194,53 @@ def test_asr_env_override(monkeypatch):
assert asr_backend.active_backend_id() == "pytorch-whisper"
# ── ASR selection resolution (Settings → Engines ASR picker) ────────────────
# Same env > prefs > auto-detect contract as TTS. The env var MUST keep
# winning so existing `OMNIVOICE_ASR_BACKEND` pins don't change behavior now
# that the Settings picker writes the prefs key.
def test_asr_active_backend_prefs_fallback(monkeypatch, tmp_path):
from core import prefs as _prefs
monkeypatch.setattr(_prefs, "_PREFS_PATH", str(tmp_path / "prefs.json"))
monkeypatch.delenv("OMNIVOICE_ASR_BACKEND", raising=False)
_prefs.set_("asr_backend", "moonshine")
assert asr_backend.active_backend_id() == "moonshine"
# Env var must beat prefs.
monkeypatch.setenv("OMNIVOICE_ASR_BACKEND", "pytorch-whisper")
assert asr_backend.active_backend_id() == "pytorch-whisper"
def test_asr_auto_detects_when_no_env_no_prefs(monkeypatch, tmp_path):
from core import prefs as _prefs
monkeypatch.setattr(_prefs, "_PREFS_PATH", str(tmp_path / "prefs.json"))
monkeypatch.delenv("OMNIVOICE_ASR_BACKEND", raising=False)
assert asr_backend.active_backend_id() in {
"whisperx", "faster-whisper", "mlx-whisper", "pytorch-whisper",
}
def test_get_active_asr_backend_follows_prefs_switch_without_restart(monkeypatch, tmp_path):
"""#981 class (fixed on the TTS side): a Settings pick must take effect on
the next transcribe, not after an app restart. get_active_asr_backend()
re-resolves the id per call, so a prefs write switches immediately."""
from core import prefs as _prefs
monkeypatch.setattr(_prefs, "_PREFS_PATH", str(tmp_path / "prefs.json"))
monkeypatch.delenv("OMNIVOICE_ASR_BACKEND", raising=False)
_prefs.set_("asr_backend", "pytorch-whisper")
assert isinstance(
asr_backend.get_active_asr_backend(), asr_backend.PyTorchWhisperBackend)
_prefs.set_("asr_backend", "moonshine")
assert isinstance(
asr_backend.get_active_asr_backend(), asr_backend.MoonshineASRBackend)
def test_asr_unknown_backend_raises(monkeypatch):
monkeypatch.setenv("OMNIVOICE_ASR_BACKEND", "not-a-real-asr")
with pytest.raises(ValueError):
asr_backend.get_active_asr_backend()
# ── LLM ─────────────────────────────────────────────────────────────────────
@@ -357,6 +404,70 @@ def test_mlx_audio_generate_rejects_unsupported_kokoro_language_before_calling_m
backend.generate("hello", language="Dutch")
def test_mlx_audio_generate_passes_ref_text_through_for_cloning():
# #1012/#1013: MLXAudioBackend.generate() read voice/ref_audio/language/
# speed from kwargs but silently dropped ref_text — CSM (sesame.py) only
# builds its cloning context when BOTH ref_audio and ref_text are
# present, so cloning on CSM always raised an opaque
# "IndexError: list index out of range" deep inside mlx-audio instead of
# ever attempting the clone. Community-diagnosed with the exact fix.
pytest.importorskip("mlx_audio", reason="mlx-audio is Apple-Silicon-only")
backend = tts_backend.MLXAudioBackend()
backend._ensure_loaded = lambda: None
captured = {}
def _fake_generate(**kw):
captured.update(kw)
return iter([types.SimpleNamespace(audio=__import__("numpy").zeros(4))])
backend._model = types.SimpleNamespace(generate=_fake_generate)
backend.generate("hello", ref_audio="/tmp/ref.wav", ref_text="the reference line")
assert captured.get("ref_text") == "the reference line"
assert captured.get("ref_audio") == "/tmp/ref.wav"
def test_mlx_audio_generate_omits_ref_text_without_ref_audio():
# ref_text alone (no ref_audio) means nothing to CSM's context builder —
# don't pass a stray kwarg an engine that isn't cloning doesn't expect.
pytest.importorskip("mlx_audio", reason="mlx-audio is Apple-Silicon-only")
backend = tts_backend.MLXAudioBackend()
backend._ensure_loaded = lambda: None
captured = {}
def _fake_generate(**kw):
captured.update(kw)
return iter([types.SimpleNamespace(audio=__import__("numpy").zeros(4))])
backend._model = types.SimpleNamespace(generate=_fake_generate)
backend.generate("hello", ref_text="orphaned text, no audio")
assert "ref_text" not in captured
def test_mlx_audio_generate_design_path_unaffected_without_any_ref():
# Absorbed from community PR #1015 (MahdiHedhli) — the design/instruct
# path (no ref_audio, no ref_text at all) must stay untouched by the
# ref_text forwarding fix; neither kwarg may leak into the model call.
pytest.importorskip("mlx_audio", reason="mlx-audio is Apple-Silicon-only")
backend = tts_backend.MLXAudioBackend()
backend._ensure_loaded = lambda: None
captured = {}
def _fake_generate(**kw):
captured.update(kw)
return iter([types.SimpleNamespace(audio=__import__("numpy").zeros(4))])
backend._model = types.SimpleNamespace(generate=_fake_generate)
backend.generate("hello")
assert "ref_text" not in captured
assert "ref_audio" not in captured
def test_mlx_audio_generate_auto_language_skips_lang_code_entirely():
# Matches the "Auto" convention other engines in this file use
# (OmniVoiceBackend.generate(), _run_backend_inference) — never resolved,
+143
View File
@@ -0,0 +1,143 @@
"""A quit mid-preload must not report a clean shutdown while a GPU-pool
thread is still running (#1000 class).
Field report: a backend log showed three rapid restart cycles, each ending
with "Shutdown: done." immediately followed by a "Model loading failed:
Could not import module 'AutoFeatureExtractor'" error — transformers' own
generic lazy-import wrapper, not a real dependency problem. The real cause:
`preload_task` (and the optional `capture_preload_task`) were created at
startup but never referenced in the shutdown block, so `idle_task`/
`worker_task` got cancelled-and-awaited while the preload task was simply
abandoned the process declared "done" while a background GPU-pool thread
was still mid-`import`, and got torn down by interpreter finalization under
it.
`_cancel_and_await_tasks` is the extracted, directly-testable shutdown
helper the full `lifespan()` context manager touches too much startup
machinery (DB init, gallery init, MCP session manager) to drive directly in
a unit test (this suite's own test_mcp_mount.py notes exactly this: running
the full lifespan contaminates other tests' event loops).
"""
from __future__ import annotations
import asyncio
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backend"))
from main import _cancel_and_await_tasks # noqa: E402
def _run(coro):
return asyncio.run(coro)
def test_a_task_that_finished_before_cancel_keeps_its_result():
"""An early-stage task (mirrors preload still importing, not yet deep in
blocking weight-load work) that completes on its own before the shutdown
helper even reaches it must not be treated as an error `.cancel()` on
an already-done task is a no-op, and its real result survives. This is
the fix: previously preload_task was never referenced in shutdown at
all, so this case (the common one most quits don't land mid-import)
was never even checked."""
finished = []
async def _quick():
await asyncio.sleep(0.01)
finished.append("done")
async def _scenario():
t = asyncio.create_task(_quick())
await asyncio.sleep(0.05) # long enough for _quick() to fully finish
assert t.done()
await _cancel_and_await_tasks(t, timeout=1.0) # must not raise on a done task
_run(_scenario())
assert finished == ["done"]
def test_none_entries_are_skipped_without_error():
"""capture_preload_task is None when OMNIVOICE_PRELOAD_CAPTURE_ASR=0 —
the helper must not crash on a mix of real tasks and None."""
async def _noop():
return None
async def _scenario():
t = asyncio.create_task(_noop())
await _cancel_and_await_tasks(t, None, timeout=1.0)
_run(_scenario()) # must not raise
def test_a_task_stuck_past_the_bound_times_out_without_hanging():
"""A task that never yields back (mirroring a GPU-pool thread stuck in a
blocking native call) must not hang shutdown forever the bound is the
backstop, same as the pre-existing idle_task/worker_task pattern."""
async def _wedged():
await asyncio.sleep(10.0)
async def _scenario():
t = asyncio.create_task(_wedged())
await asyncio.sleep(0.01)
await _cancel_and_await_tasks(t, timeout=0.2)
import time
start = time.monotonic()
_run(_scenario())
elapsed = time.monotonic() - start
assert elapsed < 2.0, f"shutdown helper did not bound its wait: took {elapsed:.2f}s"
def test_multiple_tasks_are_all_cancelled_before_any_await():
"""Cancel-then-await (not cancel-then-immediately-await-one-at-a-time) —
every task gets its cancellation requested up front, so a slow task
earlier in the list can't delay a later task's cancel signal."""
cancelled_order = []
async def _tracked(name, delay):
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
cancelled_order.append(name)
raise
async def _scenario():
t1 = asyncio.create_task(_tracked("slow", 5.0))
t2 = asyncio.create_task(_tracked("fast", 5.0))
await asyncio.sleep(0.01)
await _cancel_and_await_tasks(t1, t2, timeout=0.5)
_run(_scenario())
assert set(cancelled_order) == {"slow", "fast"}
def test_production_shutdown_wait_is_generous_enough_for_a_cold_import():
"""Post-merge code-review finding (Greptile, PR #1002): the original 3s
bound left a real residual window cancelling the asyncio task doesn't
stop the underlying OS thread, so a cold transformers import taking
longer than the bound could still let shutdown report "done" while that
thread was alive, the exact #1000 class again just with lower odds.
Python can't forcibly kill a running thread, so no finite bound
eliminates this outright this pins the production call site to a
materially more generous wait (20s, not 3s) rather than letting a future
edit quietly shrink it back down without deliberate consideration.
Source-level guard, not a live-timing test: driving an actual >3s cold
import through this suite would make it slow and environment-dependent
for no real benefit.
"""
import re
src = open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"backend", "main.py")).read()
call = re.search(
r"await _cancel_and_await_tasks\(\s*idle_task,\s*worker_task,\s*preload_task,"
r"\s*capture_preload_task,\s*timeout=([\d.]+),?\s*\)",
src,
)
assert call, "production shutdown call site not found in main.py"
assert float(call.group(1)) >= 15.0, (
f"shutdown wait bound regressed to {call.group(1)}s — see PR #1002 review history "
"before shrinking this"
)
+82
View File
@@ -21,6 +21,8 @@ from services.speaker_clone import (
MIN_SLICE_DURATION_S,
_pick_reference_slices,
extract_speaker_clones,
refine_ref_text,
refine_ref_texts,
)
SR = 16000
@@ -124,3 +126,83 @@ class TestExtractSpeakerClones:
# or every real turn boundary would be flagged.
from services.segmentation import SPEAKER_GAP
assert 0 < ADJACENT_TURN_GUARD_S < SPEAKER_GAP
class _FakeASR:
"""Stands in for the active ASR backend's .transcribe() — no model, no
network. `chunks_by_path` maps a ref_audio path to the canned chunk list
that path's re-transcription should return."""
def __init__(self, chunks_by_path=None, raises_for=()):
self.chunks_by_path = chunks_by_path or {}
self.raises_for = set(raises_for)
self.calls = []
def transcribe(self, path, *, word_timestamps=True):
self.calls.append(path)
if path in self.raises_for:
raise RuntimeError("simulated ASR failure")
return {"chunks": self.chunks_by_path.get(path, []), "language": "es"}
class TestRefineRefText:
# Issue #1004: the ASR segment's `text` field and its `[start, end]`
# timestamps routinely drift (a trailing word audible in the slice but
# missing from the text, or vice versa) — pairing a mismatched (ref_audio,
# ref_text) breaks zero-shot TTS prompt priming badly enough that the
# clone can speak the reference text verbatim instead of the target text.
# Re-transcribing the actual written clip guarantees the pair matches.
def test_replaces_mismatched_text_with_the_actual_clip_transcript(self):
asr = _FakeASR(chunks_by_path={
"/tmp/ref.wav": [{"text": "hola"}, {"text": "que tal"}],
})
out = refine_ref_text("/tmp/ref.wav", asr, fallback_text="mismatched source text")
assert out == "hola que tal"
assert asr.calls == ["/tmp/ref.wav"]
def test_falls_back_to_original_text_on_asr_failure(self):
asr = _FakeASR(raises_for={"/tmp/ref.wav"})
out = refine_ref_text("/tmp/ref.wav", asr, fallback_text="original text")
assert out == "original text"
def test_falls_back_to_original_text_on_empty_transcript(self):
# A clip ASR can't get any text out of (e.g. near-silent) shouldn't
# wipe out a usable original — empty is worse than stale.
asr = _FakeASR(chunks_by_path={"/tmp/ref.wav": []})
out = refine_ref_text("/tmp/ref.wav", asr, fallback_text="original text")
assert out == "original text"
def test_no_asr_backend_is_a_strict_no_op(self):
# Preflight ASR load failure, or any other reason the caller has no
# backend to hand in — never a crash, never blocks the original path.
out = refine_ref_text("/tmp/ref.wav", None, fallback_text="original text")
assert out == "original text"
class TestRefineRefTexts:
def test_refines_every_entry_in_place_and_returns_the_dict(self):
asr = _FakeASR(chunks_by_path={
"/tmp/spk1.wav": [{"text": "hola amigo"}],
"/tmp/spk2.wav": [{"text": "buenos dias"}],
})
clones = {
"Speaker 1": {"ref_audio": "/tmp/spk1.wav", "ref_text": "stale 1"},
"Speaker 2": {"ref_audio": "/tmp/spk2.wav", "ref_text": "stale 2"},
}
out = refine_ref_texts(clones, asr)
assert out is clones # mutated in place, returned for call-and-reassign
assert clones["Speaker 1"]["ref_text"] == "hola amigo"
assert clones["Speaker 2"]["ref_text"] == "buenos dias"
def test_a_failing_entry_does_not_affect_the_others(self):
asr = _FakeASR(
chunks_by_path={"/tmp/spk2.wav": [{"text": "buenos dias"}]},
raises_for={"/tmp/spk1.wav"},
)
clones = {
"Speaker 1": {"ref_audio": "/tmp/spk1.wav", "ref_text": "kept on failure"},
"Speaker 2": {"ref_audio": "/tmp/spk2.wav", "ref_text": "stale 2"},
}
refine_ref_texts(clones, asr)
assert clones["Speaker 1"]["ref_text"] == "kept on failure"
assert clones["Speaker 2"]["ref_text"] == "buenos dias"
+31
View File
@@ -0,0 +1,31 @@
"""The conftest torch-dtype guard resets a leaked default dtype between tests.
The CI "flaky trio" (test_effects_chain / test_generation_audio_guard /
test_persona_bundle) failed intermittently on CI-Linux with signatures that
all trace to one leak: some earlier test leaves
``torch.set_default_dtype(torch.float16)`` behind. Reproduced locally with a
simulated polluter ``torch.tensor([0.1, ])`` under fp16 yields exactly the
0.0999755859375 CI observed, and Pedalboard refuses fp16 audio outright
("only supports 32-bit and 64-bit floating point"), silently returning
unmodified audio for every preset so their outputs compare identical.
These two tests are order-dependent BY DESIGN (pytest runs tests within a
file in definition order): the first leaks, the second proves the autouse
guard in conftest.py reset the leak before the next test began.
"""
import torch
def test_a_deliberate_dtype_leak():
# Simulates the CI polluter. The conftest guard must clean this up (and
# emit a UserWarning naming this exact test as the offender).
torch.set_default_dtype(torch.float16)
assert torch.get_default_dtype() is torch.float16
def test_b_next_test_starts_back_at_float32():
# If the guard is ever removed/broken, this fails — and so, eventually,
# does the flaky trio on CI, much less legibly.
assert torch.get_default_dtype() is torch.float32
# The exact fp16 signature the trio's CI failures showed, as documentation:
assert torch.tensor([0.1]).item() != 0.0999755859375
Generated
+1 -1
View File
@@ -3207,7 +3207,7 @@ wheels = [
[[package]]
name = "omnivoice"
version = "0.3.12"
version = "0.3.14"
source = { editable = "." }
dependencies = [
{ name = "accelerate" },